feat: improve chat ui messages (#132)
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user