Files
kandev/apps/backend/docs/debugging_normalization.md
T
2026-01-31 19:45:33 +00:00

326 lines
13 KiB
Markdown

# Debugging Tool Normalization Issues
This guide explains how to debug and fix issues where tool calls from agents are not rendering correctly in the frontend (e.g., a shell command showing as generic `tool_call` instead of `tool_execute`).
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Tool Call Normalization Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Agent CLI Adapter Lifecycle Orchestrator │
│ ┌────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
│ │ Codex │──JSON──│ Codex │──Event──│ Manager │──Event──│ Event │ │
│ │ Claude │ │ Adapter │ │ │ │ Handlers │ │
│ │ etc. │ └────┬─────┘ └────┬────┘ └─────┬──────┘ │
│ └────────┘ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ NormalizedPayload AgentEvent Message DB │
│ ┌─────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ kind: ... │ ───▶ │Normalized│ ──────▶│type: ... │ │
│ │ shell_exec │ │Payload │ │tool_exec │ │
│ └─────────────┘ └──────────┘ └────┬─────┘ │
│ │ │
│ ▼ │
│ WebSocket Event │
│ ┌──────────────┐ │
│ │type:tool_exec│ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ Frontend │
│ ┌──────────────┐ │
│ │ToolExecute │ │
│ │ Message.tsx │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Key Files
| Layer | File | Purpose |
|-------|------|---------|
| Types | `internal/agentctl/types/streams/tool_payload.go` | `NormalizedPayload` struct and factory functions |
| Codex Adapter | `internal/agentctl/server/adapter/transport/codex/adapter.go` | Converts Codex events to `AgentEvent` |
| Codex Normalizer | `internal/agentctl/server/adapter/transport/codex/normalize.go` | Creates `NormalizedPayload` for Codex tools |
| StreamJSON Adapter | `internal/agentctl/server/adapter/transport/streamjson/adapter.go` | Converts Claude Code events to `AgentEvent` |
| StreamJSON Normalizer | `internal/agentctl/server/adapter/transport/streamjson/normalize.go` | Creates `NormalizedPayload` for Claude tools |
| OpenCode Normalizer | `internal/agentctl/server/adapter/transport/opencode/normalize.go` | Creates `NormalizedPayload` for OpenCode tools |
| Lifecycle Events | `internal/agent/lifecycle/events.go` | Publishes `AgentStreamEvent` to event bus |
| Orchestrator Handlers | `internal/orchestrator/event_handlers.go` | Handles events and creates DB messages |
| Message Creator | `cmd/kandev/adapters.go` | `toolKindToMessageType()` maps kind to message type |
| Frontend Renderer | `apps/web/components/task/chat/message-renderer.tsx` | Routes message types to components |
## Tool Kind to Message Type Mapping
The `NormalizedPayload.Kind()` determines which frontend component renders the tool:
| Tool Kind | Message Type | Frontend Component |
|-----------|-------------|-------------------|
| `read_file` | `tool_read` | `ToolReadMessage` |
| `code_search` | `tool_search` | `ToolSearchMessage` |
| `modify_file` | `tool_edit` | `ToolEditMessage` |
| `shell_exec` | `tool_execute` | `ToolExecuteMessage` |
| `manage_todos` | `todo` | `TodoMessage` |
| `subagent_task` | `tool_call` | `ToolSubagentMessage` |
| `generic` | `tool_call` | `ToolCallMessage` |
| (nil/unknown) | `tool_call` | `ToolCallMessage` |
## Debugging Steps
### 1. Check Raw Events
Raw events are logged to `raw-<protocol>-<agent>.jsonl` files in the backend directory:
```bash
# View raw Codex events
tail -f raw-codex-codex.jsonl | jq .
# View raw Claude events
tail -f raw-streamjson-claude.jsonl | jq .
```
### 2. Check Normalized Events
Normalized events show what the adapter produced:
```bash
# View normalized events
tail -f normalized-codex-codex.jsonl | jq .
```
Look for the `normalized` field in `tool_call` events:
```json
{
"event": {
"type": "tool_call",
"normalized": {
"kind": "shell_exec",
"shell_exec": {
"command": "ls -la",
"work_dir": "/path/to/dir"
}
}
}
}
```
If `normalized` is missing or `kind` is wrong, the issue is in the adapter.
### 3. Check Database Message Type
Query the database to see what message type was stored:
```sql
SELECT id, type, metadata->>'normalized' as normalized
FROM task_session_messages
WHERE metadata->>'tool_call_id' = '<tool_call_id>'
ORDER BY created_at DESC LIMIT 1;
```
If the type is `tool_call` but should be `tool_execute`, the issue is in the message creation flow.
### 4. Check WebSocket Events
Use browser DevTools Network tab to inspect WebSocket messages. Look for `session.message.added` events and check the `type` field.
## Common Issues
### Issue 1: Missing NormalizedPayload in tool_update Events
**Symptom:** Tool calls render as generic `tool_call` instead of specialized type.
**Cause:** The adapter sends `tool_update` events (e.g., on completion) without the `NormalizedPayload`. When there's a race condition and the message doesn't exist yet, the fallback creation uses `tool_call` as default.
**Example (Codex):**
```go
// BAD - tool_update without normalized payload
update := AgentEvent{
Type: streams.EventTypeToolUpdate,
ToolCallID: p.Item.ID,
ToolStatus: "complete",
}
// GOOD - tool_update with normalized payload
update := AgentEvent{
Type: streams.EventTypeToolUpdate,
ToolCallID: p.Item.ID,
ToolStatus: "complete",
NormalizedPayload: a.normalizer.NormalizeToolCall("commandExecution", args),
}
```
**Fix Location:** Adapter's notification handler (e.g., `NotifyItemCompleted` in Codex adapter).
### Issue 2: Wrong Tool Name Passed to Normalizer
**Symptom:** Tool renders as `tool_call` (generic) instead of specialized type.
**Cause:** The normalizer's switch statement doesn't match the tool name.
**Example:**
```go
// Normalizer expects "commandExecution" but receives "command_execution"
switch toolName {
case CodexItemCommandExecution: // "commandExecution"
return n.normalizeCommand(args)
default:
return n.normalizeGeneric(toolName, args) // Falls through here
}
```
**Fix:** Check the exact tool name string in the protocol and match it in the normalizer.
### Issue 3: Factory Function Not Setting Kind
**Symptom:** `normalized.Kind()` returns empty or wrong value.
**Cause:** Factory function doesn't set the `kind` field correctly.
**Example:**
```go
// BAD - missing kind
func NewShellExec(...) *NormalizedPayload {
return &NormalizedPayload{
shellExec: &ShellExecPayload{...},
}
}
// GOOD - kind is set
func NewShellExec(...) *NormalizedPayload {
return &NormalizedPayload{
kind: ToolKindShellExec,
shellExec: &ShellExecPayload{...},
}
}
```
**Fix Location:** `internal/agentctl/types/streams/tool_payload.go`
### Issue 4: toolKindToMessageType Not Handling New Kind
**Symptom:** New tool kind renders as generic `tool_call`.
**Cause:** The `toolKindToMessageType` function doesn't have a case for the new kind.
**Fix Location:** Two places need updating:
1. `internal/orchestrator/event_handlers.go` - `toolKindToMessageType()`
2. `cmd/kandev/adapters.go` - `toolKindToMessageType()`
### Issue 5: Missing UnmarshalJSON for NormalizedPayload
**Symptom:** All tool calls render as `tool_call` even though adapter logs show correct `kind`.
**Cause:** The `NormalizedPayload` struct has unexported fields. Without a custom `UnmarshalJSON` method, the standard library cannot populate these fields when deserializing events from WebSocket/JSON.
**Example:**
```go
// NormalizedPayload with unexported fields requires both methods:
func (p *NormalizedPayload) MarshalJSON() ([]byte, error) { ... }
func (p *NormalizedPayload) UnmarshalJSON(data []byte) error { ... } // REQUIRED!
```
**Fix Location:** `internal/agentctl/types/streams/tool_payload.go`
**How to verify:** Run the round-trip test:
```bash
go test ./internal/agentctl/types/streams/... -v -run MarshalRoundTrip
```
## Adding a New Tool Kind
1. **Add kind constant** in `internal/agentctl/types/streams/tool_payload.go`:
```go
const (
ToolKindMyNewTool ToolKind = "my_new_tool"
)
```
2. **Add payload struct** with fields:
```go
type MyNewToolPayload struct {
SomeField string `json:"some_field"`
}
```
3. **Add unexported field** to `NormalizedPayload`:
```go
type NormalizedPayload struct {
kind ToolKind
myNewTool *MyNewToolPayload
// ...
}
```
4. **Add getter method**:
```go
func (p *NormalizedPayload) MyNewTool() *MyNewToolPayload { return p.myNewTool }
```
5. **Add to MarshalJSON**:
```go
type jsonPayload struct {
MyNewTool *MyNewToolPayload `json:"my_new_tool,omitempty"`
// ...
}
```
6. **Add factory function**:
```go
func NewMyNewTool(someField string) *NormalizedPayload {
return &NormalizedPayload{
kind: ToolKindMyNewTool,
myNewTool: &MyNewToolPayload{
SomeField: someField,
},
}
}
```
7. **Update normalizers** in each adapter to call the factory function.
8. **Update toolKindToMessageType** in both locations:
```go
case streams.ToolKindMyNewTool:
return "tool_my_new"
```
9. **Add frontend component** and update `message-renderer.tsx`:
```tsx
{
matches: (comment) => comment.type === 'tool_my_new',
render: (comment) => <ToolMyNewMessage comment={comment} />,
},
```
## Testing
### Unit Tests
Run normalizer tests:
```bash
go test ./internal/agentctl/server/adapter/transport/streamjson/... -v
go test ./internal/agentctl/server/adapter/transport/codex/... -v
```
### Integration Testing
1. Start a session with the agent
2. Trigger the tool you're testing
3. Check normalized events file
4. Check database message type
5. Verify frontend renders correct component
## Checklist for Fixing Normalization Issues
- [ ] Check raw events to confirm agent sends expected data
- [ ] Check normalized events to confirm adapter produces correct payload
- [ ] Verify `NormalizedPayload.Kind()` returns correct kind
- [ ] Verify `tool_call` event includes `NormalizedPayload`
- [ ] Verify `tool_update` event includes `NormalizedPayload` (for race condition handling)
- [ ] Check `toolKindToMessageType` handles the kind in both files
- [ ] Verify database stores correct message type
- [ ] Verify frontend `message-renderer.tsx` has matching adapter