From 266c98e28fcd0bbf6cace4a57cc1c3310eb3bac0 Mon Sep 17 00:00:00 2001 From: Ming Zhao Date: Thu, 9 Apr 2026 00:06:54 +0000 Subject: [PATCH 1/3] feat: add Claude CLI agent support alongside Codex Introduce a translator.Translator interface so the wrapper can drive either Codex or Claude CLI as the underlying agent. The agent type is selected via config (agentType: "claude") or env var (CODEX_REMOTE_AGENT_TYPE=claude). New packages: - internal/core/translator: defines the Translator interface and Result type - internal/adapter/claude: Claude CLI SDK protocol translator (NDJSON, stream events, control_request/response, permission flow) Key changes: - Extract BootstrapFrames from hardcoded Codex headless init into the Translator interface so each agent handles its own startup handshake - Wrapper selects translator and binary based on AgentType config - Claude child process gets CLAUDE_CODE_ENTRYPOINT=sdk-go env var - Daemon passes CODEX_REMOTE_AGENT_TYPE to headless wrapper instances - Rename OutboundToCodex -> OutboundToAgent across the codebase Claude v1 limitations: single thread per instance (no thread switching), no turn/steer support, no --resume session persistence. --- internal/adapter/claude/translator.go | 141 ++++++ .../adapter/claude/translator_bootstrap.go | 24 + .../adapter/claude/translator_commands.go | 150 +++++++ .../claude/translator_observe_client.go | 31 ++ .../claude/translator_observe_server.go | 414 ++++++++++++++++++ internal/adapter/codex/translator.go | 48 +- .../codex/translator_observe_server.go | 6 +- .../codex/translator_overrides_test.go | 8 +- .../adapter/codex/translator_requests_test.go | 2 +- internal/adapter/codex/translator_test.go | 22 +- internal/app/daemon/app_headless.go | 3 + internal/app/wrapper/app.go | 32 +- internal/app/wrapper/app_headless.go | 35 +- internal/app/wrapper/app_io.go | 38 +- internal/app/wrapper/app_process.go | 22 +- internal/app/wrapper/app_process_test.go | 4 +- internal/app/wrapper/app_test.go | 8 +- internal/config/configfile.go | 5 + internal/config/envfile.go | 17 + internal/core/control/types.go | 1 + internal/core/translator/translator.go | 34 ++ testkit/harness/harness.go | 2 +- 22 files changed, 954 insertions(+), 93 deletions(-) create mode 100644 internal/adapter/claude/translator.go create mode 100644 internal/adapter/claude/translator_bootstrap.go create mode 100644 internal/adapter/claude/translator_commands.go create mode 100644 internal/adapter/claude/translator_observe_client.go create mode 100644 internal/adapter/claude/translator_observe_server.go create mode 100644 internal/core/translator/translator.go diff --git a/internal/adapter/claude/translator.go b/internal/adapter/claude/translator.go new file mode 100644 index 00000000..3a1c785f --- /dev/null +++ b/internal/adapter/claude/translator.go @@ -0,0 +1,141 @@ +package claude + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" + "github.com/kxn/codex-remote-feishu/internal/core/translator" +) + +// Compile-time check that *Translator satisfies the translator.Translator interface. +var _ translator.Translator = (*Translator)(nil) + +// Result is an alias for the canonical translator.Result type. +type Result = translator.Result + +// permissionRequest tracks a pending can_use_tool control request. +type permissionRequest struct { + RequestID string + ThreadID string + TurnID string +} + +// Translator converts between the Claude CLI SDK protocol (NDJSON) and +// the canonical agentproto events/commands used by the relay system. +// +// Unlike Codex, Claude CLI has no thread concept. A single process corresponds +// to one session (one thread). Thread switching is not supported in v1. +type Translator struct { + instanceID string + debugLog func(string, ...any) + nextID int + sessionID string + currentThreadID string + cwd string + model string + initComplete bool + pendingInitID string + + // Turn tracking (Claude has at most one turn active per session) + turnID string + turnActive bool + turnNumber int + + // Streaming state for content blocks + activeBlockIndex int + activeBlockType string // "text", "thinking", "tool_use" + activeItemID string + activeToolName string + activeToolUseID string + + // Permission tracking + pendingPermissions map[string]permissionRequest +} + +// NewTranslator creates a new Claude translator for the given instance. +func NewTranslator(instanceID string) *Translator { + return &Translator{ + instanceID: instanceID, + pendingPermissions: map[string]permissionRequest{}, + } +} + +// SetDebugLogger configures debug logging. +func (t *Translator) SetDebugLogger(debugLog func(string, ...any)) { + t.debugLog = debugLog +} + +func (t *Translator) debugf(format string, args ...any) { + if t.debugLog != nil { + t.debugLog(format, args...) + } +} + +func (t *Translator) nextRequest(prefix string) string { + value := fmt.Sprintf("relay-%s-%d", prefix, t.nextID) + t.nextID++ + return value +} + +func (t *Translator) nextTurnID() string { + t.turnNumber++ + return fmt.Sprintf("claude-turn-%d", t.turnNumber) +} + +func (t *Translator) nextItemID(prefix string) string { + return fmt.Sprintf("%s-%s-%d", prefix, t.turnID, t.nextID) +} + +// buildUserMessage constructs a Claude user message from agentproto inputs. +func (t *Translator) buildUserMessage(inputs []agentproto.Input) map[string]any { + // Build content - if only text, use simple string; otherwise use content blocks + if len(inputs) == 1 && inputs[0].Type == agentproto.InputText { + return map[string]any{ + "type": "user", + "message": map[string]any{ + "role": "user", + "content": inputs[0].Text, + }, + } + } + var blocks []map[string]any + for _, input := range inputs { + switch input.Type { + case agentproto.InputText: + blocks = append(blocks, map[string]any{ + "type": "text", + "text": input.Text, + }) + case agentproto.InputLocalImage, agentproto.InputRemoteImage: + url := input.URL + if input.Type == agentproto.InputLocalImage { + url = "file://" + input.Path + } + blocks = append(blocks, map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "url", + "url": url, + "media_type": strings.TrimSpace(input.MIMEType), + }, + }) + } + } + return map[string]any{ + "type": "user", + "message": map[string]any{ + "role": "user", + "content": blocks, + }, + } +} + +func marshalNDJSON(v any) ([]byte, error) { + bytes, err := json.Marshal(v) + if err != nil { + return nil, err + } + return append(bytes, '\n'), nil +} diff --git a/internal/adapter/claude/translator_bootstrap.go b/internal/adapter/claude/translator_bootstrap.go new file mode 100644 index 00000000..99c4e8ff --- /dev/null +++ b/internal/adapter/claude/translator_bootstrap.go @@ -0,0 +1,24 @@ +package claude + +// BootstrapFrames returns the initial frame to send to the Claude CLI on startup. +// This sends the SDK initialize control_request. The CLI will respond with MCP +// setup requests (handled in ObserveServer) before completing initialization. +func (t *Translator) BootstrapFrames(source string, version string) ([][]byte, error) { + t.pendingInitID = t.nextRequest("init") + + payload := map[string]any{ + "type": "control_request", + "request_id": t.pendingInitID, + "request": map[string]any{ + "subtype": "initialize", + }, + } + + bytes, err := marshalNDJSON(payload) + if err != nil { + return nil, err + } + + t.debugf("bootstrap: sending initialize request=%s", t.pendingInitID) + return [][]byte{bytes}, nil +} diff --git a/internal/adapter/claude/translator_commands.go b/internal/adapter/claude/translator_commands.go new file mode 100644 index 00000000..379d5b8d --- /dev/null +++ b/internal/adapter/claude/translator_commands.go @@ -0,0 +1,150 @@ +package claude + +import ( + "fmt" + "strings" + + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" +) + +// TranslateCommand converts a canonical agentproto command into native +// Claude CLI protocol frames to write to the agent's stdin. +func (t *Translator) TranslateCommand(command agentproto.Command) ([][]byte, error) { + switch command.Kind { + case agentproto.CommandPromptSend: + return t.translatePromptSend(command) + case agentproto.CommandTurnInterrupt: + return t.translateTurnInterrupt(command) + case agentproto.CommandTurnSteer: + // Claude CLI does not support appending input mid-turn. + return nil, fmt.Errorf("turn/steer is not supported by Claude CLI") + case agentproto.CommandThreadsRefresh: + // Claude has no native thread listing. This is handled by returning + // synthetic events -- no outbound frames needed. + return nil, nil + case agentproto.CommandRequestRespond: + return t.translateRequestRespond(command) + default: + return nil, nil + } +} + +func (t *Translator) translatePromptSend(command agentproto.Command) ([][]byte, error) { + targetThread := command.Target.ThreadID + + // If targeting a specific thread that differs from our current one, reject. + // Claude CLI is single-session; thread switching requires process restart. + if targetThread != "" && t.currentThreadID != "" && targetThread != t.currentThreadID { + t.debugf("translate prompt: rejecting thread switch from %s to %s", t.currentThreadID, targetThread) + return nil, fmt.Errorf("Claude instance does not support thread switching (current=%s, target=%s)", t.currentThreadID, targetThread) + } + + // Assign a thread ID if this is the first prompt + if t.currentThreadID == "" { + if targetThread != "" { + t.currentThreadID = targetThread + } else { + t.currentThreadID = fmt.Sprintf("claude-session-%s", t.instanceID) + } + } + + msg := t.buildUserMessage(command.Prompt.Inputs) + bytes, err := marshalNDJSON(msg) + if err != nil { + return nil, err + } + + t.debugf( + "translate prompt: thread=%s surface=%s inputs=%d", + t.currentThreadID, + firstNonEmpty(command.Origin.Surface, command.Origin.ChatID), + len(command.Prompt.Inputs), + ) + + return [][]byte{bytes}, nil +} + +func (t *Translator) translateTurnInterrupt(_ agentproto.Command) ([][]byte, error) { + payload := map[string]any{ + "type": "control_request", + "request_id": t.nextRequest("interrupt"), + "request": map[string]any{ + "subtype": "interrupt", + }, + } + bytes, err := marshalNDJSON(payload) + if err != nil { + return nil, err + } + t.debugf("translate interrupt: thread=%s turn=%s", t.currentThreadID, t.turnID) + return [][]byte{bytes}, nil +} + +func (t *Translator) translateRequestRespond(command agentproto.Command) ([][]byte, error) { + requestID := command.Request.RequestID + if requestID == "" { + return nil, nil + } + + pending, exists := t.pendingPermissions[requestID] + if !exists { + t.debugf("translate request respond: unknown request %s", requestID) + return nil, nil + } + delete(t.pendingPermissions, requestID) + + responseType, _ := command.Request.Response["type"].(string) + var behavior string + switch responseType { + case "approval": + if decision, _ := command.Request.Response["decision"].(string); strings.TrimSpace(decision) != "" { + if decision == "accept" { + behavior = "allow" + } else { + behavior = "deny" + } + } else { + approved, _ := command.Request.Response["approved"].(bool) + if approved { + behavior = "allow" + } else { + behavior = "deny" + } + } + default: + approved, _ := command.Request.Response["approved"].(bool) + if approved { + behavior = "allow" + } else { + behavior = "deny" + } + } + + response := map[string]any{ + "type": "control_response", + "response": map[string]any{ + "subtype": "success", + "request_id": pending.RequestID, + "response": map[string]any{ + "behavior": behavior, + "updatedInput": map[string]any{}, + }, + }, + } + + bytes, err := marshalNDJSON(response) + if err != nil { + return nil, err + } + t.debugf("translate request respond: request=%s behavior=%s", requestID, behavior) + return [][]byte{bytes}, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/internal/adapter/claude/translator_observe_client.go b/internal/adapter/claude/translator_observe_client.go new file mode 100644 index 00000000..69c37a46 --- /dev/null +++ b/internal/adapter/claude/translator_observe_client.go @@ -0,0 +1,31 @@ +package claude + +import ( + "encoding/json" + + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" +) + +// ObserveClient translates a line from the parent's stdin into canonical events. +// For Claude, the parent may send user messages or control requests. +func (t *Translator) ObserveClient(raw []byte) (Result, error) { + var message map[string]any + if err := json.Unmarshal(raw, &message); err != nil { + return Result{}, err + } + + msgType, _ := message["type"].(string) + switch msgType { + case "user": + t.debugf("observe client user message: thread=%s", t.currentThreadID) + return Result{Events: []agentproto.Event{{ + Kind: agentproto.EventLocalInteractionObserved, + ThreadID: t.currentThreadID, + Action: "turn_start", + TrafficClass: agentproto.TrafficClassPrimary, + Initiator: agentproto.Initiator{Kind: agentproto.InitiatorLocalUI}, + }}}, nil + default: + return Result{}, nil + } +} diff --git a/internal/adapter/claude/translator_observe_server.go b/internal/adapter/claude/translator_observe_server.go new file mode 100644 index 00000000..67c1c575 --- /dev/null +++ b/internal/adapter/claude/translator_observe_server.go @@ -0,0 +1,414 @@ +package claude + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" +) + +// ObserveServer parses a line from the Claude CLI stdout and converts it +// into canonical agentproto events. It may also return OutboundToAgent +// frames to write back to the CLI (e.g. control_response for MCP setup). +func (t *Translator) ObserveServer(raw []byte) (Result, error) { + var message map[string]any + if err := json.Unmarshal(raw, &message); err != nil { + return Result{}, err + } + + msgType, _ := message["type"].(string) + switch msgType { + case "system": + return t.observeSystem(message) + case "stream_event": + return t.observeStreamEvent(message) + case "assistant": + return t.observeAssistant(message) + case "result": + return t.observeResult(message) + case "control_request": + return t.observeControlRequest(message) + case "control_response": + return t.observeControlResponse(message) + case "user": + // CLI echoing back tool results -- suppress from relay + return Result{}, nil + default: + return Result{}, nil + } +} + +// observeSystem handles system messages (session init). +func (t *Translator) observeSystem(message map[string]any) (Result, error) { + subtype, _ := message["subtype"].(string) + if subtype != "init" { + return Result{}, nil + } + + t.sessionID = stringField(message, "session_id") + t.model = stringField(message, "model") + cwd := stringField(message, "cwd") + if cwd != "" { + t.cwd = cwd + } + t.initComplete = true + + // Assign thread ID from session if not yet assigned + if t.currentThreadID == "" { + t.currentThreadID = fmt.Sprintf("claude-session-%s", t.instanceID) + } + + t.debugf("observe system init: session=%s model=%s cwd=%s thread=%s", t.sessionID, t.model, t.cwd, t.currentThreadID) + + var events []agentproto.Event + + events = append(events, agentproto.Event{ + Kind: agentproto.EventThreadDiscovered, + ThreadID: t.currentThreadID, + CWD: t.cwd, + Name: "Claude Session", + FocusSource: "remote_created_thread", + }) + + if t.model != "" { + events = append(events, agentproto.Event{ + Kind: agentproto.EventConfigObserved, + Model: t.model, + CWD: t.cwd, + Loaded: true, + }) + } + + return Result{Events: events}, nil +} + +// observeStreamEvent handles real-time streaming deltas from the Claude CLI. +func (t *Translator) observeStreamEvent(message map[string]any) (Result, error) { + // stream_event wraps an inner event field + event, _ := message["event"].(map[string]any) + if event == nil { + // Some stream_events have the event type at top level + event = message + } + + eventType, _ := event["type"].(string) + switch eventType { + case "message_start": + return t.observeMessageStart(event) + case "content_block_start": + return t.observeContentBlockStart(event) + case "content_block_delta": + return t.observeContentBlockDelta(event) + case "content_block_stop": + return t.observeContentBlockStop(event) + case "message_delta": + // Message-level metadata update (e.g. stop_reason) -- no direct mapping needed + return Result{}, nil + case "message_stop": + // Wait for the result message for turn completion + return Result{}, nil + default: + return Result{}, nil + } +} + +func (t *Translator) observeMessageStart(event map[string]any) (Result, error) { + t.turnID = t.nextTurnID() + t.turnActive = true + t.activeBlockIndex = -1 + + t.debugf("observe message_start: thread=%s turn=%s", t.currentThreadID, t.turnID) + + return Result{Events: []agentproto.Event{{ + Kind: agentproto.EventTurnStarted, + ThreadID: t.currentThreadID, + TurnID: t.turnID, + Status: "running", + }}}, nil +} + +func (t *Translator) observeContentBlockStart(event map[string]any) (Result, error) { + t.activeBlockIndex++ + block, _ := event["content_block"].(map[string]any) + blockType, _ := block["type"].(string) + t.activeBlockType = blockType + t.activeItemID = fmt.Sprintf("item-%s-%d", t.turnID, t.activeBlockIndex) + + var itemKind string + switch blockType { + case "text": + itemKind = "agent_message" + case "thinking": + itemKind = "reasoning_content" + case "tool_use": + itemKind = "command_execution" + t.activeToolName = stringField(block, "name") + t.activeToolUseID = stringField(block, "id") + default: + itemKind = blockType + } + + t.debugf("observe content_block_start: index=%d type=%s itemKind=%s tool=%s", + t.activeBlockIndex, blockType, itemKind, t.activeToolName) + + metadata := map[string]any{"blockIndex": t.activeBlockIndex} + if t.activeToolName != "" { + metadata["toolName"] = t.activeToolName + metadata["toolUseId"] = t.activeToolUseID + } + + return Result{Events: []agentproto.Event{{ + Kind: agentproto.EventItemStarted, + ThreadID: t.currentThreadID, + TurnID: t.turnID, + ItemID: t.activeItemID, + ItemKind: itemKind, + Metadata: metadata, + }}}, nil +} + +func (t *Translator) observeContentBlockDelta(event map[string]any) (Result, error) { + delta, _ := event["delta"].(map[string]any) + if delta == nil { + return Result{}, nil + } + + deltaType, _ := delta["type"].(string) + var itemKind, text string + + switch deltaType { + case "text_delta": + itemKind = "agent_message" + text, _ = delta["text"].(string) + case "thinking_delta": + itemKind = "reasoning_content" + text, _ = delta["thinking"].(string) + case "input_json_delta": + itemKind = "command_execution" + text, _ = delta["partial_json"].(string) + default: + return Result{}, nil + } + + if text == "" { + return Result{}, nil + } + + return Result{Events: []agentproto.Event{{ + Kind: agentproto.EventItemDelta, + ThreadID: t.currentThreadID, + TurnID: t.turnID, + ItemID: t.activeItemID, + ItemKind: itemKind, + Delta: text, + }}}, nil +} + +func (t *Translator) observeContentBlockStop(event map[string]any) (Result, error) { + itemKind := "agent_message" + switch t.activeBlockType { + case "thinking": + itemKind = "reasoning_content" + case "tool_use": + itemKind = "command_execution" + case "text": + itemKind = "agent_message" + } + + t.debugf("observe content_block_stop: index=%d type=%s", t.activeBlockIndex, t.activeBlockType) + + result := Result{Events: []agentproto.Event{{ + Kind: agentproto.EventItemCompleted, + ThreadID: t.currentThreadID, + TurnID: t.turnID, + ItemID: t.activeItemID, + ItemKind: itemKind, + }}} + + // Reset active tool state + t.activeToolName = "" + t.activeToolUseID = "" + + return result, nil +} + +// observeAssistant handles complete assistant messages. +func (t *Translator) observeAssistant(message map[string]any) (Result, error) { + // The assistant message contains the full content blocks. + // If we've been tracking stream events, these are already emitted. + // We only need this for cases where streaming didn't provide all info. + return Result{}, nil +} + +// observeResult handles turn completion messages. +func (t *Translator) observeResult(message map[string]any) (Result, error) { + isError, _ := message["is_error"].(bool) + status := "completed" + errorMsg := "" + if isError { + status = "failed" + errorMsg, _ = message["result"].(string) + } + + turnID := t.turnID + t.turnActive = false + + t.debugf("observe result: thread=%s turn=%s status=%s isError=%t", t.currentThreadID, turnID, status, isError) + + return Result{Events: []agentproto.Event{{ + Kind: agentproto.EventTurnCompleted, + ThreadID: t.currentThreadID, + TurnID: turnID, + Status: status, + ErrorMessage: errorMsg, + }}}, nil +} + +// observeControlRequest handles control requests from the CLI. +// These may be tool permission checks or MCP setup during initialization. +func (t *Translator) observeControlRequest(message map[string]any) (Result, error) { + requestID := stringField(message, "request_id") + request, _ := message["request"].(map[string]any) + if request == nil { + return Result{}, nil + } + + subtype, _ := request["subtype"].(string) + switch subtype { + case "can_use_tool": + return t.observeCanUseTool(requestID, request) + case "mcp_message": + return t.observeMCPMessage(requestID, request) + default: + t.debugf("observe control_request: unknown subtype=%s request=%s", subtype, requestID) + return Result{}, nil + } +} + +// observeCanUseTool translates a tool permission request to an EventRequestStarted. +func (t *Translator) observeCanUseTool(requestID string, request map[string]any) (Result, error) { + toolName, _ := request["tool_name"].(string) + toolInput, _ := request["input"].(map[string]any) + + // Generate a canonical request ID for the relay + canonicalID := fmt.Sprintf("perm-%s", requestID) + + t.pendingPermissions[canonicalID] = permissionRequest{ + RequestID: requestID, + ThreadID: t.currentThreadID, + TurnID: t.turnID, + } + + // Build metadata for the orchestrator + title := fmt.Sprintf("Allow %s?", toolName) + body := "" + if toolInput != nil { + if cmd, ok := toolInput["command"].(string); ok { + body = cmd + } else { + raw, _ := json.Marshal(toolInput) + body = string(raw) + } + } + + metadata := map[string]any{ + "type": "approval", + "title": title, + "body": body, + "options": []map[string]any{ + {"id": "accept", "label": "Allow", "style": "primary"}, + {"id": "decline", "label": "Deny", "style": "default"}, + }, + "toolName": toolName, + } + + t.debugf("observe can_use_tool: request=%s tool=%s", requestID, toolName) + + return Result{Events: []agentproto.Event{{ + Kind: agentproto.EventRequestStarted, + ThreadID: t.currentThreadID, + TurnID: t.turnID, + RequestID: canonicalID, + Status: "pending", + Metadata: metadata, + }}}, nil +} + +// observeMCPMessage handles MCP setup messages during initialization. +// These are handled internally by responding immediately. +func (t *Translator) observeMCPMessage(requestID string, request map[string]any) (Result, error) { + serverName, _ := request["server_name"].(string) + mcpMsg, _ := request["message"].(map[string]any) + method, _ := mcpMsg["method"].(string) + rpcID := mcpMsg["id"] + + t.debugf("observe mcp_message: request=%s server=%s method=%s", requestID, serverName, method) + + // For MCP setup during init, respond with appropriate defaults + var mcpResult any + switch method { + case "initialize": + mcpResult = map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{}, + "serverInfo": map[string]any{ + "name": "codex-remote", + "version": "1.0", + }, + } + case "notifications/initialized": + mcpResult = map[string]any{} + case "tools/list": + mcpResult = map[string]any{ + "tools": []any{}, + } + default: + mcpResult = map[string]any{} + } + + response := map[string]any{ + "type": "control_response", + "response": map[string]any{ + "subtype": "success", + "request_id": requestID, + "response": map[string]any{ + "mcp_response": map[string]any{ + "jsonrpc": "2.0", + "id": rpcID, + "result": mcpResult, + }, + }, + }, + } + + bytes, err := marshalNDJSON(response) + if err != nil { + return Result{}, err + } + + return Result{OutboundToAgent: [][]byte{bytes}}, nil +} + +// observeControlResponse handles control responses from the CLI. +func (t *Translator) observeControlResponse(message map[string]any) (Result, error) { + response, _ := message["response"].(map[string]any) + if response == nil { + return Result{}, nil + } + requestID := stringField(response, "request_id") + + // Check if this is the init response + if requestID == t.pendingInitID { + t.debugf("observe control_response: init complete request=%s", requestID) + t.pendingInitID = "" + return Result{}, nil + } + + return Result{}, nil +} + +func stringField(m map[string]any, key string) string { + v, _ := m[key].(string) + return strings.TrimSpace(v) +} diff --git a/internal/adapter/codex/translator.go b/internal/adapter/codex/translator.go index d1ad786e..7f2aed5c 100644 --- a/internal/adapter/codex/translator.go +++ b/internal/adapter/codex/translator.go @@ -1,6 +1,15 @@ package codex -import "github.com/kxn/codex-remote-feishu/internal/core/agentproto" +import ( + "encoding/json" + "strings" + + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" + "github.com/kxn/codex-remote-feishu/internal/core/translator" +) + +// Compile-time check that *Translator satisfies the translator.Translator interface. +var _ translator.Translator = (*Translator)(nil) type Translator struct { instanceID string @@ -52,11 +61,8 @@ type suppressedResponseContext struct { ThreadID string } -type Result struct { - Events []agentproto.Event - OutboundToCodex [][]byte - Suppress bool -} +// Result is an alias for the canonical translator.Result type. +type Result = translator.Result func NewTranslator(instanceID string) *Translator { return &Translator{ @@ -89,3 +95,33 @@ func (t *Translator) debugf(format string, args ...any) { t.debugLog(format, args...) } } + +// BootstrapFrames returns synthetic frames to send to the Codex child process +// on startup. For headless sources, this sends a JSON-RPC initialize request. +func (t *Translator) BootstrapFrames(source string, version string) ([][]byte, error) { + if !strings.EqualFold(strings.TrimSpace(source), "headless") { + return nil, nil + } + if version == "" { + version = "dev" + } + payload := map[string]any{ + "id": "relay-bootstrap-initialize", + "method": "initialize", + "params": map[string]any{ + "clientInfo": map[string]any{ + "name": "Codex Remote Headless", + "title": "Codex Remote Headless", + "version": version, + }, + "capabilities": map[string]any{ + "experimentalApi": true, + }, + }, + } + bytes, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return [][]byte{append(bytes, '\n')}, nil +} diff --git a/internal/adapter/codex/translator_observe_server.go b/internal/adapter/codex/translator_observe_server.go index eff3c34c..d088e616 100644 --- a/internal/adapter/codex/translator_observe_server.go +++ b/internal/adapter/codex/translator_observe_server.go @@ -83,7 +83,7 @@ func (t *Translator) ObserveServer(raw []byte) (Result, error) { t.debugf("observe server thread/start result: request=%s thread=%s followup=%s", requestID, threadID, followupID) return Result{ Suppress: true, - OutboundToCodex: [][]byte{followup}, + OutboundToAgent: [][]byte{followup}, }, nil } if pending, exists := t.pendingThreadResume[requestID]; exists { @@ -108,7 +108,7 @@ func (t *Translator) ObserveServer(raw []byte) (Result, error) { t.debugf("observe server thread/resume result: request=%s thread=%s followup=%s", requestID, pending.ThreadID, followupID) return Result{ Suppress: true, - OutboundToCodex: [][]byte{followup}, + OutboundToAgent: [][]byte{followup}, }, nil } if pending, exists := t.pendingThreadNameSet[requestID]; exists { @@ -167,7 +167,7 @@ func (t *Translator) ObserveServer(raw []byte) (Result, error) { } outbound = append(outbound, append(bytes, '\n')) } - return Result{Suppress: true, OutboundToCodex: outbound}, nil + return Result{Suppress: true, OutboundToAgent: outbound}, nil } if threadID, exists := t.pendingThreadReads[requestID]; exists { record := t.threadRefreshRecords[threadID] diff --git a/internal/adapter/codex/translator_overrides_test.go b/internal/adapter/codex/translator_overrides_test.go index 7fb6ace1..7bf2fbe5 100644 --- a/internal/adapter/codex/translator_overrides_test.go +++ b/internal/adapter/codex/translator_overrides_test.go @@ -81,11 +81,11 @@ func TestTranslatePromptSendAppliesOverridesToNewThreadStartAndFollowupTurn(t *t if err != nil { t.Fatalf("observe server response: %v", err) } - if len(result.OutboundToCodex) != 1 { + if len(result.OutboundToAgent) != 1 { t.Fatalf("expected followup turn/start, got %#v", result) } var turnStart map[string]any - if err := json.Unmarshal(result.OutboundToCodex[0], &turnStart); err != nil { + if err := json.Unmarshal(result.OutboundToAgent[0], &turnStart); err != nil { t.Fatalf("unmarshal followup turn/start: %v", err) } turnParams, _ := turnStart["params"].(map[string]any) @@ -127,11 +127,11 @@ func TestTranslatePromptSendConfirmAccessModeOverridesPolicies(t *testing.T) { if err != nil { t.Fatalf("observe server response: %v", err) } - if len(result.OutboundToCodex) != 1 { + if len(result.OutboundToAgent) != 1 { t.Fatalf("expected followup turn/start, got %#v", result) } var turnStart map[string]any - if err := json.Unmarshal(result.OutboundToCodex[0], &turnStart); err != nil { + if err := json.Unmarshal(result.OutboundToAgent[0], &turnStart); err != nil { t.Fatalf("unmarshal followup turn/start: %v", err) } turnParams, _ := turnStart["params"].(map[string]any) diff --git a/internal/adapter/codex/translator_requests_test.go b/internal/adapter/codex/translator_requests_test.go index 18881b19..b8a428fa 100644 --- a/internal/adapter/codex/translator_requests_test.go +++ b/internal/adapter/codex/translator_requests_test.go @@ -288,7 +288,7 @@ func TestTranslateThreadsRefreshUsesThreadListAndBuildsSnapshot(t *testing.T) { if err != nil { t.Fatalf("observe thread/list response: %v", err) } - if !refreshed.Suppress || len(refreshed.OutboundToCodex) != 2 { + if !refreshed.Suppress || len(refreshed.OutboundToAgent) != 2 { t.Fatalf("expected suppressed thread/read followups, got %#v", refreshed) } diff --git a/internal/adapter/codex/translator_test.go b/internal/adapter/codex/translator_test.go index af8ca76b..ad541ebd 100644 --- a/internal/adapter/codex/translator_test.go +++ b/internal/adapter/codex/translator_test.go @@ -135,11 +135,11 @@ func TestTranslatePromptSendToNewThreadAndFollowupTurnStart(t *testing.T) { if err != nil { t.Fatalf("observe server response: %v", err) } - if !result.Suppress || len(result.OutboundToCodex) != 1 { + if !result.Suppress || len(result.OutboundToAgent) != 1 { t.Fatalf("expected suppressed followup turn/start, got %#v", result) } var turnStart map[string]any - if err := json.Unmarshal(result.OutboundToCodex[0], &turnStart); err != nil { + if err := json.Unmarshal(result.OutboundToAgent[0], &turnStart); err != nil { t.Fatalf("unmarshal turn/start: %v", err) } if turnStart["method"] != "turn/start" { @@ -240,7 +240,7 @@ func TestRemoteNewThreadStartClearsStaleLocalNewThreadMarker(t *testing.T) { if err != nil { t.Fatalf("observe server response: %v", err) } - if !result.Suppress || len(result.OutboundToCodex) != 1 { + if !result.Suppress || len(result.OutboundToAgent) != 1 { t.Fatalf("expected suppressed followup turn/start, got %#v", result) } started, err := tr.ObserveServer([]byte(`{"method":"turn/started","params":{"threadId":"thread-created","turn":{"id":"turn-1"}}}`)) @@ -283,12 +283,12 @@ func TestTranslatePromptSendToExistingThreadResumesWhenTargetDiffersFromCurrent( if err != nil { t.Fatalf("observe resume response: %v", err) } - if !result.Suppress || len(result.OutboundToCodex) != 1 { + if !result.Suppress || len(result.OutboundToAgent) != 1 { t.Fatalf("expected suppressed followup turn/start, got %#v", result) } var turnStart map[string]any - if err := json.Unmarshal(result.OutboundToCodex[0], &turnStart); err != nil { + if err := json.Unmarshal(result.OutboundToAgent[0], &turnStart); err != nil { t.Fatalf("unmarshal turn/start: %v", err) } if turnStart["method"] != "turn/start" { @@ -317,12 +317,12 @@ func TestTranslatePromptSendReasoningOnlyDoesNotCreateInvalidCollaborationMode(t if err != nil { t.Fatalf("observe resume response: %v", err) } - if !result.Suppress || len(result.OutboundToCodex) != 1 { + if !result.Suppress || len(result.OutboundToAgent) != 1 { t.Fatalf("expected suppressed followup turn/start, got %#v", result) } var turnStart map[string]any - if err := json.Unmarshal(result.OutboundToCodex[0], &turnStart); err != nil { + if err := json.Unmarshal(result.OutboundToAgent[0], &turnStart); err != nil { t.Fatalf("unmarshal turn/start: %v", err) } params, _ := turnStart["params"].(map[string]any) @@ -350,7 +350,7 @@ func TestObserveServerThreadResumeErrorEmitsFailedTurnCompleted(t *testing.T) { if err != nil { t.Fatalf("observe resume error: %v", err) } - if len(result.OutboundToCodex) != 0 || len(result.Events) != 1 { + if len(result.OutboundToAgent) != 0 || len(result.Events) != 1 { t.Fatalf("expected failed event without followup, got %#v", result) } if result.Events[0].Kind != agentproto.EventTurnCompleted || result.Events[0].Status != "failed" || result.Events[0].ThreadID != "thread-2" { @@ -377,12 +377,12 @@ func TestObserveServerSuppressedTurnStartErrorEmitsFailedTurnCompleted(t *testin if err != nil { t.Fatalf("observe resume response: %v", err) } - if len(result.OutboundToCodex) != 1 { + if len(result.OutboundToAgent) != 1 { t.Fatalf("expected followup turn/start, got %#v", result) } var turnStart map[string]any - if err := json.Unmarshal(result.OutboundToCodex[0], &turnStart); err != nil { + if err := json.Unmarshal(result.OutboundToAgent[0], &turnStart); err != nil { t.Fatalf("unmarshal turn/start: %v", err) } requestID, _ := turnStart["id"].(string) @@ -394,7 +394,7 @@ func TestObserveServerSuppressedTurnStartErrorEmitsFailedTurnCompleted(t *testin if err != nil { t.Fatalf("observe turn/start error: %v", err) } - if len(failed.OutboundToCodex) != 0 || len(failed.Events) != 1 { + if len(failed.OutboundToAgent) != 0 || len(failed.Events) != 1 { t.Fatalf("expected failed event without suppression, got %#v", failed) } if failed.Events[0].Kind != agentproto.EventTurnCompleted || failed.Events[0].Status != "failed" || failed.Events[0].ThreadID != "thread-2" { diff --git a/internal/app/daemon/app_headless.go b/internal/app/daemon/app_headless.go index 9e9f5cd5..473ec346 100644 --- a/internal/app/daemon/app_headless.go +++ b/internal/app/daemon/app_headless.go @@ -54,6 +54,9 @@ func (a *App) startManagedHeadless(command control.DaemonCommand) []control.UIEv "CODEX_REMOTE_INSTANCE_SOURCE=headless", "CODEX_REMOTE_INSTANCE_MANAGED=1", ) + if strings.TrimSpace(command.AgentType) != "" { + env = append(env, "CODEX_REMOTE_AGENT_TYPE="+command.AgentType) + } if strings.TrimSpace(command.ThreadCWD) == "" { env = append(env, "CODEX_REMOTE_INSTANCE_DISPLAY_NAME=headless") } diff --git a/internal/app/wrapper/app.go b/internal/app/wrapper/app.go index d8d3c1ed..bec7c7fa 100644 --- a/internal/app/wrapper/app.go +++ b/internal/app/wrapper/app.go @@ -11,17 +11,19 @@ import ( "strings" "time" + "github.com/kxn/codex-remote-feishu/internal/adapter/claude" "github.com/kxn/codex-remote-feishu/internal/adapter/codex" "github.com/kxn/codex-remote-feishu/internal/adapter/relayws" "github.com/kxn/codex-remote-feishu/internal/config" "github.com/kxn/codex-remote-feishu/internal/core/agentproto" + "github.com/kxn/codex-remote-feishu/internal/core/translator" "github.com/kxn/codex-remote-feishu/internal/debuglog" relayruntime "github.com/kxn/codex-remote-feishu/internal/runtime" ) type App struct { config Config - translator *codex.Translator + translator translator.Translator } const ( @@ -37,6 +39,8 @@ type shutdownRequest struct { type Config struct { RelayServerURL string CodexRealBinary string + AgentType string + AgentBinary string NameMode string Args []string ConfigPath string @@ -104,6 +108,8 @@ func LoadConfig(args []string) (Config, error) { return Config{ RelayServerURL: loaded.RelayServerURL, CodexRealBinary: loaded.CodexRealBinary, + AgentType: loaded.AgentType, + AgentBinary: loaded.AgentBinary, NameMode: loaded.NameMode, Args: args, ConfigPath: firstNonEmpty(services.ConfigPath, loaded.ConfigPath, paths.ConfigFile), @@ -128,15 +134,21 @@ func LoadConfig(args []string) (Config, error) { } func New(cfg Config) *App { - translator := codex.NewTranslator(cfg.InstanceID) + var t translator.Translator + switch strings.TrimSpace(strings.ToLower(cfg.AgentType)) { + case "claude": + t = claude.NewTranslator(cfg.InstanceID) + default: + t = codex.NewTranslator(cfg.InstanceID) + } if cfg.DebugRelayFlow { - translator.SetDebugLogger(func(format string, args ...any) { + t.SetDebugLogger(func(format string, args ...any) { log.Printf("relay flow translator: "+format, args...) }) } return &App{ config: cfg, - translator: translator, + translator: t, } } @@ -177,19 +189,23 @@ func (a *App) Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer childCtx, childCancel := context.WithCancel(ctx) defer childCancel() - cmd := exec.CommandContext(childCtx, a.config.CodexRealBinary, a.config.Args...) + agentBinary := a.config.CodexRealBinary + if a.config.AgentBinary != "" { + agentBinary = a.config.AgentBinary + } + cmd := exec.CommandContext(childCtx, agentBinary, a.config.Args...) cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil cmd.Dir = a.config.WorkspaceRoot cmd.Env = childEnvWithProxy(a.config.ChildProxyEnv) - configureCodexChildProcess(cmd, a.config) + configureChildProcess(cmd, a.config) childStdin, childStdout, childStderr, err := startChild(cmd) if err != nil { return 1, err } - a.debugf("child started: binary=%s pid=%d cwd=%s", a.config.CodexRealBinary, cmd.Process.Pid, a.config.WorkspaceRoot) + a.debugf("child started: binary=%s pid=%d cwd=%s agent=%s", agentBinary, cmd.Process.Pid, a.config.WorkspaceRoot, a.config.AgentType) writeCh := make(chan []byte, 128) errCh := make(chan error, 8) @@ -197,7 +213,7 @@ func (a *App) Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer commandResponses := newCommandResponseTracker() shutdownCh := make(chan shutdownRequest, 1) - if err := a.bootstrapHeadlessCodex(childStdin, rawLogger, problems.Emit); err != nil { + if err := a.bootstrapAgent(childStdin, rawLogger, problems.Emit); err != nil { childCancel() _ = cmd.Wait() return 1, err diff --git a/internal/app/wrapper/app_headless.go b/internal/app/wrapper/app_headless.go index bbfbb9a9..a807920a 100644 --- a/internal/app/wrapper/app_headless.go +++ b/internal/app/wrapper/app_headless.go @@ -1,49 +1,22 @@ package wrapper import ( - "encoding/json" "io" - "strings" "github.com/kxn/codex-remote-feishu/internal/core/agentproto" "github.com/kxn/codex-remote-feishu/internal/debuglog" ) -func (a *App) bootstrapHeadlessCodex(childStdin io.Writer, rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) error { - frames, err := a.syntheticBootstrapFrames() +func (a *App) bootstrapAgent(childStdin io.Writer, rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) error { + frames, err := a.translator.BootstrapFrames(a.config.Source, a.config.Version) if err != nil || len(frames) == 0 { return err } - a.debugf("headless bootstrap: frames=%s", summarizeFrames(frames)) + a.debugf("agent bootstrap: frames=%s", summarizeFrames(frames)) for _, frame := range frames { - if err := writeCodexFrame(childStdin, frame, a.debugf, rawLogger, reportProblem); err != nil { + if err := writeAgentFrame(childStdin, frame, a.debugf, rawLogger, reportProblem); err != nil { return err } } return nil } - -func (a *App) syntheticBootstrapFrames() ([][]byte, error) { - if !strings.EqualFold(strings.TrimSpace(a.config.Source), "headless") { - return nil, nil - } - payload := map[string]any{ - "id": "relay-bootstrap-initialize", - "method": "initialize", - "params": map[string]any{ - "clientInfo": map[string]any{ - "name": "Codex Remote Headless", - "title": "Codex Remote Headless", - "version": firstNonEmpty(a.config.Version, "dev"), - }, - "capabilities": map[string]any{ - "experimentalApi": true, - }, - }, - } - bytes, err := json.Marshal(payload) - if err != nil { - return nil, err - } - return [][]byte{append(bytes, '\n')}, nil -} diff --git a/internal/app/wrapper/app_io.go b/internal/app/wrapper/app_io.go index 78d21129..c10c9b63 100644 --- a/internal/app/wrapper/app_io.go +++ b/internal/app/wrapper/app_io.go @@ -8,13 +8,13 @@ import ( "log" "strings" - "github.com/kxn/codex-remote-feishu/internal/adapter/codex" "github.com/kxn/codex-remote-feishu/internal/adapter/relayws" "github.com/kxn/codex-remote-feishu/internal/core/agentproto" + "github.com/kxn/codex-remote-feishu/internal/core/translator" "github.com/kxn/codex-remote-feishu/internal/debuglog" ) -func stdinLoop(ctx context.Context, stdin io.Reader, writeCh chan<- []byte, translator *codex.Translator, client *relayws.Client, errCh chan<- error, debugf func(string, ...any), rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) { +func stdinLoop(ctx context.Context, stdin io.Reader, writeCh chan<- []byte, translator translator.Translator, client *relayws.Client, errCh chan<- error, debugf func(string, ...any), rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) { reader := bufio.NewReader(stdin) for { line, err := reader.ReadBytes('\n') @@ -24,8 +24,8 @@ func stdinLoop(ctx context.Context, stdin io.Reader, writeCh chan<- []byte, tran debugf("stdin from parent: %s", summarizeFrame(line)) } if result, parseErr := translator.ObserveClient(line); parseErr == nil { - if debugf != nil && (len(result.Events) > 0 || len(result.OutboundToCodex) > 0 || result.Suppress) { - debugf("stdin observe result: events=%s followups=%d suppress=%t", summarizeEventKinds(result.Events), len(result.OutboundToCodex), result.Suppress) + if debugf != nil && (len(result.Events) > 0 || len(result.OutboundToAgent) > 0 || result.Suppress) { + debugf("stdin observe result: events=%s followups=%d suppress=%t", summarizeEventKinds(result.Events), len(result.OutboundToAgent), result.Suppress) } if sendErr := client.SendEvents(result.Events); sendErr != nil { log.Printf("relay send client events failed: %v", sendErr) @@ -58,7 +58,7 @@ func stdinLoop(ctx context.Context, stdin io.Reader, writeCh chan<- []byte, tran select { case writeCh <- line: if debugf != nil { - debugf("stdin forwarded to codex: %s", summarizeFrame(line)) + debugf("stdin forwarded to agent: %s", summarizeFrame(line)) } case <-ctx.Done(): return @@ -75,7 +75,7 @@ func stdinLoop(ctx context.Context, stdin io.Reader, writeCh chan<- []byte, tran } } -func stdoutLoop(ctx context.Context, childStdout io.Reader, parentStdout io.Writer, writeCh chan<- []byte, translator *codex.Translator, client *relayws.Client, commandResponses *commandResponseTracker, errCh chan<- error, debugf func(string, ...any), rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) { +func stdoutLoop(ctx context.Context, childStdout io.Reader, parentStdout io.Writer, writeCh chan<- []byte, translator translator.Translator, client *relayws.Client, commandResponses *commandResponseTracker, errCh chan<- error, debugf func(string, ...any), rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) { reader := bufio.NewReader(childStdout) coalescer := newRelayEventCoalescer(nil, 0, 0) sendRelayEvents := func(events []agentproto.Event) { @@ -89,7 +89,7 @@ func stdoutLoop(ctx context.Context, childStdout io.Reader, parentStdout io.Writ Code: "relay_send_server_events_failed", Layer: "wrapper", Stage: "forward_server_events", - Operation: "codex.stdout", + Operation: "agent.stdout", Message: "wrapper 无法把 Codex 事件发送到 relay。", Retryable: true, })) @@ -100,9 +100,9 @@ func stdoutLoop(ctx context.Context, childStdout io.Reader, parentStdout io.Writ for { line, err := reader.ReadBytes('\n') if len(line) > 0 { - logRawFrame(rawLogger, "codex.stdout", "in", line, "", "") + logRawFrame(rawLogger, "agent.stdout", "in", line, "", "") if debugf != nil { - debugf("stdout from codex: %s", summarizeFrame(line)) + debugf("stdout from agent: %s", summarizeFrame(line)) } _, suppressCommandResponse := commandResponses.Resolve(line) result, parseErr := translator.ObserveServer(line) @@ -111,17 +111,17 @@ func stdoutLoop(ctx context.Context, childStdout io.Reader, parentStdout io.Writ debugf( "stdout observe result: events=%s followups=%d frames=%s suppress=%t", summarizeEventKinds(result.Events), - len(result.OutboundToCodex), - summarizeFrames(result.OutboundToCodex), + len(result.OutboundToAgent), + summarizeFrames(result.OutboundToAgent), result.Suppress, ) } sendRelayEvents(coalescer.Push(result.Events)) - for _, followup := range result.OutboundToCodex { + for _, followup := range result.OutboundToAgent { select { case writeCh <- followup: if debugf != nil { - debugf("stdout queued followup to codex: %s", summarizeFrame(followup)) + debugf("stdout queued followup to agent: %s", summarizeFrame(followup)) } case <-ctx.Done(): return @@ -151,7 +151,7 @@ func stdoutLoop(ctx context.Context, childStdout io.Reader, parentStdout io.Writ Code: "stdout_parse_failed", Layer: "wrapper", Stage: "observe_codex_stdout", - Operation: "codex.stdout", + Operation: "agent.stdout", Message: "wrapper 无法解析 Codex 子进程输出的 JSON-RPC 帧。", Details: fmt.Sprintf("%v; frame=%q", parseErr, previewRawLine(line)), }) @@ -195,7 +195,7 @@ func writeLoop(ctx context.Context, childStdin io.WriteCloser, writeCh <-chan [] if len(line) == 0 { continue } - if err := writeCodexFrame(childStdin, line, debugf, rawLogger, reportProblem); err != nil { + if err := writeAgentFrame(childStdin, line, debugf, rawLogger, reportProblem); err != nil { errCh <- err return } @@ -203,21 +203,21 @@ func writeLoop(ctx context.Context, childStdin io.WriteCloser, writeCh <-chan [] } } -func writeCodexFrame(childStdin io.Writer, line []byte, debugf func(string, ...any), rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) error { +func writeAgentFrame(childStdin io.Writer, line []byte, debugf func(string, ...any), rawLogger *debuglog.RawLogger, reportProblem func(agentproto.ErrorInfo)) error { if len(line) == 0 { return nil } if debugf != nil { - debugf("write to codex: %s", summarizeFrame(line)) + debugf("write to agent: %s", summarizeFrame(line)) } - logRawFrame(rawLogger, "codex.stdin", "out", line, "", "") + logRawFrame(rawLogger, "agent.stdin", "out", line, "", "") if _, err := childStdin.Write(line); err != nil { if reportProblem != nil { reportProblem(agentproto.ErrorInfoFromError(err, agentproto.ErrorInfo{ Code: "write_codex_stdin_failed", Layer: "wrapper", Stage: "write_codex_stdin", - Operation: "codex.stdin", + Operation: "agent.stdin", Message: "wrapper 无法继续向 Codex 子进程写入数据。", })) } diff --git a/internal/app/wrapper/app_process.go b/internal/app/wrapper/app_process.go index d9e6e5c0..04b2a4b2 100644 --- a/internal/app/wrapper/app_process.go +++ b/internal/app/wrapper/app_process.go @@ -55,11 +55,27 @@ func startChild(cmd *exec.Cmd) (io.WriteCloser, io.ReadCloser, io.ReadCloser, er return stdin, stdout, stderr, nil } -func configureCodexChildProcess(cmd *exec.Cmd, cfg Config) { - applyChildLaunchOptions(cmd, codexChildLaunchOptions(cfg)) +func configureChildProcess(cmd *exec.Cmd, cfg Config) { + applyChildLaunchOptions(cmd, childLaunchOptionsForAgent(cfg)) + if strings.EqualFold(strings.TrimSpace(cfg.AgentType), "claude") { + configureClaudeChildEnv(cmd) + } +} + +func configureClaudeChildEnv(cmd *exec.Cmd) { + // Set required env for Claude CLI SDK mode + cmd.Env = append(cmd.Env, "CLAUDE_CODE_ENTRYPOINT=sdk-go") + // Strip CLAUDECODE nesting guard so SDK subprocess works + filtered := make([]string, 0, len(cmd.Env)) + for _, e := range cmd.Env { + if !strings.HasPrefix(e, "CLAUDECODE=") { + filtered = append(filtered, e) + } + } + cmd.Env = filtered } -func codexChildLaunchOptions(cfg Config) childLaunchOptions { +func childLaunchOptionsForAgent(cfg Config) childLaunchOptions { if !cfg.Managed || !strings.EqualFold(strings.TrimSpace(cfg.Source), "headless") { return childLaunchOptions{} } diff --git a/internal/app/wrapper/app_process_test.go b/internal/app/wrapper/app_process_test.go index 00a38e69..c1c1d1e1 100644 --- a/internal/app/wrapper/app_process_test.go +++ b/internal/app/wrapper/app_process_test.go @@ -54,8 +54,8 @@ func TestCodexChildLaunchOptions(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := codexChildLaunchOptions(tt.cfg); got != tt.want { - t.Fatalf("codexChildLaunchOptions() = %#v, want %#v", got, tt.want) + if got := childLaunchOptionsForAgent(tt.cfg); got != tt.want { + t.Fatalf("childLaunchOptionsForAgent() = %#v, want %#v", got, tt.want) } }) } diff --git a/internal/app/wrapper/app_test.go b/internal/app/wrapper/app_test.go index 690a0cc5..37da2432 100644 --- a/internal/app/wrapper/app_test.go +++ b/internal/app/wrapper/app_test.go @@ -507,11 +507,11 @@ func TestWrapperWritesRawFramesWhenEnabled(t *testing.T) { if !strings.Contains(text, `"channel":"parent.stdin"`) { t.Fatalf("expected parent.stdin raw frame, got %s", text) } - if !strings.Contains(text, `"channel":"codex.stdin"`) { - t.Fatalf("expected codex.stdin raw frame, got %s", text) + if !strings.Contains(text, `"channel":"agent.stdin"`) { + t.Fatalf("expected agent.stdin raw frame, got %s", text) } - if !strings.Contains(text, `"channel":"codex.stdout"`) { - t.Fatalf("expected codex.stdout raw frame, got %s", text) + if !strings.Contains(text, `"channel":"agent.stdout"`) { + t.Fatalf("expected agent.stdout raw frame, got %s", text) } if !strings.Contains(text, `"channel":"relay.ws"`) { t.Fatalf("expected relay.ws raw frame, got %s", text) diff --git a/internal/config/configfile.go b/internal/config/configfile.go index d96d69c2..9382a2cc 100644 --- a/internal/config/configfile.go +++ b/internal/config/configfile.go @@ -49,6 +49,8 @@ type AdminSettings struct { type WrapperSettings struct { CodexRealBinary string `json:"codexRealBinary,omitempty"` + AgentType string `json:"agentType,omitempty"` + AgentBinary string `json:"agentBinary,omitempty"` NameMode string `json:"nameMode,omitempty"` IntegrationMode string `json:"integrationMode,omitempty"` } @@ -555,6 +557,9 @@ func (cfg AppConfig) normalized() AppConfig { if strings.TrimSpace(cfg.Wrapper.IntegrationMode) == "" { cfg.Wrapper.IntegrationMode = defaults.Wrapper.IntegrationMode } + if strings.TrimSpace(cfg.Wrapper.AgentType) == "" { + cfg.Wrapper.AgentType = "codex" + } if strings.TrimSpace(cfg.Storage.PreviewRootFolderName) == "" { cfg.Storage.PreviewRootFolderName = defaults.Storage.PreviewRootFolderName diff --git a/internal/config/envfile.go b/internal/config/envfile.go index bdc0a93d..f2bb67af 100644 --- a/internal/config/envfile.go +++ b/internal/config/envfile.go @@ -12,6 +12,8 @@ import ( type WrapperConfig struct { RelayServerURL string CodexRealBinary string + AgentType string + AgentBinary string NameMode string IntegrationMode string ConfigPath string @@ -112,6 +114,15 @@ func LoadWrapperConfig() (WrapperConfig, error) { return WrapperConfig{}, err } relayPort := chooseInt(os.Getenv("RELAY_PORT"), loaded.Config.Relay.ListenPort) + agentType := chooseNonEmpty( + os.Getenv("CODEX_REMOTE_AGENT_TYPE"), + loaded.Config.Wrapper.AgentType, + "codex", + ) + defaultBinary := "codex" + if agentType == "claude" { + defaultBinary = "claude" + } cfg := WrapperConfig{ RelayServerURL: chooseNonEmpty( os.Getenv("RELAY_SERVER_URL"), @@ -123,6 +134,12 @@ func LoadWrapperConfig() (WrapperConfig, error) { loaded.Config.Wrapper.CodexRealBinary, "codex", ), + AgentType: agentType, + AgentBinary: chooseNonEmpty( + os.Getenv("CODEX_REMOTE_AGENT_BINARY"), + loaded.Config.Wrapper.AgentBinary, + defaultBinary, + ), NameMode: chooseNonEmpty( os.Getenv("CODEX_REMOTE_WRAPPER_NAME_MODE"), loaded.Config.Wrapper.NameMode, diff --git a/internal/core/control/types.go b/internal/core/control/types.go index 3974d4d0..53cebb37 100644 --- a/internal/core/control/types.go +++ b/internal/core/control/types.go @@ -332,6 +332,7 @@ type DaemonCommand struct { ThreadID string ThreadTitle string ThreadCWD string + AgentType string AutoRestore bool Text string } diff --git a/internal/core/translator/translator.go b/internal/core/translator/translator.go new file mode 100644 index 00000000..b37e1abc --- /dev/null +++ b/internal/core/translator/translator.go @@ -0,0 +1,34 @@ +package translator + +import "github.com/kxn/codex-remote-feishu/internal/core/agentproto" + +// Result holds the output of translating a single protocol message. +type Result struct { + Events []agentproto.Event + OutboundToAgent [][]byte + Suppress bool +} + +// Translator is the interface that agent-specific adapters implement. +// Both Codex and Claude translators satisfy this interface, allowing the +// wrapper to be agent-agnostic. +type Translator interface { + // TranslateCommand converts a canonical agentproto command into native + // agent protocol frames to write to the agent's stdin. + TranslateCommand(command agentproto.Command) ([][]byte, error) + + // ObserveServer translates a line from the agent's stdout into canonical + // events and optional follow-up frames to write back to the agent. + ObserveServer(raw []byte) (Result, error) + + // ObserveClient translates a line from the parent's stdin into canonical events. + ObserveClient(raw []byte) (Result, error) + + // BootstrapFrames returns synthetic frames to send to the agent on startup. + // For Codex: the headless initialize frame. + // For Claude: the SDK initialize control_request. + BootstrapFrames(source string, version string) ([][]byte, error) + + // SetDebugLogger configures debug logging. + SetDebugLogger(debugLog func(string, ...any)) +} diff --git a/testkit/harness/harness.go b/testkit/harness/harness.go index 1f7bc646..fe2356f7 100644 --- a/testkit/harness/harness.go +++ b/testkit/harness/harness.go @@ -115,7 +115,7 @@ func (h *Harness) processServerOutput(raw []byte) error { if err := h.applyAgentEvents(result.Events); err != nil { return err } - for _, followup := range result.OutboundToCodex { + for _, followup := range result.OutboundToAgent { outputs, err := h.Codex.HandleRemoteCommand(followup) if err != nil { return err From 5804ec082ea0581823965a2408dbcb07e137dc52 Mon Sep 17 00:00:00 2001 From: Ming Zhao Date: Thu, 9 Apr 2026 00:29:40 +0000 Subject: [PATCH 2/3] test: add Claude translator integration and replay tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integration test (go test -tags integration) spawns real Claude CLI, sends a prompt, and verifies the translator produces the correct agentproto event sequence (thread.discovered → turn.started → item.started → item.delta → item.completed → turn.completed) - Records the full NDJSON session with privacy masking for replay - clauderecord package: Entry/Recorder for capture, MaskEntries for privacy (session_id, uuid, email, org, paths, api keys), LoadFixture for replay - Replay unit tests load the masked fixture and verify translator output without needing a live Claude CLI - Fixture testdata/hello_simple.ndjson committed with all PII masked Key finding during implementation: Claude CLI sends the system init message only after the first user message is written to stdin, not immediately after the initialize control_response. --- internal/adapter/claude/clauderecord/mask.go | 147 ++++++++++ .../adapter/claude/clauderecord/mask_test.go | 138 +++++++++ .../adapter/claude/clauderecord/record.go | 163 +++++++++++ .../claude/testdata/hello_simple.ndjson | 13 + .../claude/translator_integration_test.go | 268 ++++++++++++++++++ .../adapter/claude/translator_replay_test.go | 155 ++++++++++ 6 files changed, 884 insertions(+) create mode 100644 internal/adapter/claude/clauderecord/mask.go create mode 100644 internal/adapter/claude/clauderecord/mask_test.go create mode 100644 internal/adapter/claude/clauderecord/record.go create mode 100644 internal/adapter/claude/testdata/hello_simple.ndjson create mode 100644 internal/adapter/claude/translator_integration_test.go create mode 100644 internal/adapter/claude/translator_replay_test.go diff --git a/internal/adapter/claude/clauderecord/mask.go b/internal/adapter/claude/clauderecord/mask.go new file mode 100644 index 00000000..edf935e6 --- /dev/null +++ b/internal/adapter/claude/clauderecord/mask.go @@ -0,0 +1,147 @@ +package clauderecord + +import ( + "encoding/json" + "fmt" + "strings" +) + +// MaskOptions configures which values are replaced during masking. +type MaskOptions struct { + // WorkspaceCWD is the real CWD to replace with /test/workspace. + WorkspaceCWD string + // HomePath is the real home directory to replace with /test/home. + // If empty, it is inferred from WorkspaceCWD or ignored. + HomePath string +} + +// MaskEntries returns a deep copy of entries with sensitive fields masked. +func MaskEntries(entries []Entry, opts MaskOptions) []Entry { + m := &masker{ + opts: opts, + uuidMap: map[string]string{}, + requestMap: map[string]string{}, + } + out := make([]Entry, len(entries)) + for i, e := range entries { + out[i] = Entry{ + Timestamp: e.Timestamp, + Direction: e.Direction, + Seq: e.Seq, + Frame: m.maskFrame(e.Frame), + } + } + return out +} + +type masker struct { + opts MaskOptions + uuidMap map[string]string + requestMap map[string]string + uuidSeq int + reqSeq int +} + +func (m *masker) maskFrame(raw json.RawMessage) json.RawMessage { + var parsed any + if err := json.Unmarshal(raw, &parsed); err != nil { + return raw + } + masked := m.walkValue(parsed, "") + out, err := json.Marshal(masked) + if err != nil { + return raw + } + return out +} + +func (m *masker) walkValue(v any, key string) any { + switch val := v.(type) { + case map[string]any: + return m.walkMap(val) + case []any: + return m.walkSlice(val) + case string: + return m.maskString(val, key) + default: + return v + } +} + +func (m *masker) walkMap(obj map[string]any) map[string]any { + out := make(map[string]any, len(obj)) + for k, v := range obj { + out[k] = m.walkValue(v, k) + } + return out +} + +func (m *masker) walkSlice(arr []any) []any { + out := make([]any, len(arr)) + for i, v := range arr { + out[i] = m.walkValue(v, "") + } + return out +} + +func (m *masker) maskString(val string, key string) string { + // Known sensitive keys + switch key { + case "session_id": + return "masked-session-id" + case "uuid": + return m.mapUUID(val) + case "request_id": + return m.mapRequestID(val) + case "api_key", "apiKeySource": + return "REDACTED" + case "email": + return "masked@example.com" + case "organization": + return "masked-org" + } + + // Path masking + val = m.maskPaths(val) + return val +} + +func (m *masker) maskPaths(val string) string { + if m.opts.WorkspaceCWD != "" && strings.Contains(val, m.opts.WorkspaceCWD) { + val = strings.ReplaceAll(val, m.opts.WorkspaceCWD, "/test/workspace") + } + if m.opts.HomePath != "" && strings.Contains(val, m.opts.HomePath) { + val = strings.ReplaceAll(val, m.opts.HomePath, "/test/home") + } + return val +} + +func (m *masker) mapUUID(original string) string { + if original == "" { + return "" + } + if mapped, ok := m.uuidMap[original]; ok { + return mapped + } + m.uuidSeq++ + mapped := fmt.Sprintf("masked-uuid-%d", m.uuidSeq) + m.uuidMap[original] = mapped + return mapped +} + +func (m *masker) mapRequestID(original string) string { + if original == "" { + return "" + } + // Keep relay-generated IDs (they're not sensitive and aid debugging) + if strings.HasPrefix(original, "relay-") { + return original + } + if mapped, ok := m.requestMap[original]; ok { + return mapped + } + m.reqSeq++ + mapped := fmt.Sprintf("masked-req-%d", m.reqSeq) + m.requestMap[original] = mapped + return mapped +} diff --git a/internal/adapter/claude/clauderecord/mask_test.go b/internal/adapter/claude/clauderecord/mask_test.go new file mode 100644 index 00000000..068f9217 --- /dev/null +++ b/internal/adapter/claude/clauderecord/mask_test.go @@ -0,0 +1,138 @@ +package clauderecord + +import ( + "encoding/json" + "testing" +) + +func TestMaskSessionID(t *testing.T) { + entries := []Entry{{ + Seq: 0, + Direction: DirRecv, + Frame: json.RawMessage(`{"type":"system","session_id":"real-session-abc123","model":"claude-sonnet-4-20250514"}`), + }} + masked := MaskEntries(entries, MaskOptions{}) + var frame map[string]any + if err := json.Unmarshal(masked[0].Frame, &frame); err != nil { + t.Fatal(err) + } + if frame["session_id"] != "masked-session-id" { + t.Errorf("session_id not masked: %v", frame["session_id"]) + } + if frame["model"] != "claude-sonnet-4-20250514" { + t.Errorf("model should be preserved: %v", frame["model"]) + } +} + +func TestMaskUUID(t *testing.T) { + entries := []Entry{ + {Seq: 0, Direction: DirRecv, Frame: json.RawMessage(`{"uuid":"uuid-aaa"}`)}, + {Seq: 1, Direction: DirRecv, Frame: json.RawMessage(`{"uuid":"uuid-bbb"}`)}, + {Seq: 2, Direction: DirRecv, Frame: json.RawMessage(`{"uuid":"uuid-aaa"}`)}, + } + masked := MaskEntries(entries, MaskOptions{}) + + get := func(i int) string { + var f map[string]any + json.Unmarshal(masked[i].Frame, &f) + return f["uuid"].(string) + } + if get(0) != "masked-uuid-1" { + t.Errorf("first uuid: %s", get(0)) + } + if get(1) != "masked-uuid-2" { + t.Errorf("second uuid: %s", get(1)) + } + // Same original UUID gets same masked value + if get(2) != "masked-uuid-1" { + t.Errorf("repeated uuid: %s", get(2)) + } +} + +func TestMaskRequestID(t *testing.T) { + entries := []Entry{ + {Seq: 0, Direction: DirRecv, Frame: json.RawMessage(`{"request_id":"cli-generated-id"}`)}, + {Seq: 1, Direction: DirSend, Frame: json.RawMessage(`{"request_id":"relay-init-0"}`)}, + } + masked := MaskEntries(entries, MaskOptions{}) + + get := func(i int) string { + var f map[string]any + json.Unmarshal(masked[i].Frame, &f) + return f["request_id"].(string) + } + if get(0) != "masked-req-1" { + t.Errorf("cli request_id not masked: %s", get(0)) + } + // relay-prefixed IDs are kept + if get(1) != "relay-init-0" { + t.Errorf("relay request_id should be kept: %s", get(1)) + } +} + +func TestMaskPaths(t *testing.T) { + entries := []Entry{{ + Seq: 0, + Direction: DirRecv, + Frame: json.RawMessage(`{"cwd":"/home/realuser/projects/myapp","file":"/home/realuser/.config/something"}`), + }} + masked := MaskEntries(entries, MaskOptions{ + WorkspaceCWD: "/home/realuser/projects/myapp", + HomePath: "/home/realuser", + }) + var frame map[string]any + if err := json.Unmarshal(masked[0].Frame, &frame); err != nil { + t.Fatal(err) + } + if frame["cwd"] != "/test/workspace" { + t.Errorf("cwd not masked: %v", frame["cwd"]) + } + if frame["file"] != "/test/home/.config/something" { + t.Errorf("home path not masked: %v", frame["file"]) + } +} + +func TestMaskAPIKey(t *testing.T) { + entries := []Entry{{ + Seq: 0, + Direction: DirRecv, + Frame: json.RawMessage(`{"apiKeySource":"env:ANTHROPIC_API_KEY","api_key":"sk-ant-secret"}`), + }} + masked := MaskEntries(entries, MaskOptions{}) + var frame map[string]any + json.Unmarshal(masked[0].Frame, &frame) + if frame["apiKeySource"] != "REDACTED" { + t.Errorf("apiKeySource not masked: %v", frame["apiKeySource"]) + } + if frame["api_key"] != "REDACTED" { + t.Errorf("api_key not masked: %v", frame["api_key"]) + } +} + +func TestMaskNestedStructures(t *testing.T) { + entries := []Entry{{ + Seq: 0, + Direction: DirRecv, + Frame: json.RawMessage(`{ + "type":"control_request", + "request_id":"cli-req-1", + "request":{ + "subtype":"mcp_message", + "server_name":"my-tools", + "message":{"method":"initialize","id":1} + } + }`), + }} + masked := MaskEntries(entries, MaskOptions{}) + var frame map[string]any + json.Unmarshal(masked[0].Frame, &frame) + if frame["request_id"] != "masked-req-1" { + t.Errorf("nested request_id not masked: %v", frame["request_id"]) + } + // Numeric id in MCP message should be preserved + req := frame["request"].(map[string]any) + msg := req["message"].(map[string]any) + if msg["id"] != float64(1) { + t.Errorf("mcp numeric id should be preserved: %v", msg["id"]) + } +} diff --git a/internal/adapter/claude/clauderecord/record.go b/internal/adapter/claude/clauderecord/record.go new file mode 100644 index 00000000..54d3c8da --- /dev/null +++ b/internal/adapter/claude/clauderecord/record.go @@ -0,0 +1,163 @@ +// Package clauderecord captures, masks, and replays Claude CLI NDJSON sessions +// for integration testing and fixture generation. +package clauderecord + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +// Direction indicates whether a frame was sent to or received from the CLI. +type Direction string + +const ( + DirSend Direction = "send" // stdin → claude + DirRecv Direction = "recv" // claude → stdout +) + +// Entry is one NDJSON line in a recording. +type Entry struct { + Timestamp time.Time `json:"ts"` + Direction Direction `json:"dir"` + Seq int `json:"seq"` + Frame json.RawMessage `json:"frame"` +} + +// Recorder captures NDJSON frames exchanged with the Claude CLI. +type Recorder struct { + mu sync.Mutex + entries []Entry + seq int + start time.Time +} + +// NewRecorder creates a new recording session. +func NewRecorder() *Recorder { + return &Recorder{start: time.Now().UTC()} +} + +// RecordSend records a frame sent to the CLI's stdin. +func (r *Recorder) RecordSend(frame []byte) { + r.record(DirSend, frame) +} + +// RecordRecv records a frame received from the CLI's stdout. +func (r *Recorder) RecordRecv(frame []byte) { + r.record(DirRecv, frame) +} + +func (r *Recorder) record(dir Direction, frame []byte) { + r.mu.Lock() + defer r.mu.Unlock() + trimmed := trimBytes(frame) + if len(trimmed) == 0 { + return + } + r.entries = append(r.entries, Entry{ + Timestamp: time.Now().UTC(), + Direction: dir, + Seq: r.seq, + Frame: json.RawMessage(trimmed), + }) + r.seq++ +} + +// Entries returns a copy of all recorded entries. +func (r *Recorder) Entries() []Entry { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]Entry, len(r.entries)) + copy(out, r.entries) + return out +} + +// SaveRaw writes the unmasked recording to a file. +func (r *Recorder) SaveRaw(path string) error { + return saveEntries(path, r.Entries()) +} + +// SaveMasked applies privacy masking and writes the result to a file. +func (r *Recorder) SaveMasked(path string, opts MaskOptions) error { + masked := MaskEntries(r.Entries(), opts) + return saveEntries(path, masked) +} + +func saveEntries(path string, entries []Entry) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + for _, e := range entries { + if err := enc.Encode(e); err != nil { + return err + } + } + return nil +} + +// LoadFixture reads a recording from a NDJSON file. +func LoadFixture(path string) ([]Entry, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var entries []Entry + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 1<<20), 1<<20) // 1MB line buffer + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var e Entry + if err := json.Unmarshal(line, &e); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, scanner.Err() +} + +// RecvEntries returns only the entries received from the CLI. +func RecvEntries(entries []Entry) []Entry { + var out []Entry + for _, e := range entries { + if e.Direction == DirRecv { + out = append(out, e) + } + } + return out +} + +// SendEntries returns only the entries sent to the CLI. +func SendEntries(entries []Entry) []Entry { + var out []Entry + for _, e := range entries { + if e.Direction == DirSend { + out = append(out, e) + } + } + return out +} + +func trimBytes(b []byte) []byte { + start, end := 0, len(b) + for start < end && (b[start] == ' ' || b[start] == '\t' || b[start] == '\r' || b[start] == '\n') { + start++ + } + for end > start && (b[end-1] == ' ' || b[end-1] == '\t' || b[end-1] == '\r' || b[end-1] == '\n') { + end-- + } + return b[start:end] +} diff --git a/internal/adapter/claude/testdata/hello_simple.ndjson b/internal/adapter/claude/testdata/hello_simple.ndjson new file mode 100644 index 00000000..2ab99390 --- /dev/null +++ b/internal/adapter/claude/testdata/hello_simple.ndjson @@ -0,0 +1,13 @@ +{"ts":"2026-04-09T00:28:04.115264627Z","dir":"send","seq":0,"frame":{"request":{"subtype":"initialize"},"request_id":"relay-init-0","type":"control_request"}} +{"ts":"2026-04-09T00:28:06.06102458Z","dir":"recv","seq":1,"frame":{"response":{"request_id":"relay-init-0","response":{"account":{"apiProvider":"firstParty","email":"masked@example.com","organization":"masked-org","subscriptionType":"Claude Max"},"agents":[{"description":"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.","name":"general-purpose"},{"description":"Use this agent to configure the user's Claude Code status line setting.","model":"sonnet","name":"statusline-setup"},{"description":"Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.","model":"haiku","name":"Explore"},{"description":"Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.","name":"Plan"},{"description":"Use this agent when the user asks questions (\"Can Claude...\", \"Does Claude...\", \"How do I...\") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage.","model":"haiku","name":"claude-code-guide"}],"available_output_styles":["default","Explanatory","Learning"],"commands":[{"argumentHint":"","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, use Config tool. (bundled)","name":"update-config"},{"argumentHint":"[issue description]","description":"Enable debug logging for this session and help diagnose issues (bundled)","name":"debug"},{"argumentHint":"","description":"Review changed code for reuse, quality, and efficiency, then fix any issues found. (bundled)","name":"simplify"},{"argumentHint":"\u003cinstruction\u003e","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR. (bundled)","name":"batch"},{"argumentHint":"[interval] \u003cprompt\u003e","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) (bundled)","name":"loop"},{"argumentHint":"","description":"Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule. (bundled)","name":"schedule"},{"argumentHint":"","description":"Build Claude API / Anthropic SDK apps.\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks to use the Claude API, Anthropic SDKs, or Managed Agents (`/v1/agents`, `/v1/sessions`); or asks to add a Claude feature (prompt caching, adaptive thinking, compaction, code_execution, batch, files API, citations, memory tool) or a Claude model (Opus/Sonnet/Haiku) to a Claude file.\nDO NOT TRIGGER when: file imports `openai`/non-Anthropic SDK, filename signals another provider (`agent-openai.py`, `*-generic.py`), code is provider-neutral, or task is general programming/ML. (bundled)","name":"claude-api"},{"argumentHint":"\u003coptional custom summarization instructions\u003e","description":"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]","name":"compact"},{"argumentHint":"","description":"Show current context usage","name":"context"},{"argumentHint":"","description":"Show the total cost and duration of the current session","name":"cost"},{"argumentHint":"","description":"Dump the JS heap to ~/Desktop","name":"heapdump"},{"argumentHint":"","description":"Initialize a new CLAUDE.md file with codebase documentation","name":"init"},{"argumentHint":"","description":"Review a pull request","name":"review"},{"argumentHint":"","description":"Complete a security review of the pending changes on the current branch","name":"security-review"},{"argumentHint":"","description":"Configure extra usage to keep working when limits are hit","name":"extra-usage"},{"argumentHint":"","description":"Generate a report analyzing your Claude Code sessions","name":"insights"},{"argumentHint":"","description":"Find a time for a meeting with colleagues","name":"claude.ai Google Calendar:schedule_meeting (MCP)"},{"argumentHint":"","description":"Find time for a quick meeting today","name":"claude.ai Google Calendar:quick_meeting_today (MCP)"},{"argumentHint":"","description":"Schedule recurring 1:1 meetings","name":"claude.ai Google Calendar:recurring_1on1 (MCP)"},{"argumentHint":"","description":"Find time for a team meeting with multiple people","name":"claude.ai Google Calendar:team_meeting_scheduler (MCP)"},{"argumentHint":"","description":"Analyze someone's calendar availability patterns","name":"claude.ai Google Calendar:analyze_availability (MCP)"}],"models":[{"description":"Opus 4.6 with 1M context · Most capable for complex work","displayName":"Default (recommended)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"supportsFastMode":true,"value":"default"},{"description":"Sonnet 4.6 · Best for everyday tasks","displayName":"Sonnet","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet"},{"description":"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok","displayName":"Sonnet (1M context)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet[1m]"},{"description":"Haiku 4.5 · Fastest for quick answers","displayName":"Haiku","value":"haiku"}],"output_style":"default","pid":2291848},"subtype":"success"},"type":"control_response"}} +{"ts":"2026-04-09T00:28:09.117856715Z","dir":"send","seq":2,"frame":{"message":{"content":"respond with exactly the word hello and nothing else","role":"user"},"type":"user"}} +{"ts":"2026-04-09T00:28:09.128399829Z","dir":"recv","seq":3,"frame":{"agents":["general-purpose","statusline-setup","Explore","Plan","claude-code-guide"],"apiKeySource":"REDACTED","claude_code_version":"2.1.97","cwd":"/test/workspace","fast_mode_state":"off","mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Clerk","status":"connected"},{"name":"claude.ai Excalidraw","status":"connected"},{"name":"claude.ai Linear","status":"connected"},{"name":"claude.ai Ramp","status":"connected"},{"name":"claude.ai Notion","status":"connected"},{"name":"claude.ai Google Calendar","status":"connected"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-opus-4-6[1m]","output_style":"default","permissionMode":"bypassPermissions","plugins":[],"session_id":"masked-session-id","skills":["update-config","debug","simplify","batch","loop","schedule","claude-api"],"slash_commands":["update-config","debug","simplify","batch","loop","schedule","claude-api","compact","context","cost","heapdump","init","review","security-review","extra-usage","insights","mcp__claude_ai_Google_Calendar__schedule_meeting","mcp__claude_ai_Google_Calendar__quick_meeting_today","mcp__claude_ai_Google_Calendar__recurring_1on1","mcp__claude_ai_Google_Calendar__team_meeting_scheduler","mcp__claude_ai_Google_Calendar__analyze_availability"],"subtype":"init","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","NotebookEdit","Read","ReadMcpResourceTool","RemoteTrigger","SendMessage","Skill","TaskOutput","TaskStop","TeamCreate","TeamDelete","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Clerk__clerk_sdk_snippet","mcp__claude_ai_Clerk__list_clerk_sdk_snippets","mcp__claude_ai_Excalidraw__create_view","mcp__claude_ai_Excalidraw__export_to_excalidraw","mcp__claude_ai_Excalidraw__read_checkpoint","mcp__claude_ai_Excalidraw__read_me","mcp__claude_ai_Excalidraw__save_checkpoint","mcp__claude_ai_Gmail__gmail_create_draft","mcp__claude_ai_Gmail__gmail_get_profile","mcp__claude_ai_Gmail__gmail_list_drafts","mcp__claude_ai_Gmail__gmail_list_labels","mcp__claude_ai_Gmail__gmail_read_message","mcp__claude_ai_Gmail__gmail_read_thread","mcp__claude_ai_Gmail__gmail_search_messages","mcp__claude_ai_Google_Calendar__gcal_create_event","mcp__claude_ai_Google_Calendar__gcal_delete_event","mcp__claude_ai_Google_Calendar__gcal_find_meeting_times","mcp__claude_ai_Google_Calendar__gcal_find_my_free_time","mcp__claude_ai_Google_Calendar__gcal_get_event","mcp__claude_ai_Google_Calendar__gcal_list_calendars","mcp__claude_ai_Google_Calendar__gcal_list_events","mcp__claude_ai_Google_Calendar__gcal_respond_to_event","mcp__claude_ai_Google_Calendar__gcal_update_event","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Linear__create_attachment","mcp__claude_ai_Linear__create_document","mcp__claude_ai_Linear__create_issue_label","mcp__claude_ai_Linear__delete_attachment","mcp__claude_ai_Linear__delete_comment","mcp__claude_ai_Linear__delete_status_update","mcp__claude_ai_Linear__extract_images","mcp__claude_ai_Linear__get_attachment","mcp__claude_ai_Linear__get_document","mcp__claude_ai_Linear__get_initiative","mcp__claude_ai_Linear__get_issue","mcp__claude_ai_Linear__get_issue_status","mcp__claude_ai_Linear__get_milestone","mcp__claude_ai_Linear__get_project","mcp__claude_ai_Linear__get_status_updates","mcp__claude_ai_Linear__get_team","mcp__claude_ai_Linear__get_user","mcp__claude_ai_Linear__list_comments","mcp__claude_ai_Linear__list_cycles","mcp__claude_ai_Linear__list_documents","mcp__claude_ai_Linear__list_initiatives","mcp__claude_ai_Linear__list_issue_labels","mcp__claude_ai_Linear__list_issue_statuses","mcp__claude_ai_Linear__list_issues","mcp__claude_ai_Linear__list_milestones","mcp__claude_ai_Linear__list_project_labels","mcp__claude_ai_Linear__list_projects","mcp__claude_ai_Linear__list_teams","mcp__claude_ai_Linear__list_users","mcp__claude_ai_Linear__research","mcp__claude_ai_Linear__save_comment","mcp__claude_ai_Linear__save_initiative","mcp__claude_ai_Linear__save_issue","mcp__claude_ai_Linear__save_milestone","mcp__claude_ai_Linear__save_project","mcp__claude_ai_Linear__save_status_update","mcp__claude_ai_Linear__search_documentation","mcp__claude_ai_Linear__update_document","mcp__claude_ai_Notion__notion-create-comment","mcp__claude_ai_Notion__notion-create-database","mcp__claude_ai_Notion__notion-create-pages","mcp__claude_ai_Notion__notion-create-view","mcp__claude_ai_Notion__notion-duplicate-page","mcp__claude_ai_Notion__notion-fetch","mcp__claude_ai_Notion__notion-get-comments","mcp__claude_ai_Notion__notion-get-teams","mcp__claude_ai_Notion__notion-get-users","mcp__claude_ai_Notion__notion-move-pages","mcp__claude_ai_Notion__notion-query-database-view","mcp__claude_ai_Notion__notion-query-meeting-notes","mcp__claude_ai_Notion__notion-search","mcp__claude_ai_Notion__notion-update-data-source","mcp__claude_ai_Notion__notion-update-page","mcp__claude_ai_Notion__notion-update-view","mcp__claude_ai_Ramp__get_current_user","mcp__claude_ai_Ramp__ramp_ask_help_center","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"type":"system","uuid":"masked-uuid-1"}} +{"ts":"2026-04-09T00:28:10.443737419Z","dir":"recv","seq":4,"frame":{"event":{"message":{"content":[],"id":"msg_017duNa1zGCNPyrMQGGeXsdN","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6190,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-2"}} +{"ts":"2026-04-09T00:28:10.44388168Z","dir":"recv","seq":5,"frame":{"event":{"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-3"}} +{"ts":"2026-04-09T00:28:10.444821081Z","dir":"recv","seq":6,"frame":{"event":{"delta":{"text":"hello","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-4"}} +{"ts":"2026-04-09T00:28:10.513194948Z","dir":"recv","seq":7,"frame":{"message":{"content":[{"text":"hello","type":"text"}],"context_management":null,"id":"msg_017duNa1zGCNPyrMQGGeXsdN","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6190,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-5"}} +{"ts":"2026-04-09T00:28:10.513274788Z","dir":"recv","seq":8,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-6"}} +{"ts":"2026-04-09T00:28:10.536621868Z","dir":"recv","seq":9,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"input_tokens":3,"output_tokens":4}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-7"}} +{"ts":"2026-04-09T00:28:10.536723848Z","dir":"recv","seq":10,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-8"}} +{"ts":"2026-04-09T00:28:10.537249989Z","dir":"recv","seq":11,"frame":{"rate_limit_info":{"isUsingOverage":false,"rateLimitType":"seven_day","resetsAt":1775793600,"status":"allowed_warning","surpassedThreshold":0.75,"utilization":0.84},"session_id":"masked-session-id","type":"rate_limit_event","uuid":"masked-uuid-9"}} +{"ts":"2026-04-09T00:28:10.538797201Z","dir":"recv","seq":12,"frame":{"duration_api_ms":1406,"duration_ms":1416,"fast_mode_state":"off","is_error":false,"modelUsage":{"claude-opus-4-6[1m]":{"cacheCreationInputTokens":6190,"cacheReadInputTokens":11448,"contextWindow":1000000,"costUSD":0.0445265,"inputTokens":3,"maxOutputTokens":64000,"outputTokens":4,"webSearchRequests":0}},"num_turns":1,"permission_denials":[],"result":"hello","session_id":"masked-session-id","stop_reason":"end_turn","subtype":"success","terminal_reason":"completed","total_cost_usd":0.0445265,"type":"result","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6190,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"inference_geo":"","input_tokens":3,"iterations":[],"output_tokens":4,"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0},"service_tier":"standard","speed":"standard"},"uuid":"masked-uuid-10"}} diff --git a/internal/adapter/claude/translator_integration_test.go b/internal/adapter/claude/translator_integration_test.go new file mode 100644 index 00000000..28e38f15 --- /dev/null +++ b/internal/adapter/claude/translator_integration_test.go @@ -0,0 +1,268 @@ +//go:build integration + +package claude_test + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kxn/codex-remote-feishu/internal/adapter/claude" + "github.com/kxn/codex-remote-feishu/internal/adapter/claude/clauderecord" + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" +) + +// TestClaudeCLIIntegration spawns a real Claude CLI process in SDK mode, +// sends a simple prompt, and verifies that the translator produces the +// expected agentproto event sequence. It also records the NDJSON transcript +// with privacy masking for use as a replay fixture. +// +// Run with: go test -tags integration -run TestClaudeCLIIntegration -v -timeout 120s +func TestClaudeCLIIntegration(t *testing.T) { + cliBin, err := exec.LookPath("claude") + if err != nil { + t.Skip("claude CLI not found on PATH, skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + cwd, _ := os.Getwd() + homePath, _ := os.UserHomeDir() + + // Build command + args := []string{ + "--output-format", "stream-json", + "--verbose", + "--dangerously-skip-permissions", + "--include-partial-messages", + "--setting-sources", "", + "--input-format", "stream-json", + } + cmd := exec.CommandContext(ctx, cliBin, args...) + cmd.Env = buildClaudeEnv(os.Environ()) + cmd.Dir = cwd + + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatalf("stdin pipe: %v", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + cmd.Stderr = os.Stderr // let Claude's stderr show in test output + + recorder := clauderecord.NewRecorder() + translator := claude.NewTranslator("integration-test") + translator.SetDebugLogger(func(format string, args ...any) { + t.Logf("translator: "+format, args...) + }) + + if err := cmd.Start(); err != nil { + t.Fatalf("start claude: %v", err) + } + defer func() { + stdin.Close() + cmd.Wait() + }() + + // Channels for collecting events from the reader goroutine + eventsCh := make(chan agentproto.Event, 256) + outboundCh := make(chan []byte, 64) + readerDone := make(chan error, 1) + + // Read stdout in background using ReadBytes('\n') for reliable line detection + go func() { + reader := bufio.NewReaderSize(stdout, 4<<20) // 4MB buffer + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + recorder.RecordRecv(line) + + var peek struct{ Type string `json:"type"` } + json.Unmarshal(line, &peek) + t.Logf("recv: type=%s len=%d", peek.Type, len(line)) + + result, obsErr := translator.ObserveServer(line) + if obsErr != nil { + t.Logf("observe error: %v (line: %s)", obsErr, truncate(line, 200)) + continue + } + for _, event := range result.Events { + t.Logf("event: kind=%s", event.Kind) + eventsCh <- event + } + for _, out := range result.OutboundToAgent { + outboundCh <- out + } + } + if err != nil { + readerDone <- err + return + } + } + }() + + // Write outbound frames (MCP handshake responses) back to stdin + go func() { + for frame := range outboundCh { + recorder.RecordSend(frame) + stdin.Write(frame) + } + }() + + // Step 1: Send bootstrap (initialize handshake) + bootstrapFrames, err := translator.BootstrapFrames("integration-test", "1.0") + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + for _, frame := range bootstrapFrames { + recorder.RecordSend(frame) + if _, err := stdin.Write(frame); err != nil { + t.Fatalf("write bootstrap: %v", err) + } + } + + // Step 2: Wait briefly for init, then send user message. + // Claude CLI may buffer the system init message until the first user message is sent. + var allEvents []agentproto.Event + drainWithTimeout(eventsCh, &allEvents, 5*time.Second) + t.Logf("after init drain: %d events", len(allEvents)) + + // Step 3: Send user message + userMsg := map[string]any{ + "type": "user", + "message": map[string]any{ + "role": "user", + "content": "respond with exactly the word hello and nothing else", + }, + } + msgBytes, _ := json.Marshal(userMsg) + msgBytes = append(msgBytes, '\n') + recorder.RecordSend(msgBytes) + if _, err := stdin.Write(msgBytes); err != nil { + t.Fatalf("write user message: %v", err) + } + + // Step 4: Collect events until turn completion (includes system init + streaming) + waitForEventKind(t, ctx, eventsCh, &allEvents, agentproto.EventTurnCompleted, "turn completion") + + // Step 5: Verify event sequence + t.Logf("collected %d events", len(allEvents)) + for i, e := range allEvents { + t.Logf(" event[%d]: kind=%s threadId=%s turnId=%s itemKind=%s delta=%s", + i, e.Kind, e.ThreadID, e.TurnID, e.ItemKind, truncate([]byte(e.Delta), 80)) + } + + assertHasEventKind(t, allEvents, agentproto.EventThreadDiscovered) + assertHasEventKind(t, allEvents, agentproto.EventTurnStarted) + assertHasEventKind(t, allEvents, agentproto.EventItemStarted) + assertHasEventKind(t, allEvents, agentproto.EventItemCompleted) + + turnCompleted := findEvent(allEvents, agentproto.EventTurnCompleted) + if turnCompleted == nil { + t.Fatal("missing EventTurnCompleted") + } + if turnCompleted.Status != "completed" { + t.Errorf("turn status: got %q, want %q", turnCompleted.Status, "completed") + } + + // Check that deltas contain "hello" + var fullText string + for _, e := range allEvents { + if e.Kind == agentproto.EventItemDelta { + fullText += e.Delta + } + } + if !strings.Contains(strings.ToLower(fullText), "hello") { + t.Errorf("expected delta text to contain 'hello', got %q", fullText) + } + + // Step 6: Save masked recording + close(outboundCh) + stdin.Close() + + fixturePath := filepath.Join("testdata", "hello_simple.ndjson") + if err := recorder.SaveMasked(fixturePath, clauderecord.MaskOptions{ + WorkspaceCWD: cwd, + HomePath: homePath, + }); err != nil { + t.Fatalf("save masked recording: %v", err) + } + t.Logf("saved masked recording to %s (%d entries)", fixturePath, len(recorder.Entries())) +} + +// drainWithTimeout collects events from the channel for up to the given duration. +func drainWithTimeout(ch <-chan agentproto.Event, allEvents *[]agentproto.Event, d time.Duration) { + timer := time.NewTimer(d) + defer timer.Stop() + for { + select { + case event := <-ch: + *allEvents = append(*allEvents, event) + case <-timer.C: + return + } + } +} + +// waitForEventKind drains eventsCh into allEvents until the target kind appears. +func waitForEventKind(t *testing.T, ctx context.Context, ch <-chan agentproto.Event, allEvents *[]agentproto.Event, kind agentproto.EventKind, label string) { + t.Helper() + for { + select { + case <-ctx.Done(): + t.Fatalf("timeout waiting for %s (%s); collected %d events so far", kind, label, len(*allEvents)) + case event := <-ch: + *allEvents = append(*allEvents, event) + if event.Kind == kind { + return + } + } + } +} + +func assertHasEventKind(t *testing.T, events []agentproto.Event, kind agentproto.EventKind) { + t.Helper() + for _, e := range events { + if e.Kind == kind { + return + } + } + t.Errorf("expected event kind %s in sequence", kind) +} + +func findEvent(events []agentproto.Event, kind agentproto.EventKind) *agentproto.Event { + for i := range events { + if events[i].Kind == kind { + return &events[i] + } + } + return nil +} + +func truncate(b []byte, max int) string { + s := strings.TrimSpace(string(b)) + if len(s) > max { + return s[:max] + "..." + } + return s +} + +func buildClaudeEnv(parent []string) []string { + filtered := make([]string, 0, len(parent)+1) + for _, e := range parent { + if strings.HasPrefix(e, "CLAUDECODE=") { + continue + } + filtered = append(filtered, e) + } + return append(filtered, "CLAUDE_CODE_ENTRYPOINT=sdk-go") +} diff --git a/internal/adapter/claude/translator_replay_test.go b/internal/adapter/claude/translator_replay_test.go new file mode 100644 index 00000000..02ebe9cc --- /dev/null +++ b/internal/adapter/claude/translator_replay_test.go @@ -0,0 +1,155 @@ +package claude_test + +import ( + "os" + "strings" + "testing" + + "github.com/kxn/codex-remote-feishu/internal/adapter/claude" + "github.com/kxn/codex-remote-feishu/internal/adapter/claude/clauderecord" + "github.com/kxn/codex-remote-feishu/internal/core/agentproto" +) + +// TestTranslatorReplayHelloSimple replays a masked NDJSON fixture through +// the Claude translator and verifies the agentproto event sequence. +// The fixture is generated by the integration test (go test -tags integration). +func TestTranslatorReplayHelloSimple(t *testing.T) { + fixturePath := "testdata/hello_simple.ndjson" + if _, err := os.Stat(fixturePath); os.IsNotExist(err) { + t.Skipf("fixture %s not found; run integration test first: go test -tags integration -run TestClaudeCLIIntegration", fixturePath) + } + + entries, err := clauderecord.LoadFixture(fixturePath) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + + tr := claude.NewTranslator("replay-test") + tr.SetDebugLogger(func(format string, args ...any) { + t.Logf("translator: "+format, args...) + }) + + var allEvents []agentproto.Event + var allOutbound [][]byte + + for _, entry := range entries { + switch entry.Direction { + case clauderecord.DirRecv: + result, err := tr.ObserveServer(entry.Frame) + if err != nil { + t.Logf("observe seq=%d error: %v", entry.Seq, err) + continue + } + allEvents = append(allEvents, result.Events...) + allOutbound = append(allOutbound, result.OutboundToAgent...) + case clauderecord.DirSend: + result, err := tr.ObserveClient(entry.Frame) + if err != nil { + continue + } + allEvents = append(allEvents, result.Events...) + } + } + + t.Logf("replayed %d entries -> %d events, %d outbound frames", len(entries), len(allEvents), len(allOutbound)) + for i, e := range allEvents { + t.Logf(" event[%d]: kind=%s threadId=%s itemKind=%s delta_len=%d", + i, e.Kind, e.ThreadID, e.ItemKind, len(e.Delta)) + } + + // Verify expected event kinds appear in order + assertEventOrder(t, allEvents, []agentproto.EventKind{ + agentproto.EventThreadDiscovered, + agentproto.EventTurnStarted, + agentproto.EventItemStarted, + agentproto.EventItemCompleted, + agentproto.EventTurnCompleted, + }) + + // Verify turn completed successfully + turnCompleted := findEventByKind(allEvents, agentproto.EventTurnCompleted) + if turnCompleted == nil { + t.Fatal("missing EventTurnCompleted") + } + if turnCompleted.Status != "completed" { + t.Errorf("turn status: got %q, want %q", turnCompleted.Status, "completed") + } + + // Verify deltas contain text + var fullText string + for _, e := range allEvents { + if e.Kind == agentproto.EventItemDelta && e.ItemKind == "agent_message" { + fullText += e.Delta + } + } + if !strings.Contains(strings.ToLower(fullText), "hello") { + t.Errorf("expected delta text to contain 'hello', got %q", fullText) + } + + // Verify masked CWD propagated correctly + threadEvent := findEventByKind(allEvents, agentproto.EventThreadDiscovered) + if threadEvent != nil && threadEvent.CWD != "" { + if strings.Contains(threadEvent.CWD, "/home/") && !strings.Contains(threadEvent.CWD, "/test/") { + t.Errorf("CWD appears unmasked: %s", threadEvent.CWD) + } + } +} + +// TestTranslatorReplayMCPHandshake verifies that MCP control_request messages +// during initialization produce OutboundToAgent frames (the MCP responses). +func TestTranslatorReplayMCPHandshake(t *testing.T) { + fixturePath := "testdata/hello_simple.ndjson" + if _, err := os.Stat(fixturePath); os.IsNotExist(err) { + t.Skipf("fixture %s not found", fixturePath) + } + + entries, err := clauderecord.LoadFixture(fixturePath) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + + tr := claude.NewTranslator("mcp-test") + + var mcpResponseCount int + for _, entry := range clauderecord.RecvEntries(entries) { + result, err := tr.ObserveServer(entry.Frame) + if err != nil { + continue + } + mcpResponseCount += len(result.OutboundToAgent) + } + + // We expect at least one outbound frame if there were MCP setup messages. + // If the recording has no MCP setup (e.g. no MCP servers configured), + // this is still valid -- just log it. + t.Logf("MCP handshake produced %d outbound frames", mcpResponseCount) +} + +// assertEventOrder checks that the specified event kinds appear in order +// within the event list, allowing other events between them. +func assertEventOrder(t *testing.T, events []agentproto.Event, expected []agentproto.EventKind) { + t.Helper() + pos := 0 + for _, e := range events { + if pos < len(expected) && e.Kind == expected[pos] { + pos++ + } + } + if pos < len(expected) { + var found []agentproto.EventKind + for _, e := range events { + found = append(found, e.Kind) + } + t.Errorf("event sequence incomplete: matched %d/%d expected kinds\nexpected: %v\nfound: %v", + pos, len(expected), expected, found) + } +} + +func findEventByKind(events []agentproto.Event, kind agentproto.EventKind) *agentproto.Event { + for i := range events { + if events[i].Kind == kind { + return &events[i] + } + } + return nil +} From 13c3bbd65c4d4c63138f8875961bb77a153ffe75 Mon Sep 17 00:00:00 2001 From: Ming Zhao Date: Thu, 9 Apr 2026 00:47:40 +0000 Subject: [PATCH 3/3] test: extend Claude integration tests with tool use, thinking, and multi-turn Add three new integration test scenarios against real Claude CLI: - TestClaudeCLIToolUse: Bash tool invocation with input_json_delta streaming, tool result echo, and follow-up text response - TestClaudeCLIThinking: reasoning_content blocks with thinking_delta - TestClaudeCLIMultiTurn: two sequential turns with context retention Fix translator block index tracking: - Track blocks by protocol index field (map[int]*blockState) instead of a single activeBlockIndex counter. This correctly handles: - Multiple concurrent blocks (thinking at index 0, text at index 1) - assistant messages arriving before content_block_stop - signature_delta and unknown delta types (silently skipped) Replay tests now cover all 4 fixtures with structural assertions: - Block index uniqueness and start/complete pairing - Multi-turn ID separation - Tool metadata (toolName) propagation - Thinking delta presence Fixtures are privacy-masked (session_id, uuid, email, org, paths). --- .../claude/testdata/hello_simple.ndjson | 26 +- .../adapter/claude/testdata/multi_turn.ndjson | 23 ++ .../adapter/claude/testdata/thinking.ndjson | 14 + .../claude/testdata/tool_use_bash.ndjson | 31 ++ internal/adapter/claude/translator.go | 20 +- .../claude/translator_integration_test.go | 316 +++++++++++++----- .../claude/translator_observe_server.go | 68 ++-- .../adapter/claude/translator_replay_test.go | 296 ++++++++++++---- 8 files changed, 606 insertions(+), 188 deletions(-) create mode 100644 internal/adapter/claude/testdata/multi_turn.ndjson create mode 100644 internal/adapter/claude/testdata/thinking.ndjson create mode 100644 internal/adapter/claude/testdata/tool_use_bash.ndjson diff --git a/internal/adapter/claude/testdata/hello_simple.ndjson b/internal/adapter/claude/testdata/hello_simple.ndjson index 2ab99390..47491e8f 100644 --- a/internal/adapter/claude/testdata/hello_simple.ndjson +++ b/internal/adapter/claude/testdata/hello_simple.ndjson @@ -1,13 +1,13 @@ -{"ts":"2026-04-09T00:28:04.115264627Z","dir":"send","seq":0,"frame":{"request":{"subtype":"initialize"},"request_id":"relay-init-0","type":"control_request"}} -{"ts":"2026-04-09T00:28:06.06102458Z","dir":"recv","seq":1,"frame":{"response":{"request_id":"relay-init-0","response":{"account":{"apiProvider":"firstParty","email":"masked@example.com","organization":"masked-org","subscriptionType":"Claude Max"},"agents":[{"description":"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.","name":"general-purpose"},{"description":"Use this agent to configure the user's Claude Code status line setting.","model":"sonnet","name":"statusline-setup"},{"description":"Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.","model":"haiku","name":"Explore"},{"description":"Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.","name":"Plan"},{"description":"Use this agent when the user asks questions (\"Can Claude...\", \"Does Claude...\", \"How do I...\") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage.","model":"haiku","name":"claude-code-guide"}],"available_output_styles":["default","Explanatory","Learning"],"commands":[{"argumentHint":"","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, use Config tool. (bundled)","name":"update-config"},{"argumentHint":"[issue description]","description":"Enable debug logging for this session and help diagnose issues (bundled)","name":"debug"},{"argumentHint":"","description":"Review changed code for reuse, quality, and efficiency, then fix any issues found. (bundled)","name":"simplify"},{"argumentHint":"\u003cinstruction\u003e","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR. (bundled)","name":"batch"},{"argumentHint":"[interval] \u003cprompt\u003e","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) (bundled)","name":"loop"},{"argumentHint":"","description":"Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule. (bundled)","name":"schedule"},{"argumentHint":"","description":"Build Claude API / Anthropic SDK apps.\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks to use the Claude API, Anthropic SDKs, or Managed Agents (`/v1/agents`, `/v1/sessions`); or asks to add a Claude feature (prompt caching, adaptive thinking, compaction, code_execution, batch, files API, citations, memory tool) or a Claude model (Opus/Sonnet/Haiku) to a Claude file.\nDO NOT TRIGGER when: file imports `openai`/non-Anthropic SDK, filename signals another provider (`agent-openai.py`, `*-generic.py`), code is provider-neutral, or task is general programming/ML. (bundled)","name":"claude-api"},{"argumentHint":"\u003coptional custom summarization instructions\u003e","description":"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]","name":"compact"},{"argumentHint":"","description":"Show current context usage","name":"context"},{"argumentHint":"","description":"Show the total cost and duration of the current session","name":"cost"},{"argumentHint":"","description":"Dump the JS heap to ~/Desktop","name":"heapdump"},{"argumentHint":"","description":"Initialize a new CLAUDE.md file with codebase documentation","name":"init"},{"argumentHint":"","description":"Review a pull request","name":"review"},{"argumentHint":"","description":"Complete a security review of the pending changes on the current branch","name":"security-review"},{"argumentHint":"","description":"Configure extra usage to keep working when limits are hit","name":"extra-usage"},{"argumentHint":"","description":"Generate a report analyzing your Claude Code sessions","name":"insights"},{"argumentHint":"","description":"Find a time for a meeting with colleagues","name":"claude.ai Google Calendar:schedule_meeting (MCP)"},{"argumentHint":"","description":"Find time for a quick meeting today","name":"claude.ai Google Calendar:quick_meeting_today (MCP)"},{"argumentHint":"","description":"Schedule recurring 1:1 meetings","name":"claude.ai Google Calendar:recurring_1on1 (MCP)"},{"argumentHint":"","description":"Find time for a team meeting with multiple people","name":"claude.ai Google Calendar:team_meeting_scheduler (MCP)"},{"argumentHint":"","description":"Analyze someone's calendar availability patterns","name":"claude.ai Google Calendar:analyze_availability (MCP)"}],"models":[{"description":"Opus 4.6 with 1M context · Most capable for complex work","displayName":"Default (recommended)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"supportsFastMode":true,"value":"default"},{"description":"Sonnet 4.6 · Best for everyday tasks","displayName":"Sonnet","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet"},{"description":"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok","displayName":"Sonnet (1M context)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet[1m]"},{"description":"Haiku 4.5 · Fastest for quick answers","displayName":"Haiku","value":"haiku"}],"output_style":"default","pid":2291848},"subtype":"success"},"type":"control_response"}} -{"ts":"2026-04-09T00:28:09.117856715Z","dir":"send","seq":2,"frame":{"message":{"content":"respond with exactly the word hello and nothing else","role":"user"},"type":"user"}} -{"ts":"2026-04-09T00:28:09.128399829Z","dir":"recv","seq":3,"frame":{"agents":["general-purpose","statusline-setup","Explore","Plan","claude-code-guide"],"apiKeySource":"REDACTED","claude_code_version":"2.1.97","cwd":"/test/workspace","fast_mode_state":"off","mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Clerk","status":"connected"},{"name":"claude.ai Excalidraw","status":"connected"},{"name":"claude.ai Linear","status":"connected"},{"name":"claude.ai Ramp","status":"connected"},{"name":"claude.ai Notion","status":"connected"},{"name":"claude.ai Google Calendar","status":"connected"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-opus-4-6[1m]","output_style":"default","permissionMode":"bypassPermissions","plugins":[],"session_id":"masked-session-id","skills":["update-config","debug","simplify","batch","loop","schedule","claude-api"],"slash_commands":["update-config","debug","simplify","batch","loop","schedule","claude-api","compact","context","cost","heapdump","init","review","security-review","extra-usage","insights","mcp__claude_ai_Google_Calendar__schedule_meeting","mcp__claude_ai_Google_Calendar__quick_meeting_today","mcp__claude_ai_Google_Calendar__recurring_1on1","mcp__claude_ai_Google_Calendar__team_meeting_scheduler","mcp__claude_ai_Google_Calendar__analyze_availability"],"subtype":"init","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","NotebookEdit","Read","ReadMcpResourceTool","RemoteTrigger","SendMessage","Skill","TaskOutput","TaskStop","TeamCreate","TeamDelete","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Clerk__clerk_sdk_snippet","mcp__claude_ai_Clerk__list_clerk_sdk_snippets","mcp__claude_ai_Excalidraw__create_view","mcp__claude_ai_Excalidraw__export_to_excalidraw","mcp__claude_ai_Excalidraw__read_checkpoint","mcp__claude_ai_Excalidraw__read_me","mcp__claude_ai_Excalidraw__save_checkpoint","mcp__claude_ai_Gmail__gmail_create_draft","mcp__claude_ai_Gmail__gmail_get_profile","mcp__claude_ai_Gmail__gmail_list_drafts","mcp__claude_ai_Gmail__gmail_list_labels","mcp__claude_ai_Gmail__gmail_read_message","mcp__claude_ai_Gmail__gmail_read_thread","mcp__claude_ai_Gmail__gmail_search_messages","mcp__claude_ai_Google_Calendar__gcal_create_event","mcp__claude_ai_Google_Calendar__gcal_delete_event","mcp__claude_ai_Google_Calendar__gcal_find_meeting_times","mcp__claude_ai_Google_Calendar__gcal_find_my_free_time","mcp__claude_ai_Google_Calendar__gcal_get_event","mcp__claude_ai_Google_Calendar__gcal_list_calendars","mcp__claude_ai_Google_Calendar__gcal_list_events","mcp__claude_ai_Google_Calendar__gcal_respond_to_event","mcp__claude_ai_Google_Calendar__gcal_update_event","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Linear__create_attachment","mcp__claude_ai_Linear__create_document","mcp__claude_ai_Linear__create_issue_label","mcp__claude_ai_Linear__delete_attachment","mcp__claude_ai_Linear__delete_comment","mcp__claude_ai_Linear__delete_status_update","mcp__claude_ai_Linear__extract_images","mcp__claude_ai_Linear__get_attachment","mcp__claude_ai_Linear__get_document","mcp__claude_ai_Linear__get_initiative","mcp__claude_ai_Linear__get_issue","mcp__claude_ai_Linear__get_issue_status","mcp__claude_ai_Linear__get_milestone","mcp__claude_ai_Linear__get_project","mcp__claude_ai_Linear__get_status_updates","mcp__claude_ai_Linear__get_team","mcp__claude_ai_Linear__get_user","mcp__claude_ai_Linear__list_comments","mcp__claude_ai_Linear__list_cycles","mcp__claude_ai_Linear__list_documents","mcp__claude_ai_Linear__list_initiatives","mcp__claude_ai_Linear__list_issue_labels","mcp__claude_ai_Linear__list_issue_statuses","mcp__claude_ai_Linear__list_issues","mcp__claude_ai_Linear__list_milestones","mcp__claude_ai_Linear__list_project_labels","mcp__claude_ai_Linear__list_projects","mcp__claude_ai_Linear__list_teams","mcp__claude_ai_Linear__list_users","mcp__claude_ai_Linear__research","mcp__claude_ai_Linear__save_comment","mcp__claude_ai_Linear__save_initiative","mcp__claude_ai_Linear__save_issue","mcp__claude_ai_Linear__save_milestone","mcp__claude_ai_Linear__save_project","mcp__claude_ai_Linear__save_status_update","mcp__claude_ai_Linear__search_documentation","mcp__claude_ai_Linear__update_document","mcp__claude_ai_Notion__notion-create-comment","mcp__claude_ai_Notion__notion-create-database","mcp__claude_ai_Notion__notion-create-pages","mcp__claude_ai_Notion__notion-create-view","mcp__claude_ai_Notion__notion-duplicate-page","mcp__claude_ai_Notion__notion-fetch","mcp__claude_ai_Notion__notion-get-comments","mcp__claude_ai_Notion__notion-get-teams","mcp__claude_ai_Notion__notion-get-users","mcp__claude_ai_Notion__notion-move-pages","mcp__claude_ai_Notion__notion-query-database-view","mcp__claude_ai_Notion__notion-query-meeting-notes","mcp__claude_ai_Notion__notion-search","mcp__claude_ai_Notion__notion-update-data-source","mcp__claude_ai_Notion__notion-update-page","mcp__claude_ai_Notion__notion-update-view","mcp__claude_ai_Ramp__get_current_user","mcp__claude_ai_Ramp__ramp_ask_help_center","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"type":"system","uuid":"masked-uuid-1"}} -{"ts":"2026-04-09T00:28:10.443737419Z","dir":"recv","seq":4,"frame":{"event":{"message":{"content":[],"id":"msg_017duNa1zGCNPyrMQGGeXsdN","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6190,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-2"}} -{"ts":"2026-04-09T00:28:10.44388168Z","dir":"recv","seq":5,"frame":{"event":{"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-3"}} -{"ts":"2026-04-09T00:28:10.444821081Z","dir":"recv","seq":6,"frame":{"event":{"delta":{"text":"hello","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-4"}} -{"ts":"2026-04-09T00:28:10.513194948Z","dir":"recv","seq":7,"frame":{"message":{"content":[{"text":"hello","type":"text"}],"context_management":null,"id":"msg_017duNa1zGCNPyrMQGGeXsdN","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6190,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-5"}} -{"ts":"2026-04-09T00:28:10.513274788Z","dir":"recv","seq":8,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-6"}} -{"ts":"2026-04-09T00:28:10.536621868Z","dir":"recv","seq":9,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"input_tokens":3,"output_tokens":4}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-7"}} -{"ts":"2026-04-09T00:28:10.536723848Z","dir":"recv","seq":10,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-8"}} -{"ts":"2026-04-09T00:28:10.537249989Z","dir":"recv","seq":11,"frame":{"rate_limit_info":{"isUsingOverage":false,"rateLimitType":"seven_day","resetsAt":1775793600,"status":"allowed_warning","surpassedThreshold":0.75,"utilization":0.84},"session_id":"masked-session-id","type":"rate_limit_event","uuid":"masked-uuid-9"}} -{"ts":"2026-04-09T00:28:10.538797201Z","dir":"recv","seq":12,"frame":{"duration_api_ms":1406,"duration_ms":1416,"fast_mode_state":"off","is_error":false,"modelUsage":{"claude-opus-4-6[1m]":{"cacheCreationInputTokens":6190,"cacheReadInputTokens":11448,"contextWindow":1000000,"costUSD":0.0445265,"inputTokens":3,"maxOutputTokens":64000,"outputTokens":4,"webSearchRequests":0}},"num_turns":1,"permission_denials":[],"result":"hello","session_id":"masked-session-id","stop_reason":"end_turn","subtype":"success","terminal_reason":"completed","total_cost_usd":0.0445265,"type":"result","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6190,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6190,"cache_read_input_tokens":11448,"inference_geo":"","input_tokens":3,"iterations":[],"output_tokens":4,"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0},"service_tier":"standard","speed":"standard"},"uuid":"masked-uuid-10"}} +{"ts":"2026-04-09T00:45:56.523435978Z","dir":"send","seq":0,"frame":{"request":{"subtype":"initialize"},"request_id":"relay-init-0","type":"control_request"}} +{"ts":"2026-04-09T00:45:58.639256704Z","dir":"recv","seq":1,"frame":{"response":{"request_id":"relay-init-0","response":{"account":{"apiProvider":"firstParty","email":"masked@example.com","organization":"masked-org","subscriptionType":"Claude Max"},"agents":[{"description":"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.","name":"general-purpose"},{"description":"Use this agent to configure the user's Claude Code status line setting.","model":"sonnet","name":"statusline-setup"},{"description":"Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.","model":"haiku","name":"Explore"},{"description":"Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.","name":"Plan"},{"description":"Use this agent when the user asks questions (\"Can Claude...\", \"Does Claude...\", \"How do I...\") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage.","model":"haiku","name":"claude-code-guide"}],"available_output_styles":["default","Explanatory","Learning"],"commands":[{"argumentHint":"","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, use Config tool. (bundled)","name":"update-config"},{"argumentHint":"[issue description]","description":"Enable debug logging for this session and help diagnose issues (bundled)","name":"debug"},{"argumentHint":"","description":"Review changed code for reuse, quality, and efficiency, then fix any issues found. (bundled)","name":"simplify"},{"argumentHint":"\u003cinstruction\u003e","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR. (bundled)","name":"batch"},{"argumentHint":"[interval] \u003cprompt\u003e","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) (bundled)","name":"loop"},{"argumentHint":"","description":"Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule. (bundled)","name":"schedule"},{"argumentHint":"","description":"Build Claude API / Anthropic SDK apps.\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks to use the Claude API, Anthropic SDKs, or Managed Agents (`/v1/agents`, `/v1/sessions`); or asks to add a Claude feature (prompt caching, adaptive thinking, compaction, code_execution, batch, files API, citations, memory tool) or a Claude model (Opus/Sonnet/Haiku) to a Claude file.\nDO NOT TRIGGER when: file imports `openai`/non-Anthropic SDK, filename signals another provider (`agent-openai.py`, `*-generic.py`), code is provider-neutral, or task is general programming/ML. (bundled)","name":"claude-api"},{"argumentHint":"\u003coptional custom summarization instructions\u003e","description":"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]","name":"compact"},{"argumentHint":"","description":"Show current context usage","name":"context"},{"argumentHint":"","description":"Show the total cost and duration of the current session","name":"cost"},{"argumentHint":"","description":"Dump the JS heap to ~/Desktop","name":"heapdump"},{"argumentHint":"","description":"Initialize a new CLAUDE.md file with codebase documentation","name":"init"},{"argumentHint":"","description":"Review a pull request","name":"review"},{"argumentHint":"","description":"Complete a security review of the pending changes on the current branch","name":"security-review"},{"argumentHint":"","description":"Configure extra usage to keep working when limits are hit","name":"extra-usage"},{"argumentHint":"","description":"Generate a report analyzing your Claude Code sessions","name":"insights"},{"argumentHint":"","description":"Find a time for a meeting with colleagues","name":"claude.ai Google Calendar:schedule_meeting (MCP)"},{"argumentHint":"","description":"Find time for a quick meeting today","name":"claude.ai Google Calendar:quick_meeting_today (MCP)"},{"argumentHint":"","description":"Schedule recurring 1:1 meetings","name":"claude.ai Google Calendar:recurring_1on1 (MCP)"},{"argumentHint":"","description":"Find time for a team meeting with multiple people","name":"claude.ai Google Calendar:team_meeting_scheduler (MCP)"},{"argumentHint":"","description":"Analyze someone's calendar availability patterns","name":"claude.ai Google Calendar:analyze_availability (MCP)"}],"models":[{"description":"Opus 4.6 with 1M context · Most capable for complex work","displayName":"Default (recommended)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"supportsFastMode":true,"value":"default"},{"description":"Sonnet 4.6 · Best for everyday tasks","displayName":"Sonnet","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet"},{"description":"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok","displayName":"Sonnet (1M context)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet[1m]"},{"description":"Haiku 4.5 · Fastest for quick answers","displayName":"Haiku","value":"haiku"}],"output_style":"default","pid":2878468},"subtype":"success"},"type":"control_response"}} +{"ts":"2026-04-09T00:46:01.526215206Z","dir":"send","seq":2,"frame":{"message":{"content":"respond with exactly the word hello and nothing else","role":"user"},"type":"user"}} +{"ts":"2026-04-09T00:46:01.537174619Z","dir":"recv","seq":3,"frame":{"agents":["general-purpose","statusline-setup","Explore","Plan","claude-code-guide"],"apiKeySource":"REDACTED","claude_code_version":"2.1.97","cwd":"/test/workspace","fast_mode_state":"off","mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Clerk","status":"connected"},{"name":"claude.ai Excalidraw","status":"connected"},{"name":"claude.ai Linear","status":"connected"},{"name":"claude.ai Ramp","status":"connected"},{"name":"claude.ai Notion","status":"connected"},{"name":"claude.ai Google Calendar","status":"connected"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-opus-4-6[1m]","output_style":"default","permissionMode":"bypassPermissions","plugins":[],"session_id":"masked-session-id","skills":["update-config","debug","simplify","batch","loop","schedule","claude-api"],"slash_commands":["update-config","debug","simplify","batch","loop","schedule","claude-api","compact","context","cost","heapdump","init","review","security-review","extra-usage","insights","mcp__claude_ai_Google_Calendar__schedule_meeting","mcp__claude_ai_Google_Calendar__quick_meeting_today","mcp__claude_ai_Google_Calendar__recurring_1on1","mcp__claude_ai_Google_Calendar__team_meeting_scheduler","mcp__claude_ai_Google_Calendar__analyze_availability"],"subtype":"init","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","NotebookEdit","Read","ReadMcpResourceTool","RemoteTrigger","SendMessage","Skill","TaskOutput","TaskStop","TeamCreate","TeamDelete","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Clerk__clerk_sdk_snippet","mcp__claude_ai_Clerk__list_clerk_sdk_snippets","mcp__claude_ai_Excalidraw__create_view","mcp__claude_ai_Excalidraw__export_to_excalidraw","mcp__claude_ai_Excalidraw__read_checkpoint","mcp__claude_ai_Excalidraw__read_me","mcp__claude_ai_Excalidraw__save_checkpoint","mcp__claude_ai_Gmail__gmail_create_draft","mcp__claude_ai_Gmail__gmail_get_profile","mcp__claude_ai_Gmail__gmail_list_drafts","mcp__claude_ai_Gmail__gmail_list_labels","mcp__claude_ai_Gmail__gmail_read_message","mcp__claude_ai_Gmail__gmail_read_thread","mcp__claude_ai_Gmail__gmail_search_messages","mcp__claude_ai_Google_Calendar__gcal_create_event","mcp__claude_ai_Google_Calendar__gcal_delete_event","mcp__claude_ai_Google_Calendar__gcal_find_meeting_times","mcp__claude_ai_Google_Calendar__gcal_find_my_free_time","mcp__claude_ai_Google_Calendar__gcal_get_event","mcp__claude_ai_Google_Calendar__gcal_list_calendars","mcp__claude_ai_Google_Calendar__gcal_list_events","mcp__claude_ai_Google_Calendar__gcal_respond_to_event","mcp__claude_ai_Google_Calendar__gcal_update_event","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Linear__create_attachment","mcp__claude_ai_Linear__create_document","mcp__claude_ai_Linear__create_issue_label","mcp__claude_ai_Linear__delete_attachment","mcp__claude_ai_Linear__delete_comment","mcp__claude_ai_Linear__delete_status_update","mcp__claude_ai_Linear__extract_images","mcp__claude_ai_Linear__get_attachment","mcp__claude_ai_Linear__get_document","mcp__claude_ai_Linear__get_initiative","mcp__claude_ai_Linear__get_issue","mcp__claude_ai_Linear__get_issue_status","mcp__claude_ai_Linear__get_milestone","mcp__claude_ai_Linear__get_project","mcp__claude_ai_Linear__get_status_updates","mcp__claude_ai_Linear__get_team","mcp__claude_ai_Linear__get_user","mcp__claude_ai_Linear__list_comments","mcp__claude_ai_Linear__list_cycles","mcp__claude_ai_Linear__list_documents","mcp__claude_ai_Linear__list_initiatives","mcp__claude_ai_Linear__list_issue_labels","mcp__claude_ai_Linear__list_issue_statuses","mcp__claude_ai_Linear__list_issues","mcp__claude_ai_Linear__list_milestones","mcp__claude_ai_Linear__list_project_labels","mcp__claude_ai_Linear__list_projects","mcp__claude_ai_Linear__list_teams","mcp__claude_ai_Linear__list_users","mcp__claude_ai_Linear__research","mcp__claude_ai_Linear__save_comment","mcp__claude_ai_Linear__save_initiative","mcp__claude_ai_Linear__save_issue","mcp__claude_ai_Linear__save_milestone","mcp__claude_ai_Linear__save_project","mcp__claude_ai_Linear__save_status_update","mcp__claude_ai_Linear__search_documentation","mcp__claude_ai_Linear__update_document","mcp__claude_ai_Notion__notion-create-comment","mcp__claude_ai_Notion__notion-create-database","mcp__claude_ai_Notion__notion-create-pages","mcp__claude_ai_Notion__notion-create-view","mcp__claude_ai_Notion__notion-duplicate-page","mcp__claude_ai_Notion__notion-fetch","mcp__claude_ai_Notion__notion-get-comments","mcp__claude_ai_Notion__notion-get-teams","mcp__claude_ai_Notion__notion-get-users","mcp__claude_ai_Notion__notion-move-pages","mcp__claude_ai_Notion__notion-query-database-view","mcp__claude_ai_Notion__notion-query-meeting-notes","mcp__claude_ai_Notion__notion-search","mcp__claude_ai_Notion__notion-update-data-source","mcp__claude_ai_Notion__notion-update-page","mcp__claude_ai_Notion__notion-update-view","mcp__claude_ai_Ramp__get_current_user","mcp__claude_ai_Ramp__ramp_ask_help_center","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"type":"system","uuid":"masked-uuid-1"}} +{"ts":"2026-04-09T00:46:03.605140698Z","dir":"recv","seq":4,"frame":{"event":{"message":{"content":[],"id":"msg_014Sj4xRXjveWHoKZ54ghyG2","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6345,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6345,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-2"}} +{"ts":"2026-04-09T00:46:03.605311028Z","dir":"recv","seq":5,"frame":{"event":{"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-3"}} +{"ts":"2026-04-09T00:46:03.605467809Z","dir":"recv","seq":6,"frame":{"event":{"delta":{"text":"hello","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-4"}} +{"ts":"2026-04-09T00:46:03.793407249Z","dir":"recv","seq":7,"frame":{"message":{"content":[{"text":"hello","type":"text"}],"context_management":null,"id":"msg_014Sj4xRXjveWHoKZ54ghyG2","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6345,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6345,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-5"}} +{"ts":"2026-04-09T00:46:03.79358336Z","dir":"recv","seq":8,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-6"}} +{"ts":"2026-04-09T00:46:03.880407912Z","dir":"recv","seq":9,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6345,"cache_read_input_tokens":11448,"input_tokens":3,"output_tokens":4}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-7"}} +{"ts":"2026-04-09T00:46:03.88756068Z","dir":"recv","seq":10,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-8"}} +{"ts":"2026-04-09T00:46:03.889668633Z","dir":"recv","seq":11,"frame":{"rate_limit_info":{"isUsingOverage":false,"rateLimitType":"seven_day","resetsAt":1775793600,"status":"allowed_warning","surpassedThreshold":0.75,"utilization":0.85},"session_id":"masked-session-id","type":"rate_limit_event","uuid":"masked-uuid-9"}} +{"ts":"2026-04-09T00:46:03.891392595Z","dir":"recv","seq":12,"frame":{"duration_api_ms":2349,"duration_ms":2361,"fast_mode_state":"off","is_error":false,"modelUsage":{"claude-opus-4-6[1m]":{"cacheCreationInputTokens":6345,"cacheReadInputTokens":11448,"contextWindow":1000000,"costUSD":0.045495249999999994,"inputTokens":3,"maxOutputTokens":64000,"outputTokens":4,"webSearchRequests":0}},"num_turns":1,"permission_denials":[],"result":"hello","session_id":"masked-session-id","stop_reason":"end_turn","subtype":"success","terminal_reason":"completed","total_cost_usd":0.045495249999999994,"type":"result","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6345,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6345,"cache_read_input_tokens":11448,"inference_geo":"","input_tokens":3,"iterations":[],"output_tokens":4,"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0},"service_tier":"standard","speed":"standard"},"uuid":"masked-uuid-10"}} diff --git a/internal/adapter/claude/testdata/multi_turn.ndjson b/internal/adapter/claude/testdata/multi_turn.ndjson new file mode 100644 index 00000000..20b2e014 --- /dev/null +++ b/internal/adapter/claude/testdata/multi_turn.ndjson @@ -0,0 +1,23 @@ +{"ts":"2026-04-09T00:46:20.914898506Z","dir":"send","seq":0,"frame":{"request":{"subtype":"initialize"},"request_id":"relay-init-0","type":"control_request"}} +{"ts":"2026-04-09T00:46:25.918832885Z","dir":"send","seq":1,"frame":{"message":{"content":"remember the word 'banana'. respond with just 'ok'","role":"user"},"type":"user"}} +{"ts":"2026-04-09T00:46:26.399089069Z","dir":"recv","seq":2,"frame":{"response":{"request_id":"relay-init-0","response":{"account":{"apiProvider":"firstParty","email":"masked@example.com","organization":"masked-org","subscriptionType":"Claude Max"},"agents":[{"description":"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.","name":"general-purpose"},{"description":"Use this agent to configure the user's Claude Code status line setting.","model":"sonnet","name":"statusline-setup"},{"description":"Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.","model":"haiku","name":"Explore"},{"description":"Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.","name":"Plan"},{"description":"Use this agent when the user asks questions (\"Can Claude...\", \"Does Claude...\", \"How do I...\") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage.","model":"haiku","name":"claude-code-guide"}],"available_output_styles":["default","Explanatory","Learning"],"commands":[{"argumentHint":"","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, use Config tool. (bundled)","name":"update-config"},{"argumentHint":"[issue description]","description":"Enable debug logging for this session and help diagnose issues (bundled)","name":"debug"},{"argumentHint":"","description":"Review changed code for reuse, quality, and efficiency, then fix any issues found. (bundled)","name":"simplify"},{"argumentHint":"\u003cinstruction\u003e","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR. (bundled)","name":"batch"},{"argumentHint":"[interval] \u003cprompt\u003e","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) (bundled)","name":"loop"},{"argumentHint":"","description":"Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule. (bundled)","name":"schedule"},{"argumentHint":"","description":"Build Claude API / Anthropic SDK apps.\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks to use the Claude API, Anthropic SDKs, or Managed Agents (`/v1/agents`, `/v1/sessions`); or asks to add a Claude feature (prompt caching, adaptive thinking, compaction, code_execution, batch, files API, citations, memory tool) or a Claude model (Opus/Sonnet/Haiku) to a Claude file.\nDO NOT TRIGGER when: file imports `openai`/non-Anthropic SDK, filename signals another provider (`agent-openai.py`, `*-generic.py`), code is provider-neutral, or task is general programming/ML. (bundled)","name":"claude-api"},{"argumentHint":"\u003coptional custom summarization instructions\u003e","description":"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]","name":"compact"},{"argumentHint":"","description":"Show current context usage","name":"context"},{"argumentHint":"","description":"Show the total cost and duration of the current session","name":"cost"},{"argumentHint":"","description":"Dump the JS heap to ~/Desktop","name":"heapdump"},{"argumentHint":"","description":"Initialize a new CLAUDE.md file with codebase documentation","name":"init"},{"argumentHint":"","description":"Review a pull request","name":"review"},{"argumentHint":"","description":"Complete a security review of the pending changes on the current branch","name":"security-review"},{"argumentHint":"","description":"Configure extra usage to keep working when limits are hit","name":"extra-usage"},{"argumentHint":"","description":"Generate a report analyzing your Claude Code sessions","name":"insights"},{"argumentHint":"","description":"Find a time for a meeting with colleagues","name":"claude.ai Google Calendar:schedule_meeting (MCP)"},{"argumentHint":"","description":"Find time for a quick meeting today","name":"claude.ai Google Calendar:quick_meeting_today (MCP)"},{"argumentHint":"","description":"Schedule recurring 1:1 meetings","name":"claude.ai Google Calendar:recurring_1on1 (MCP)"},{"argumentHint":"","description":"Find time for a team meeting with multiple people","name":"claude.ai Google Calendar:team_meeting_scheduler (MCP)"},{"argumentHint":"","description":"Analyze someone's calendar availability patterns","name":"claude.ai Google Calendar:analyze_availability (MCP)"}],"models":[{"description":"Opus 4.6 with 1M context · Most capable for complex work","displayName":"Default (recommended)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"supportsFastMode":true,"value":"default"},{"description":"Sonnet 4.6 · Best for everyday tasks","displayName":"Sonnet","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet"},{"description":"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok","displayName":"Sonnet (1M context)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet[1m]"},{"description":"Haiku 4.5 · Fastest for quick answers","displayName":"Haiku","value":"haiku"}],"output_style":"default","pid":2892647},"subtype":"success"},"type":"control_response"}} +{"ts":"2026-04-09T00:46:26.410605373Z","dir":"recv","seq":3,"frame":{"agents":["general-purpose","statusline-setup","Explore","Plan","claude-code-guide"],"apiKeySource":"REDACTED","claude_code_version":"2.1.97","cwd":"/test/workspace","fast_mode_state":"off","mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Clerk","status":"connected"},{"name":"claude.ai Excalidraw","status":"connected"},{"name":"claude.ai Linear","status":"connected"},{"name":"claude.ai Ramp","status":"connected"},{"name":"claude.ai Notion","status":"pending"},{"name":"claude.ai Google Calendar","status":"connected"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-opus-4-6[1m]","output_style":"default","permissionMode":"bypassPermissions","plugins":[],"session_id":"masked-session-id","skills":["update-config","debug","simplify","batch","loop","schedule","claude-api"],"slash_commands":["update-config","debug","simplify","batch","loop","schedule","claude-api","compact","context","cost","heapdump","init","review","security-review","extra-usage","insights","mcp__claude_ai_Google_Calendar__schedule_meeting","mcp__claude_ai_Google_Calendar__quick_meeting_today","mcp__claude_ai_Google_Calendar__recurring_1on1","mcp__claude_ai_Google_Calendar__team_meeting_scheduler","mcp__claude_ai_Google_Calendar__analyze_availability"],"subtype":"init","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","NotebookEdit","Read","ReadMcpResourceTool","RemoteTrigger","SendMessage","Skill","TaskOutput","TaskStop","TeamCreate","TeamDelete","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Clerk__clerk_sdk_snippet","mcp__claude_ai_Clerk__list_clerk_sdk_snippets","mcp__claude_ai_Excalidraw__create_view","mcp__claude_ai_Excalidraw__export_to_excalidraw","mcp__claude_ai_Excalidraw__read_checkpoint","mcp__claude_ai_Excalidraw__read_me","mcp__claude_ai_Excalidraw__save_checkpoint","mcp__claude_ai_Gmail__gmail_create_draft","mcp__claude_ai_Gmail__gmail_get_profile","mcp__claude_ai_Gmail__gmail_list_drafts","mcp__claude_ai_Gmail__gmail_list_labels","mcp__claude_ai_Gmail__gmail_read_message","mcp__claude_ai_Gmail__gmail_read_thread","mcp__claude_ai_Gmail__gmail_search_messages","mcp__claude_ai_Google_Calendar__gcal_create_event","mcp__claude_ai_Google_Calendar__gcal_delete_event","mcp__claude_ai_Google_Calendar__gcal_find_meeting_times","mcp__claude_ai_Google_Calendar__gcal_find_my_free_time","mcp__claude_ai_Google_Calendar__gcal_get_event","mcp__claude_ai_Google_Calendar__gcal_list_calendars","mcp__claude_ai_Google_Calendar__gcal_list_events","mcp__claude_ai_Google_Calendar__gcal_respond_to_event","mcp__claude_ai_Google_Calendar__gcal_update_event","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Linear__create_attachment","mcp__claude_ai_Linear__create_document","mcp__claude_ai_Linear__create_issue_label","mcp__claude_ai_Linear__delete_attachment","mcp__claude_ai_Linear__delete_comment","mcp__claude_ai_Linear__delete_status_update","mcp__claude_ai_Linear__extract_images","mcp__claude_ai_Linear__get_attachment","mcp__claude_ai_Linear__get_document","mcp__claude_ai_Linear__get_initiative","mcp__claude_ai_Linear__get_issue","mcp__claude_ai_Linear__get_issue_status","mcp__claude_ai_Linear__get_milestone","mcp__claude_ai_Linear__get_project","mcp__claude_ai_Linear__get_status_updates","mcp__claude_ai_Linear__get_team","mcp__claude_ai_Linear__get_user","mcp__claude_ai_Linear__list_comments","mcp__claude_ai_Linear__list_cycles","mcp__claude_ai_Linear__list_documents","mcp__claude_ai_Linear__list_initiatives","mcp__claude_ai_Linear__list_issue_labels","mcp__claude_ai_Linear__list_issue_statuses","mcp__claude_ai_Linear__list_issues","mcp__claude_ai_Linear__list_milestones","mcp__claude_ai_Linear__list_project_labels","mcp__claude_ai_Linear__list_projects","mcp__claude_ai_Linear__list_teams","mcp__claude_ai_Linear__list_users","mcp__claude_ai_Linear__research","mcp__claude_ai_Linear__save_comment","mcp__claude_ai_Linear__save_initiative","mcp__claude_ai_Linear__save_issue","mcp__claude_ai_Linear__save_milestone","mcp__claude_ai_Linear__save_project","mcp__claude_ai_Linear__save_status_update","mcp__claude_ai_Linear__search_documentation","mcp__claude_ai_Linear__update_document","mcp__claude_ai_Ramp__get_current_user","mcp__claude_ai_Ramp__ramp_ask_help_center","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"type":"system","uuid":"masked-uuid-1"}} +{"ts":"2026-04-09T00:46:28.362917332Z","dir":"recv","seq":4,"frame":{"event":{"message":{"content":[],"id":"msg_01AMyh7VAeTtisCoZNNynxJf","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6092,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6092,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-2"}} +{"ts":"2026-04-09T00:46:28.363197332Z","dir":"recv","seq":5,"frame":{"event":{"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-3"}} +{"ts":"2026-04-09T00:46:28.363271833Z","dir":"recv","seq":6,"frame":{"event":{"delta":{"text":"ok","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-4"}} +{"ts":"2026-04-09T00:46:28.442404515Z","dir":"recv","seq":7,"frame":{"message":{"content":[{"text":"ok","type":"text"}],"context_management":null,"id":"msg_01AMyh7VAeTtisCoZNNynxJf","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6092,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6092,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-5"}} +{"ts":"2026-04-09T00:46:28.442471955Z","dir":"recv","seq":8,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-6"}} +{"ts":"2026-04-09T00:46:28.507581481Z","dir":"recv","seq":9,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6092,"cache_read_input_tokens":11448,"input_tokens":3,"output_tokens":4}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-7"}} +{"ts":"2026-04-09T00:46:28.507720381Z","dir":"recv","seq":10,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-8"}} +{"ts":"2026-04-09T00:46:28.509288973Z","dir":"recv","seq":11,"frame":{"rate_limit_info":{"isUsingOverage":false,"rateLimitType":"seven_day","resetsAt":1775793600,"status":"allowed_warning","surpassedThreshold":0.75,"utilization":0.85},"session_id":"masked-session-id","type":"rate_limit_event","uuid":"masked-uuid-9"}} +{"ts":"2026-04-09T00:46:28.511256215Z","dir":"recv","seq":12,"frame":{"duration_api_ms":2096,"duration_ms":2109,"fast_mode_state":"off","is_error":false,"modelUsage":{"claude-opus-4-6[1m]":{"cacheCreationInputTokens":6092,"cacheReadInputTokens":11448,"contextWindow":1000000,"costUSD":0.043913999999999995,"inputTokens":3,"maxOutputTokens":64000,"outputTokens":4,"webSearchRequests":0}},"num_turns":1,"permission_denials":[],"result":"ok","session_id":"masked-session-id","stop_reason":"end_turn","subtype":"success","terminal_reason":"completed","total_cost_usd":0.043913999999999995,"type":"result","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6092,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6092,"cache_read_input_tokens":11448,"inference_geo":"","input_tokens":3,"iterations":[],"output_tokens":4,"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0},"service_tier":"standard","speed":"standard"},"uuid":"masked-uuid-10"}} +{"ts":"2026-04-09T00:46:28.511487666Z","dir":"send","seq":13,"frame":{"message":{"content":"what word did I ask you to remember? respond with just that word","role":"user"},"type":"user"}} +{"ts":"2026-04-09T00:46:28.51530612Z","dir":"recv","seq":14,"frame":{"agents":["general-purpose","statusline-setup","Explore","Plan","claude-code-guide"],"apiKeySource":"REDACTED","claude_code_version":"2.1.97","cwd":"/test/workspace","fast_mode_state":"off","mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Clerk","status":"connected"},{"name":"claude.ai Excalidraw","status":"connected"},{"name":"claude.ai Linear","status":"connected"},{"name":"claude.ai Ramp","status":"connected"},{"name":"claude.ai Notion","status":"connected"},{"name":"claude.ai Google Calendar","status":"connected"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-opus-4-6[1m]","output_style":"default","permissionMode":"bypassPermissions","plugins":[],"session_id":"masked-session-id","skills":["update-config","debug","simplify","batch","loop","schedule","claude-api"],"slash_commands":["update-config","debug","simplify","batch","loop","schedule","claude-api","compact","context","cost","heapdump","init","review","security-review","extra-usage","insights","mcp__claude_ai_Google_Calendar__schedule_meeting","mcp__claude_ai_Google_Calendar__quick_meeting_today","mcp__claude_ai_Google_Calendar__recurring_1on1","mcp__claude_ai_Google_Calendar__team_meeting_scheduler","mcp__claude_ai_Google_Calendar__analyze_availability"],"subtype":"init","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","NotebookEdit","Read","ReadMcpResourceTool","RemoteTrigger","SendMessage","Skill","TaskOutput","TaskStop","TeamCreate","TeamDelete","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Clerk__clerk_sdk_snippet","mcp__claude_ai_Clerk__list_clerk_sdk_snippets","mcp__claude_ai_Excalidraw__create_view","mcp__claude_ai_Excalidraw__export_to_excalidraw","mcp__claude_ai_Excalidraw__read_checkpoint","mcp__claude_ai_Excalidraw__read_me","mcp__claude_ai_Excalidraw__save_checkpoint","mcp__claude_ai_Gmail__gmail_create_draft","mcp__claude_ai_Gmail__gmail_get_profile","mcp__claude_ai_Gmail__gmail_list_drafts","mcp__claude_ai_Gmail__gmail_list_labels","mcp__claude_ai_Gmail__gmail_read_message","mcp__claude_ai_Gmail__gmail_read_thread","mcp__claude_ai_Gmail__gmail_search_messages","mcp__claude_ai_Google_Calendar__gcal_create_event","mcp__claude_ai_Google_Calendar__gcal_delete_event","mcp__claude_ai_Google_Calendar__gcal_find_meeting_times","mcp__claude_ai_Google_Calendar__gcal_find_my_free_time","mcp__claude_ai_Google_Calendar__gcal_get_event","mcp__claude_ai_Google_Calendar__gcal_list_calendars","mcp__claude_ai_Google_Calendar__gcal_list_events","mcp__claude_ai_Google_Calendar__gcal_respond_to_event","mcp__claude_ai_Google_Calendar__gcal_update_event","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Linear__create_attachment","mcp__claude_ai_Linear__create_document","mcp__claude_ai_Linear__create_issue_label","mcp__claude_ai_Linear__delete_attachment","mcp__claude_ai_Linear__delete_comment","mcp__claude_ai_Linear__delete_status_update","mcp__claude_ai_Linear__extract_images","mcp__claude_ai_Linear__get_attachment","mcp__claude_ai_Linear__get_document","mcp__claude_ai_Linear__get_initiative","mcp__claude_ai_Linear__get_issue","mcp__claude_ai_Linear__get_issue_status","mcp__claude_ai_Linear__get_milestone","mcp__claude_ai_Linear__get_project","mcp__claude_ai_Linear__get_status_updates","mcp__claude_ai_Linear__get_team","mcp__claude_ai_Linear__get_user","mcp__claude_ai_Linear__list_comments","mcp__claude_ai_Linear__list_cycles","mcp__claude_ai_Linear__list_documents","mcp__claude_ai_Linear__list_initiatives","mcp__claude_ai_Linear__list_issue_labels","mcp__claude_ai_Linear__list_issue_statuses","mcp__claude_ai_Linear__list_issues","mcp__claude_ai_Linear__list_milestones","mcp__claude_ai_Linear__list_project_labels","mcp__claude_ai_Linear__list_projects","mcp__claude_ai_Linear__list_teams","mcp__claude_ai_Linear__list_users","mcp__claude_ai_Linear__research","mcp__claude_ai_Linear__save_comment","mcp__claude_ai_Linear__save_initiative","mcp__claude_ai_Linear__save_issue","mcp__claude_ai_Linear__save_milestone","mcp__claude_ai_Linear__save_project","mcp__claude_ai_Linear__save_status_update","mcp__claude_ai_Linear__search_documentation","mcp__claude_ai_Linear__update_document","mcp__claude_ai_Notion__notion-create-comment","mcp__claude_ai_Notion__notion-create-database","mcp__claude_ai_Notion__notion-create-pages","mcp__claude_ai_Notion__notion-create-view","mcp__claude_ai_Notion__notion-duplicate-page","mcp__claude_ai_Notion__notion-fetch","mcp__claude_ai_Notion__notion-get-comments","mcp__claude_ai_Notion__notion-get-teams","mcp__claude_ai_Notion__notion-get-users","mcp__claude_ai_Notion__notion-move-pages","mcp__claude_ai_Notion__notion-query-database-view","mcp__claude_ai_Notion__notion-query-meeting-notes","mcp__claude_ai_Notion__notion-search","mcp__claude_ai_Notion__notion-update-data-source","mcp__claude_ai_Notion__notion-update-page","mcp__claude_ai_Notion__notion-update-view","mcp__claude_ai_Ramp__get_current_user","mcp__claude_ai_Ramp__ramp_ask_help_center","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"type":"system","uuid":"masked-uuid-11"}} +{"ts":"2026-04-09T00:46:29.771897466Z","dir":"recv","seq":15,"frame":{"event":{"message":{"content":[],"id":"msg_01FsNhD9vMHfz5DBG6u2S6X8","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6439,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6439,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-12"}} +{"ts":"2026-04-09T00:46:29.772012406Z","dir":"recv","seq":16,"frame":{"event":{"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-13"}} +{"ts":"2026-04-09T00:46:29.772533747Z","dir":"recv","seq":17,"frame":{"event":{"delta":{"text":"banana","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-14"}} +{"ts":"2026-04-09T00:46:29.837968363Z","dir":"recv","seq":18,"frame":{"message":{"content":[{"text":"banana","type":"text"}],"context_management":null,"id":"msg_01FsNhD9vMHfz5DBG6u2S6X8","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6439,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6439,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-15"}} +{"ts":"2026-04-09T00:46:29.838063483Z","dir":"recv","seq":19,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-16"}} +{"ts":"2026-04-09T00:46:29.883172346Z","dir":"recv","seq":20,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6439,"cache_read_input_tokens":11448,"input_tokens":3,"output_tokens":4}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-17"}} +{"ts":"2026-04-09T00:46:29.883477366Z","dir":"recv","seq":21,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-18"}} +{"ts":"2026-04-09T00:46:29.884917058Z","dir":"recv","seq":22,"frame":{"duration_api_ms":3464,"duration_ms":1371,"fast_mode_state":"off","is_error":false,"modelUsage":{"claude-opus-4-6[1m]":{"cacheCreationInputTokens":12531,"cacheReadInputTokens":22896,"contextWindow":1000000,"costUSD":0.08999674999999999,"inputTokens":6,"maxOutputTokens":64000,"outputTokens":8,"webSearchRequests":0}},"num_turns":1,"permission_denials":[],"result":"banana","session_id":"masked-session-id","stop_reason":"end_turn","subtype":"success","terminal_reason":"completed","total_cost_usd":0.08999674999999999,"type":"result","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6439,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6439,"cache_read_input_tokens":11448,"inference_geo":"","input_tokens":3,"iterations":[],"output_tokens":4,"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0},"service_tier":"standard","speed":"standard"},"uuid":"masked-uuid-19"}} diff --git a/internal/adapter/claude/testdata/thinking.ndjson b/internal/adapter/claude/testdata/thinking.ndjson new file mode 100644 index 00000000..abdf8d05 --- /dev/null +++ b/internal/adapter/claude/testdata/thinking.ndjson @@ -0,0 +1,14 @@ +{"ts":"2026-04-09T00:46:13.572675439Z","dir":"send","seq":0,"frame":{"request":{"subtype":"initialize"},"request_id":"relay-init-0","type":"control_request"}} +{"ts":"2026-04-09T00:46:16.56137009Z","dir":"recv","seq":1,"frame":{"response":{"request_id":"relay-init-0","response":{"account":{"apiProvider":"firstParty","email":"masked@example.com","organization":"masked-org","subscriptionType":"Claude Max"},"agents":[{"description":"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.","name":"general-purpose"},{"description":"Use this agent to configure the user's Claude Code status line setting.","model":"sonnet","name":"statusline-setup"},{"description":"Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.","model":"haiku","name":"Explore"},{"description":"Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.","name":"Plan"},{"description":"Use this agent when the user asks questions (\"Can Claude...\", \"Does Claude...\", \"How do I...\") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage.","model":"haiku","name":"claude-code-guide"}],"available_output_styles":["default","Explanatory","Learning"],"commands":[{"argumentHint":"","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, use Config tool. (bundled)","name":"update-config"},{"argumentHint":"[issue description]","description":"Enable debug logging for this session and help diagnose issues (bundled)","name":"debug"},{"argumentHint":"","description":"Review changed code for reuse, quality, and efficiency, then fix any issues found. (bundled)","name":"simplify"},{"argumentHint":"\u003cinstruction\u003e","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR. (bundled)","name":"batch"},{"argumentHint":"[interval] \u003cprompt\u003e","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) (bundled)","name":"loop"},{"argumentHint":"","description":"Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule. (bundled)","name":"schedule"},{"argumentHint":"","description":"Build Claude API / Anthropic SDK apps.\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks to use the Claude API, Anthropic SDKs, or Managed Agents (`/v1/agents`, `/v1/sessions`); or asks to add a Claude feature (prompt caching, adaptive thinking, compaction, code_execution, batch, files API, citations, memory tool) or a Claude model (Opus/Sonnet/Haiku) to a Claude file.\nDO NOT TRIGGER when: file imports `openai`/non-Anthropic SDK, filename signals another provider (`agent-openai.py`, `*-generic.py`), code is provider-neutral, or task is general programming/ML. (bundled)","name":"claude-api"},{"argumentHint":"\u003coptional custom summarization instructions\u003e","description":"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]","name":"compact"},{"argumentHint":"","description":"Show current context usage","name":"context"},{"argumentHint":"","description":"Show the total cost and duration of the current session","name":"cost"},{"argumentHint":"","description":"Dump the JS heap to ~/Desktop","name":"heapdump"},{"argumentHint":"","description":"Initialize a new CLAUDE.md file with codebase documentation","name":"init"},{"argumentHint":"","description":"Review a pull request","name":"review"},{"argumentHint":"","description":"Complete a security review of the pending changes on the current branch","name":"security-review"},{"argumentHint":"","description":"Configure extra usage to keep working when limits are hit","name":"extra-usage"},{"argumentHint":"","description":"Generate a report analyzing your Claude Code sessions","name":"insights"},{"argumentHint":"","description":"Find a time for a meeting with colleagues","name":"claude.ai Google Calendar:schedule_meeting (MCP)"},{"argumentHint":"","description":"Find time for a quick meeting today","name":"claude.ai Google Calendar:quick_meeting_today (MCP)"},{"argumentHint":"","description":"Schedule recurring 1:1 meetings","name":"claude.ai Google Calendar:recurring_1on1 (MCP)"},{"argumentHint":"","description":"Find time for a team meeting with multiple people","name":"claude.ai Google Calendar:team_meeting_scheduler (MCP)"},{"argumentHint":"","description":"Analyze someone's calendar availability patterns","name":"claude.ai Google Calendar:analyze_availability (MCP)"}],"models":[{"description":"Opus 4.6 with 1M context · Most capable for complex work","displayName":"Default (recommended)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"supportsFastMode":true,"value":"default"},{"description":"Sonnet 4.6 · Best for everyday tasks","displayName":"Sonnet","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet"},{"description":"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok","displayName":"Sonnet (1M context)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet[1m]"},{"description":"Haiku 4.5 · Fastest for quick answers","displayName":"Haiku","value":"haiku"}],"output_style":"default","pid":2888465},"subtype":"success"},"type":"control_response"}} +{"ts":"2026-04-09T00:46:18.572852094Z","dir":"send","seq":2,"frame":{"message":{"content":"what is 17 * 23? think through it step by step, then give the answer","role":"user"},"type":"user"}} +{"ts":"2026-04-09T00:46:18.584719938Z","dir":"recv","seq":3,"frame":{"agents":["general-purpose","statusline-setup","Explore","Plan","claude-code-guide"],"apiKeySource":"REDACTED","claude_code_version":"2.1.97","cwd":"/test/workspace","fast_mode_state":"off","mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Clerk","status":"connected"},{"name":"claude.ai Excalidraw","status":"connected"},{"name":"claude.ai Linear","status":"connected"},{"name":"claude.ai Ramp","status":"connected"},{"name":"claude.ai Notion","status":"connected"},{"name":"claude.ai Google Calendar","status":"connected"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-opus-4-6[1m]","output_style":"default","permissionMode":"bypassPermissions","plugins":[],"session_id":"masked-session-id","skills":["update-config","debug","simplify","batch","loop","schedule","claude-api"],"slash_commands":["update-config","debug","simplify","batch","loop","schedule","claude-api","compact","context","cost","heapdump","init","review","security-review","extra-usage","insights","mcp__claude_ai_Google_Calendar__schedule_meeting","mcp__claude_ai_Google_Calendar__quick_meeting_today","mcp__claude_ai_Google_Calendar__recurring_1on1","mcp__claude_ai_Google_Calendar__team_meeting_scheduler","mcp__claude_ai_Google_Calendar__analyze_availability"],"subtype":"init","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","NotebookEdit","Read","ReadMcpResourceTool","RemoteTrigger","SendMessage","Skill","TaskOutput","TaskStop","TeamCreate","TeamDelete","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Clerk__clerk_sdk_snippet","mcp__claude_ai_Clerk__list_clerk_sdk_snippets","mcp__claude_ai_Excalidraw__create_view","mcp__claude_ai_Excalidraw__export_to_excalidraw","mcp__claude_ai_Excalidraw__read_checkpoint","mcp__claude_ai_Excalidraw__read_me","mcp__claude_ai_Excalidraw__save_checkpoint","mcp__claude_ai_Gmail__gmail_create_draft","mcp__claude_ai_Gmail__gmail_get_profile","mcp__claude_ai_Gmail__gmail_list_drafts","mcp__claude_ai_Gmail__gmail_list_labels","mcp__claude_ai_Gmail__gmail_read_message","mcp__claude_ai_Gmail__gmail_read_thread","mcp__claude_ai_Gmail__gmail_search_messages","mcp__claude_ai_Google_Calendar__gcal_create_event","mcp__claude_ai_Google_Calendar__gcal_delete_event","mcp__claude_ai_Google_Calendar__gcal_find_meeting_times","mcp__claude_ai_Google_Calendar__gcal_find_my_free_time","mcp__claude_ai_Google_Calendar__gcal_get_event","mcp__claude_ai_Google_Calendar__gcal_list_calendars","mcp__claude_ai_Google_Calendar__gcal_list_events","mcp__claude_ai_Google_Calendar__gcal_respond_to_event","mcp__claude_ai_Google_Calendar__gcal_update_event","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Linear__create_attachment","mcp__claude_ai_Linear__create_document","mcp__claude_ai_Linear__create_issue_label","mcp__claude_ai_Linear__delete_attachment","mcp__claude_ai_Linear__delete_comment","mcp__claude_ai_Linear__delete_status_update","mcp__claude_ai_Linear__extract_images","mcp__claude_ai_Linear__get_attachment","mcp__claude_ai_Linear__get_document","mcp__claude_ai_Linear__get_initiative","mcp__claude_ai_Linear__get_issue","mcp__claude_ai_Linear__get_issue_status","mcp__claude_ai_Linear__get_milestone","mcp__claude_ai_Linear__get_project","mcp__claude_ai_Linear__get_status_updates","mcp__claude_ai_Linear__get_team","mcp__claude_ai_Linear__get_user","mcp__claude_ai_Linear__list_comments","mcp__claude_ai_Linear__list_cycles","mcp__claude_ai_Linear__list_documents","mcp__claude_ai_Linear__list_initiatives","mcp__claude_ai_Linear__list_issue_labels","mcp__claude_ai_Linear__list_issue_statuses","mcp__claude_ai_Linear__list_issues","mcp__claude_ai_Linear__list_milestones","mcp__claude_ai_Linear__list_project_labels","mcp__claude_ai_Linear__list_projects","mcp__claude_ai_Linear__list_teams","mcp__claude_ai_Linear__list_users","mcp__claude_ai_Linear__research","mcp__claude_ai_Linear__save_comment","mcp__claude_ai_Linear__save_initiative","mcp__claude_ai_Linear__save_issue","mcp__claude_ai_Linear__save_milestone","mcp__claude_ai_Linear__save_project","mcp__claude_ai_Linear__save_status_update","mcp__claude_ai_Linear__search_documentation","mcp__claude_ai_Linear__update_document","mcp__claude_ai_Notion__notion-create-comment","mcp__claude_ai_Notion__notion-create-database","mcp__claude_ai_Notion__notion-create-pages","mcp__claude_ai_Notion__notion-create-view","mcp__claude_ai_Notion__notion-duplicate-page","mcp__claude_ai_Notion__notion-fetch","mcp__claude_ai_Notion__notion-get-comments","mcp__claude_ai_Notion__notion-get-teams","mcp__claude_ai_Notion__notion-get-users","mcp__claude_ai_Notion__notion-move-pages","mcp__claude_ai_Notion__notion-query-database-view","mcp__claude_ai_Notion__notion-query-meeting-notes","mcp__claude_ai_Notion__notion-search","mcp__claude_ai_Notion__notion-update-data-source","mcp__claude_ai_Notion__notion-update-page","mcp__claude_ai_Notion__notion-update-view","mcp__claude_ai_Ramp__get_current_user","mcp__claude_ai_Ramp__ramp_ask_help_center","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"type":"system","uuid":"masked-uuid-1"}} +{"ts":"2026-04-09T00:46:20.277391617Z","dir":"recv","seq":4,"frame":{"event":{"message":{"content":[],"id":"msg_016qGqF9fota6YchYM6HSGqa","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6356,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6356,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":6,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-2"}} +{"ts":"2026-04-09T00:46:20.277706047Z","dir":"recv","seq":5,"frame":{"event":{"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-3"}} +{"ts":"2026-04-09T00:46:20.277809147Z","dir":"recv","seq":6,"frame":{"event":{"delta":{"text":"\n\n17 × 23","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-4"}} +{"ts":"2026-04-09T00:46:20.686882938Z","dir":"recv","seq":7,"frame":{"event":{"delta":{"text":"\n\n= 17 × 20 + 17 × 3\n= 340 + 51\n= **391**","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-5"}} +{"ts":"2026-04-09T00:46:20.68917848Z","dir":"recv","seq":8,"frame":{"message":{"content":[{"text":"\n\n17 × 23\n\n= 17 × 20 + 17 × 3\n= 340 + 51\n= **391**","type":"text"}],"context_management":null,"id":"msg_016qGqF9fota6YchYM6HSGqa","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6356,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6356,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":6,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-6"}} +{"ts":"2026-04-09T00:46:20.68923459Z","dir":"recv","seq":9,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-7"}} +{"ts":"2026-04-09T00:46:20.746917748Z","dir":"recv","seq":10,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6356,"cache_read_input_tokens":11448,"input_tokens":3,"output_tokens":38}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-8"}} +{"ts":"2026-04-09T00:46:20.747222979Z","dir":"recv","seq":11,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-9"}} +{"ts":"2026-04-09T00:46:20.747813739Z","dir":"recv","seq":12,"frame":{"rate_limit_info":{"isUsingOverage":false,"rateLimitType":"seven_day","resetsAt":1775793600,"status":"allowed_warning","surpassedThreshold":0.75,"utilization":0.85},"session_id":"masked-session-id","type":"rate_limit_event","uuid":"masked-uuid-10"}} +{"ts":"2026-04-09T00:46:20.749374901Z","dir":"recv","seq":13,"frame":{"duration_api_ms":2160,"duration_ms":2172,"fast_mode_state":"off","is_error":false,"modelUsage":{"claude-opus-4-6[1m]":{"cacheCreationInputTokens":6356,"cacheReadInputTokens":11448,"contextWindow":1000000,"costUSD":0.046414,"inputTokens":3,"maxOutputTokens":64000,"outputTokens":38,"webSearchRequests":0}},"num_turns":1,"permission_denials":[],"result":"\n\n17 × 23\n\n= 17 × 20 + 17 × 3\n= 340 + 51\n= **391**","session_id":"masked-session-id","stop_reason":"end_turn","subtype":"success","terminal_reason":"completed","total_cost_usd":0.046414,"type":"result","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6356,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6356,"cache_read_input_tokens":11448,"inference_geo":"","input_tokens":3,"iterations":[],"output_tokens":38,"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0},"service_tier":"standard","speed":"standard"},"uuid":"masked-uuid-11"}} diff --git a/internal/adapter/claude/testdata/tool_use_bash.ndjson b/internal/adapter/claude/testdata/tool_use_bash.ndjson new file mode 100644 index 00000000..c88cc801 --- /dev/null +++ b/internal/adapter/claude/testdata/tool_use_bash.ndjson @@ -0,0 +1,31 @@ +{"ts":"2026-04-09T00:46:04.052554524Z","dir":"send","seq":0,"frame":{"request":{"subtype":"initialize"},"request_id":"relay-init-0","type":"control_request"}} +{"ts":"2026-04-09T00:46:06.1513506Z","dir":"recv","seq":1,"frame":{"response":{"request_id":"relay-init-0","response":{"account":{"apiProvider":"firstParty","email":"masked@example.com","organization":"masked-org","subscriptionType":"Claude Max"},"agents":[{"description":"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.","name":"general-purpose"},{"description":"Use this agent to configure the user's Claude Code status line setting.","model":"sonnet","name":"statusline-setup"},{"description":"Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.","model":"haiku","name":"Explore"},{"description":"Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.","name":"Plan"},{"description":"Use this agent when the user asks questions (\"Can Claude...\", \"Does Claude...\", \"How do I...\") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage.","model":"haiku","name":"claude-code-guide"}],"available_output_styles":["default","Explanatory","Learning"],"commands":[{"argumentHint":"","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, use Config tool. (bundled)","name":"update-config"},{"argumentHint":"[issue description]","description":"Enable debug logging for this session and help diagnose issues (bundled)","name":"debug"},{"argumentHint":"","description":"Review changed code for reuse, quality, and efficiency, then fix any issues found. (bundled)","name":"simplify"},{"argumentHint":"\u003cinstruction\u003e","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR. (bundled)","name":"batch"},{"argumentHint":"[interval] \u003cprompt\u003e","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) (bundled)","name":"loop"},{"argumentHint":"","description":"Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule. (bundled)","name":"schedule"},{"argumentHint":"","description":"Build Claude API / Anthropic SDK apps.\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks to use the Claude API, Anthropic SDKs, or Managed Agents (`/v1/agents`, `/v1/sessions`); or asks to add a Claude feature (prompt caching, adaptive thinking, compaction, code_execution, batch, files API, citations, memory tool) or a Claude model (Opus/Sonnet/Haiku) to a Claude file.\nDO NOT TRIGGER when: file imports `openai`/non-Anthropic SDK, filename signals another provider (`agent-openai.py`, `*-generic.py`), code is provider-neutral, or task is general programming/ML. (bundled)","name":"claude-api"},{"argumentHint":"\u003coptional custom summarization instructions\u003e","description":"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]","name":"compact"},{"argumentHint":"","description":"Show current context usage","name":"context"},{"argumentHint":"","description":"Show the total cost and duration of the current session","name":"cost"},{"argumentHint":"","description":"Dump the JS heap to ~/Desktop","name":"heapdump"},{"argumentHint":"","description":"Initialize a new CLAUDE.md file with codebase documentation","name":"init"},{"argumentHint":"","description":"Review a pull request","name":"review"},{"argumentHint":"","description":"Complete a security review of the pending changes on the current branch","name":"security-review"},{"argumentHint":"","description":"Configure extra usage to keep working when limits are hit","name":"extra-usage"},{"argumentHint":"","description":"Generate a report analyzing your Claude Code sessions","name":"insights"},{"argumentHint":"","description":"Find a time for a meeting with colleagues","name":"claude.ai Google Calendar:schedule_meeting (MCP)"},{"argumentHint":"","description":"Find time for a quick meeting today","name":"claude.ai Google Calendar:quick_meeting_today (MCP)"},{"argumentHint":"","description":"Schedule recurring 1:1 meetings","name":"claude.ai Google Calendar:recurring_1on1 (MCP)"},{"argumentHint":"","description":"Find time for a team meeting with multiple people","name":"claude.ai Google Calendar:team_meeting_scheduler (MCP)"},{"argumentHint":"","description":"Analyze someone's calendar availability patterns","name":"claude.ai Google Calendar:analyze_availability (MCP)"}],"models":[{"description":"Opus 4.6 with 1M context · Most capable for complex work","displayName":"Default (recommended)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"supportsFastMode":true,"value":"default"},{"description":"Sonnet 4.6 · Best for everyday tasks","displayName":"Sonnet","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet"},{"description":"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok","displayName":"Sonnet (1M context)","supportedEffortLevels":["low","medium","high","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true,"supportsEffort":true,"value":"sonnet[1m]"},{"description":"Haiku 4.5 · Fastest for quick answers","displayName":"Haiku","value":"haiku"}],"output_style":"default","pid":2883980},"subtype":"success"},"type":"control_response"}} +{"ts":"2026-04-09T00:46:09.054813871Z","dir":"send","seq":2,"frame":{"message":{"content":"run the command 'echo test123' and tell me the output. Use the Bash tool.","role":"user"},"type":"user"}} +{"ts":"2026-04-09T00:46:09.066393275Z","dir":"recv","seq":3,"frame":{"agents":["general-purpose","statusline-setup","Explore","Plan","claude-code-guide"],"apiKeySource":"REDACTED","claude_code_version":"2.1.97","cwd":"/test/workspace","fast_mode_state":"off","mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Clerk","status":"connected"},{"name":"claude.ai Excalidraw","status":"connected"},{"name":"claude.ai Linear","status":"connected"},{"name":"claude.ai Ramp","status":"connected"},{"name":"claude.ai Notion","status":"connected"},{"name":"claude.ai Google Calendar","status":"connected"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-opus-4-6[1m]","output_style":"default","permissionMode":"bypassPermissions","plugins":[],"session_id":"masked-session-id","skills":["update-config","debug","simplify","batch","loop","schedule","claude-api"],"slash_commands":["update-config","debug","simplify","batch","loop","schedule","claude-api","compact","context","cost","heapdump","init","review","security-review","extra-usage","insights","mcp__claude_ai_Google_Calendar__schedule_meeting","mcp__claude_ai_Google_Calendar__quick_meeting_today","mcp__claude_ai_Google_Calendar__recurring_1on1","mcp__claude_ai_Google_Calendar__team_meeting_scheduler","mcp__claude_ai_Google_Calendar__analyze_availability"],"subtype":"init","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","NotebookEdit","Read","ReadMcpResourceTool","RemoteTrigger","SendMessage","Skill","TaskOutput","TaskStop","TeamCreate","TeamDelete","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Clerk__clerk_sdk_snippet","mcp__claude_ai_Clerk__list_clerk_sdk_snippets","mcp__claude_ai_Excalidraw__create_view","mcp__claude_ai_Excalidraw__export_to_excalidraw","mcp__claude_ai_Excalidraw__read_checkpoint","mcp__claude_ai_Excalidraw__read_me","mcp__claude_ai_Excalidraw__save_checkpoint","mcp__claude_ai_Gmail__gmail_create_draft","mcp__claude_ai_Gmail__gmail_get_profile","mcp__claude_ai_Gmail__gmail_list_drafts","mcp__claude_ai_Gmail__gmail_list_labels","mcp__claude_ai_Gmail__gmail_read_message","mcp__claude_ai_Gmail__gmail_read_thread","mcp__claude_ai_Gmail__gmail_search_messages","mcp__claude_ai_Google_Calendar__gcal_create_event","mcp__claude_ai_Google_Calendar__gcal_delete_event","mcp__claude_ai_Google_Calendar__gcal_find_meeting_times","mcp__claude_ai_Google_Calendar__gcal_find_my_free_time","mcp__claude_ai_Google_Calendar__gcal_get_event","mcp__claude_ai_Google_Calendar__gcal_list_calendars","mcp__claude_ai_Google_Calendar__gcal_list_events","mcp__claude_ai_Google_Calendar__gcal_respond_to_event","mcp__claude_ai_Google_Calendar__gcal_update_event","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Linear__create_attachment","mcp__claude_ai_Linear__create_document","mcp__claude_ai_Linear__create_issue_label","mcp__claude_ai_Linear__delete_attachment","mcp__claude_ai_Linear__delete_comment","mcp__claude_ai_Linear__delete_status_update","mcp__claude_ai_Linear__extract_images","mcp__claude_ai_Linear__get_attachment","mcp__claude_ai_Linear__get_document","mcp__claude_ai_Linear__get_initiative","mcp__claude_ai_Linear__get_issue","mcp__claude_ai_Linear__get_issue_status","mcp__claude_ai_Linear__get_milestone","mcp__claude_ai_Linear__get_project","mcp__claude_ai_Linear__get_status_updates","mcp__claude_ai_Linear__get_team","mcp__claude_ai_Linear__get_user","mcp__claude_ai_Linear__list_comments","mcp__claude_ai_Linear__list_cycles","mcp__claude_ai_Linear__list_documents","mcp__claude_ai_Linear__list_initiatives","mcp__claude_ai_Linear__list_issue_labels","mcp__claude_ai_Linear__list_issue_statuses","mcp__claude_ai_Linear__list_issues","mcp__claude_ai_Linear__list_milestones","mcp__claude_ai_Linear__list_project_labels","mcp__claude_ai_Linear__list_projects","mcp__claude_ai_Linear__list_teams","mcp__claude_ai_Linear__list_users","mcp__claude_ai_Linear__research","mcp__claude_ai_Linear__save_comment","mcp__claude_ai_Linear__save_initiative","mcp__claude_ai_Linear__save_issue","mcp__claude_ai_Linear__save_milestone","mcp__claude_ai_Linear__save_project","mcp__claude_ai_Linear__save_status_update","mcp__claude_ai_Linear__search_documentation","mcp__claude_ai_Linear__update_document","mcp__claude_ai_Notion__notion-create-comment","mcp__claude_ai_Notion__notion-create-database","mcp__claude_ai_Notion__notion-create-pages","mcp__claude_ai_Notion__notion-create-view","mcp__claude_ai_Notion__notion-duplicate-page","mcp__claude_ai_Notion__notion-fetch","mcp__claude_ai_Notion__notion-get-comments","mcp__claude_ai_Notion__notion-get-teams","mcp__claude_ai_Notion__notion-get-users","mcp__claude_ai_Notion__notion-move-pages","mcp__claude_ai_Notion__notion-query-database-view","mcp__claude_ai_Notion__notion-query-meeting-notes","mcp__claude_ai_Notion__notion-search","mcp__claude_ai_Notion__notion-update-data-source","mcp__claude_ai_Notion__notion-update-page","mcp__claude_ai_Notion__notion-update-view","mcp__claude_ai_Ramp__get_current_user","mcp__claude_ai_Ramp__ramp_ask_help_center","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"type":"system","uuid":"masked-uuid-1"}} +{"ts":"2026-04-09T00:46:11.49681903Z","dir":"recv","seq":4,"frame":{"event":{"message":{"content":[],"id":"msg_01CoUkpeRtCRWFkohDnqfKx3","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6357,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6357,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":50,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-2"}} +{"ts":"2026-04-09T00:46:11.49692268Z","dir":"recv","seq":5,"frame":{"event":{"content_block":{"caller":{"type":"direct"},"id":"toolu_01JZWiYoAovQ3ubuZFazuZVF","input":{},"name":"Bash","type":"tool_use"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-3"}} +{"ts":"2026-04-09T00:46:11.498048122Z","dir":"recv","seq":6,"frame":{"event":{"delta":{"partial_json":"","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-4"}} +{"ts":"2026-04-09T00:46:11.509986906Z","dir":"recv","seq":7,"frame":{"event":{"delta":{"partial_json":"{\"comman","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-5"}} +{"ts":"2026-04-09T00:46:11.510054976Z","dir":"recv","seq":8,"frame":{"event":{"delta":{"partial_json":"d\": \"ech","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-6"}} +{"ts":"2026-04-09T00:46:11.510255846Z","dir":"recv","seq":9,"frame":{"event":{"delta":{"partial_json":"o test123\"","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-7"}} +{"ts":"2026-04-09T00:46:11.78626735Z","dir":"recv","seq":10,"frame":{"event":{"delta":{"partial_json":", \"descripti","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-8"}} +{"ts":"2026-04-09T00:46:11.787945312Z","dir":"recv","seq":11,"frame":{"event":{"delta":{"partial_json":"on\": ","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-9"}} +{"ts":"2026-04-09T00:46:11.792799078Z","dir":"recv","seq":12,"frame":{"event":{"delta":{"partial_json":"\"Ech","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-10"}} +{"ts":"2026-04-09T00:46:11.798115004Z","dir":"recv","seq":13,"frame":{"event":{"delta":{"partial_json":"o tes","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-11"}} +{"ts":"2026-04-09T00:46:11.801362818Z","dir":"recv","seq":14,"frame":{"event":{"delta":{"partial_json":"t string","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-12"}} +{"ts":"2026-04-09T00:46:11.804321501Z","dir":"recv","seq":15,"frame":{"event":{"delta":{"partial_json":"\"}","type":"input_json_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-13"}} +{"ts":"2026-04-09T00:46:11.807357635Z","dir":"recv","seq":16,"frame":{"message":{"content":[{"caller":{"type":"direct"},"id":"toolu_01JZWiYoAovQ3ubuZFazuZVF","input":{"command":"echo test123","description":"Echo test string"},"name":"Bash","type":"tool_use"}],"context_management":null,"id":"msg_01CoUkpeRtCRWFkohDnqfKx3","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6357,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6357,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":3,"output_tokens":50,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-14"}} +{"ts":"2026-04-09T00:46:11.812857972Z","dir":"recv","seq":17,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-15"}} +{"ts":"2026-04-09T00:46:11.850399196Z","dir":"recv","seq":18,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"tool_use","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6357,"cache_read_input_tokens":11448,"input_tokens":3,"output_tokens":74}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-16"}} +{"ts":"2026-04-09T00:46:11.853406229Z","dir":"recv","seq":19,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-17"}} +{"ts":"2026-04-09T00:46:11.857005913Z","dir":"recv","seq":20,"frame":{"rate_limit_info":{"isUsingOverage":false,"rateLimitType":"seven_day","resetsAt":1775793600,"status":"allowed_warning","surpassedThreshold":0.75,"utilization":0.85},"session_id":"masked-session-id","type":"rate_limit_event","uuid":"masked-uuid-18"}} +{"ts":"2026-04-09T00:46:11.858152425Z","dir":"recv","seq":21,"frame":{"message":{"content":[{"content":"test123","is_error":false,"tool_use_id":"toolu_01JZWiYoAovQ3ubuZFazuZVF","type":"tool_result"}],"role":"user"},"parent_tool_use_id":null,"session_id":"masked-session-id","timestamp":"2026-04-09T00:46:11.855Z","tool_use_result":{"interrupted":false,"isImage":false,"noOutputExpected":false,"stderr":"","stdout":"test123"},"type":"user","uuid":"masked-uuid-19"}} +{"ts":"2026-04-09T00:46:13.168817115Z","dir":"recv","seq":22,"frame":{"event":{"message":{"content":[],"id":"msg_01Rggi8SPkpp6wT2EPkoAPwD","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6447,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6447,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":1,"output_tokens":1,"service_tier":"standard"}},"type":"message_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-20"}} +{"ts":"2026-04-09T00:46:13.169122755Z","dir":"recv","seq":23,"frame":{"event":{"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-21"}} +{"ts":"2026-04-09T00:46:13.169531825Z","dir":"recv","seq":24,"frame":{"event":{"delta":{"text":"The","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-22"}} +{"ts":"2026-04-09T00:46:13.353602522Z","dir":"recv","seq":25,"frame":{"event":{"delta":{"text":" output is `test123`.","type":"text_delta"},"index":0,"type":"content_block_delta"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-23"}} +{"ts":"2026-04-09T00:46:13.353978262Z","dir":"recv","seq":26,"frame":{"message":{"content":[{"text":"The output is `test123`.","type":"text"}],"context_management":null,"id":"msg_01Rggi8SPkpp6wT2EPkoAPwD","model":"claude-opus-4-6","role":"assistant","stop_details":null,"stop_reason":null,"stop_sequence":null,"type":"message","usage":{"cache_creation":{"ephemeral_1h_input_tokens":6447,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":6447,"cache_read_input_tokens":11448,"inference_geo":"not_available","input_tokens":1,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"assistant","uuid":"masked-uuid-24"}} +{"ts":"2026-04-09T00:46:13.354028112Z","dir":"recv","seq":27,"frame":{"event":{"index":0,"type":"content_block_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-25"}} +{"ts":"2026-04-09T00:46:13.407700295Z","dir":"recv","seq":28,"frame":{"event":{"context_management":{"applied_edits":[]},"delta":{"stop_details":null,"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"cache_creation_input_tokens":6447,"cache_read_input_tokens":11448,"input_tokens":1,"output_tokens":10}},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-26"}} +{"ts":"2026-04-09T00:46:13.407784895Z","dir":"recv","seq":29,"frame":{"event":{"type":"message_stop"},"parent_tool_use_id":null,"session_id":"masked-session-id","type":"stream_event","uuid":"masked-uuid-27"}} +{"ts":"2026-04-09T00:46:13.409346377Z","dir":"recv","seq":30,"frame":{"duration_api_ms":4336,"duration_ms":4350,"fast_mode_state":"off","is_error":false,"modelUsage":{"claude-opus-4-6[1m]":{"cacheCreationInputTokens":12804,"cacheReadInputTokens":22896,"contextWindow":1000000,"costUSD":0.09359299999999998,"inputTokens":4,"maxOutputTokens":64000,"outputTokens":84,"webSearchRequests":0}},"num_turns":2,"permission_denials":[],"result":"The output is `test123`.","session_id":"masked-session-id","stop_reason":"end_turn","subtype":"success","terminal_reason":"completed","total_cost_usd":0.09359299999999998,"type":"result","usage":{"cache_creation":{"ephemeral_1h_input_tokens":12804,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":12804,"cache_read_input_tokens":22896,"inference_geo":"","input_tokens":4,"iterations":[],"output_tokens":84,"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0},"service_tier":"standard","speed":"standard"},"uuid":"masked-uuid-28"}} diff --git a/internal/adapter/claude/translator.go b/internal/adapter/claude/translator.go index 3a1c785f..afe3703e 100644 --- a/internal/adapter/claude/translator.go +++ b/internal/adapter/claude/translator.go @@ -15,6 +15,14 @@ var _ translator.Translator = (*Translator)(nil) // Result is an alias for the canonical translator.Result type. type Result = translator.Result +// blockState tracks the state of a content block being streamed. +type blockState struct { + blockType string // "text", "thinking", "tool_use" + itemID string + toolName string + toolUseID string +} + // permissionRequest tracks a pending can_use_tool control request. type permissionRequest struct { RequestID string @@ -43,12 +51,11 @@ type Translator struct { turnActive bool turnNumber int - // Streaming state for content blocks - activeBlockIndex int - activeBlockType string // "text", "thinking", "tool_use" - activeItemID string - activeToolName string - activeToolUseID string + // Streaming state for content blocks, indexed by block index + blocks map[int]*blockState + activeItemID string + activeToolName string + activeToolUseID string // Permission tracking pendingPermissions map[string]permissionRequest @@ -58,6 +65,7 @@ type Translator struct { func NewTranslator(instanceID string) *Translator { return &Translator{ instanceID: instanceID, + blocks: map[int]*blockState{}, pendingPermissions: map[string]permissionRequest{}, } } diff --git a/internal/adapter/claude/translator_integration_test.go b/internal/adapter/claude/translator_integration_test.go index 28e38f15..cf4e82c1 100644 --- a/internal/adapter/claude/translator_integration_test.go +++ b/internal/adapter/claude/translator_integration_test.go @@ -18,25 +18,29 @@ import ( "github.com/kxn/codex-remote-feishu/internal/core/agentproto" ) -// TestClaudeCLIIntegration spawns a real Claude CLI process in SDK mode, -// sends a simple prompt, and verifies that the translator produces the -// expected agentproto event sequence. It also records the NDJSON transcript -// with privacy masking for use as a replay fixture. -// -// Run with: go test -tags integration -run TestClaudeCLIIntegration -v -timeout 120s -func TestClaudeCLIIntegration(t *testing.T) { +// claudeSession wraps a live Claude CLI subprocess for integration testing. +type claudeSession struct { + t *testing.T + cmd *exec.Cmd + stdin interface{ Write([]byte) (int, error); Close() error } + translator *claude.Translator + recorder *clauderecord.Recorder + eventsCh chan agentproto.Event + outboundCh chan []byte + cwd string + homePath string +} + +func startClaude(t *testing.T, ctx context.Context) *claudeSession { + t.Helper() cliBin, err := exec.LookPath("claude") if err != nil { t.Skip("claude CLI not found on PATH, skipping integration test") } - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - cwd, _ := os.Getwd() homePath, _ := os.UserHomeDir() - // Build command args := []string{ "--output-format", "stream-json", "--verbose", @@ -57,7 +61,7 @@ func TestClaudeCLIIntegration(t *testing.T) { if err != nil { t.Fatalf("stdout pipe: %v", err) } - cmd.Stderr = os.Stderr // let Claude's stderr show in test output + cmd.Stderr = os.Stderr recorder := clauderecord.NewRecorder() translator := claude.NewTranslator("integration-test") @@ -68,35 +72,27 @@ func TestClaudeCLIIntegration(t *testing.T) { if err := cmd.Start(); err != nil { t.Fatalf("start claude: %v", err) } - defer func() { - stdin.Close() - cmd.Wait() - }() - // Channels for collecting events from the reader goroutine - eventsCh := make(chan agentproto.Event, 256) + eventsCh := make(chan agentproto.Event, 512) outboundCh := make(chan []byte, 64) - readerDone := make(chan error, 1) - // Read stdout in background using ReadBytes('\n') for reliable line detection + // Read stdout in background go func() { - reader := bufio.NewReaderSize(stdout, 4<<20) // 4MB buffer + reader := bufio.NewReaderSize(stdout, 4<<20) for { line, err := reader.ReadBytes('\n') if len(line) > 0 { recorder.RecordRecv(line) - var peek struct{ Type string `json:"type"` } json.Unmarshal(line, &peek) t.Logf("recv: type=%s len=%d", peek.Type, len(line)) result, obsErr := translator.ObserveServer(line) if obsErr != nil { - t.Logf("observe error: %v (line: %s)", obsErr, truncate(line, 200)) + t.Logf("observe error: %v", obsErr) continue } for _, event := range result.Events { - t.Logf("event: kind=%s", event.Kind) eventsCh <- event } for _, out := range result.OutboundToAgent { @@ -104,13 +100,12 @@ func TestClaudeCLIIntegration(t *testing.T) { } } if err != nil { - readerDone <- err return } } }() - // Write outbound frames (MCP handshake responses) back to stdin + // Write outbound frames back to stdin go func() { for frame := range outboundCh { recorder.RecordSend(frame) @@ -118,88 +113,254 @@ func TestClaudeCLIIntegration(t *testing.T) { } }() - // Step 1: Send bootstrap (initialize handshake) + // Send bootstrap bootstrapFrames, err := translator.BootstrapFrames("integration-test", "1.0") if err != nil { t.Fatalf("bootstrap: %v", err) } for _, frame := range bootstrapFrames { recorder.RecordSend(frame) - if _, err := stdin.Write(frame); err != nil { - t.Fatalf("write bootstrap: %v", err) - } + stdin.Write(frame) } - // Step 2: Wait briefly for init, then send user message. - // Claude CLI may buffer the system init message until the first user message is sent. - var allEvents []agentproto.Event - drainWithTimeout(eventsCh, &allEvents, 5*time.Second) - t.Logf("after init drain: %d events", len(allEvents)) + // Wait briefly for init handshake + drainWithTimeout(eventsCh, &[]agentproto.Event{}, 5*time.Second) + + return &claudeSession{ + t: t, cmd: cmd, stdin: stdin, + translator: translator, recorder: recorder, + eventsCh: eventsCh, outboundCh: outboundCh, + cwd: cwd, homePath: homePath, + } +} - // Step 3: Send user message - userMsg := map[string]any{ +func (s *claudeSession) sendUserMessage(text string) { + msg := map[string]any{ "type": "user", "message": map[string]any{ "role": "user", - "content": "respond with exactly the word hello and nothing else", + "content": text, }, } - msgBytes, _ := json.Marshal(userMsg) - msgBytes = append(msgBytes, '\n') - recorder.RecordSend(msgBytes) - if _, err := stdin.Write(msgBytes); err != nil { - t.Fatalf("write user message: %v", err) + b, _ := json.Marshal(msg) + b = append(b, '\n') + s.recorder.RecordSend(b) + s.stdin.Write(b) +} + +func (s *claudeSession) collectUntilTurnComplete(ctx context.Context) []agentproto.Event { + var events []agentproto.Event + waitForEventKind(s.t, ctx, s.eventsCh, &events, agentproto.EventTurnCompleted, "turn completion") + return events +} + +func (s *claudeSession) saveMaskedFixture(name string) { + path := filepath.Join("testdata", name) + if err := s.recorder.SaveMasked(path, clauderecord.MaskOptions{ + WorkspaceCWD: s.cwd, + HomePath: s.homePath, + }); err != nil { + s.t.Fatalf("save fixture: %v", err) } + s.t.Logf("saved fixture to %s (%d entries)", path, len(s.recorder.Entries())) +} + +func (s *claudeSession) close() { + close(s.outboundCh) + s.stdin.Close() + s.cmd.Wait() +} + +// --- Test: Simple hello --- + +func TestClaudeCLIIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + s := startClaude(t, ctx) + defer s.close() - // Step 4: Collect events until turn completion (includes system init + streaming) - waitForEventKind(t, ctx, eventsCh, &allEvents, agentproto.EventTurnCompleted, "turn completion") + s.sendUserMessage("respond with exactly the word hello and nothing else") + events := s.collectUntilTurnComplete(ctx) - // Step 5: Verify event sequence - t.Logf("collected %d events", len(allEvents)) - for i, e := range allEvents { - t.Logf(" event[%d]: kind=%s threadId=%s turnId=%s itemKind=%s delta=%s", - i, e.Kind, e.ThreadID, e.TurnID, e.ItemKind, truncate([]byte(e.Delta), 80)) + logEvents(t, events) + + assertHasEventKind(t, events, agentproto.EventThreadDiscovered) + assertHasEventKind(t, events, agentproto.EventTurnStarted) + assertHasEventKind(t, events, agentproto.EventItemStarted) + assertHasEventKind(t, events, agentproto.EventItemCompleted) + + tc := findEvent(events, agentproto.EventTurnCompleted) + if tc == nil || tc.Status != "completed" { + t.Fatalf("turn not completed successfully: %+v", tc) } - assertHasEventKind(t, allEvents, agentproto.EventThreadDiscovered) - assertHasEventKind(t, allEvents, agentproto.EventTurnStarted) - assertHasEventKind(t, allEvents, agentproto.EventItemStarted) - assertHasEventKind(t, allEvents, agentproto.EventItemCompleted) + assertDeltaContains(t, events, "hello") + + s.saveMaskedFixture("hello_simple.ndjson") +} + +// --- Test: Tool use (Bash) --- + +func TestClaudeCLIToolUse(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + s := startClaude(t, ctx) + defer s.close() - turnCompleted := findEvent(allEvents, agentproto.EventTurnCompleted) - if turnCompleted == nil { - t.Fatal("missing EventTurnCompleted") + // Ask Claude to run a simple command -- it will use the Bash tool + s.sendUserMessage("run the command 'echo test123' and tell me the output. Use the Bash tool.") + events := s.collectUntilTurnComplete(ctx) + + logEvents(t, events) + + assertHasEventKind(t, events, agentproto.EventTurnStarted) + + // Should have tool_use content blocks (command_execution items) + hasToolStart := false + hasToolDelta := false + for _, e := range events { + if e.Kind == agentproto.EventItemStarted && e.ItemKind == "command_execution" { + hasToolStart = true + if meta, ok := e.Metadata["toolName"].(string); ok { + t.Logf("tool started: %s", meta) + } + } + if e.Kind == agentproto.EventItemDelta && e.ItemKind == "command_execution" { + hasToolDelta = true + } } - if turnCompleted.Status != "completed" { - t.Errorf("turn status: got %q, want %q", turnCompleted.Status, "completed") + if hasToolStart { + t.Logf("tool use detected: start=%v deltas=%v", hasToolStart, hasToolDelta) } - // Check that deltas contain "hello" - var fullText string - for _, e := range allEvents { - if e.Kind == agentproto.EventItemDelta { - fullText += e.Delta + tc := findEvent(events, agentproto.EventTurnCompleted) + if tc == nil || tc.Status != "completed" { + t.Fatalf("turn not completed: %+v", tc) + } + + // Check that response text mentions the command output + assertDeltaContains(t, events, "test123") + + s.saveMaskedFixture("tool_use_bash.ndjson") +} + +// --- Test: Thinking blocks --- + +func TestClaudeCLIThinking(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + s := startClaude(t, ctx) + defer s.close() + + // Ask something that should trigger thinking + s.sendUserMessage("what is 17 * 23? think through it step by step, then give the answer") + events := s.collectUntilTurnComplete(ctx) + + logEvents(t, events) + + // Check for thinking content blocks + hasThinking := false + for _, e := range events { + if e.Kind == agentproto.EventItemStarted && e.ItemKind == "reasoning_content" { + hasThinking = true + } + if e.Kind == agentproto.EventItemDelta && e.ItemKind == "reasoning_content" { + t.Logf("thinking delta: %s", truncate([]byte(e.Delta), 80)) } } - if !strings.Contains(strings.ToLower(fullText), "hello") { - t.Errorf("expected delta text to contain 'hello', got %q", fullText) + if hasThinking { + t.Log("thinking blocks detected") } - // Step 6: Save masked recording - close(outboundCh) - stdin.Close() + tc := findEvent(events, agentproto.EventTurnCompleted) + if tc == nil || tc.Status != "completed" { + t.Fatalf("turn not completed: %+v", tc) + } - fixturePath := filepath.Join("testdata", "hello_simple.ndjson") - if err := recorder.SaveMasked(fixturePath, clauderecord.MaskOptions{ - WorkspaceCWD: cwd, - HomePath: homePath, - }); err != nil { - t.Fatalf("save masked recording: %v", err) + // Should contain the answer (391) + assertDeltaContains(t, events, "391") + + s.saveMaskedFixture("thinking.ndjson") +} + +// --- Test: Multi-turn conversation --- + +func TestClaudeCLIMultiTurn(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + s := startClaude(t, ctx) + defer s.close() + + // Turn 1 + s.sendUserMessage("remember the word 'banana'. respond with just 'ok'") + events1 := s.collectUntilTurnComplete(ctx) + logEvents(t, events1) + + tc1 := findEvent(events1, agentproto.EventTurnCompleted) + if tc1 == nil || tc1.Status != "completed" { + t.Fatalf("turn 1 not completed: %+v", tc1) + } + turn1ID := tc1.TurnID + + // Turn 2 + s.sendUserMessage("what word did I ask you to remember? respond with just that word") + events2 := s.collectUntilTurnComplete(ctx) + logEvents(t, events2) + + tc2 := findEvent(events2, agentproto.EventTurnCompleted) + if tc2 == nil || tc2.Status != "completed" { + t.Fatalf("turn 2 not completed: %+v", tc2) + } + turn2ID := tc2.TurnID + + // Turns should have different IDs + if turn1ID == turn2ID { + t.Errorf("turns should have different IDs: %s vs %s", turn1ID, turn2ID) + } + + // Turn 2 should mention banana + assertDeltaContains(t, events2, "banana") + + s.saveMaskedFixture("multi_turn.ndjson") +} + +// --- Helpers --- + +func logEvents(t *testing.T, events []agentproto.Event) { + t.Helper() + t.Logf("collected %d events", len(events)) + for i, e := range events { + extra := "" + if e.Delta != "" { + extra = " delta=" + truncate([]byte(e.Delta), 60) + } + if e.ItemKind != "" { + extra += " itemKind=" + e.ItemKind + } + if e.Status != "" { + extra += " status=" + e.Status + } + t.Logf(" event[%d]: kind=%s%s", i, e.Kind, extra) + } +} + +func assertDeltaContains(t *testing.T, events []agentproto.Event, substr string) { + t.Helper() + var fullText string + for _, e := range events { + if e.Kind == agentproto.EventItemDelta { + fullText += e.Delta + } + } + if !strings.Contains(strings.ToLower(fullText), strings.ToLower(substr)) { + t.Errorf("expected delta text to contain %q, got %q", substr, truncate([]byte(fullText), 200)) } - t.Logf("saved masked recording to %s (%d entries)", fixturePath, len(recorder.Entries())) } -// drainWithTimeout collects events from the channel for up to the given duration. func drainWithTimeout(ch <-chan agentproto.Event, allEvents *[]agentproto.Event, d time.Duration) { timer := time.NewTimer(d) defer timer.Stop() @@ -213,7 +374,6 @@ func drainWithTimeout(ch <-chan agentproto.Event, allEvents *[]agentproto.Event, } } -// waitForEventKind drains eventsCh into allEvents until the target kind appears. func waitForEventKind(t *testing.T, ctx context.Context, ch <-chan agentproto.Event, allEvents *[]agentproto.Event, kind agentproto.EventKind, label string) { t.Helper() for { diff --git a/internal/adapter/claude/translator_observe_server.go b/internal/adapter/claude/translator_observe_server.go index 67c1c575..a509a3b0 100644 --- a/internal/adapter/claude/translator_observe_server.go +++ b/internal/adapter/claude/translator_observe_server.go @@ -116,7 +116,7 @@ func (t *Translator) observeStreamEvent(message map[string]any) (Result, error) func (t *Translator) observeMessageStart(event map[string]any) (Result, error) { t.turnID = t.nextTurnID() t.turnActive = true - t.activeBlockIndex = -1 + t.blocks = map[int]*blockState{} t.debugf("observe message_start: thread=%s turn=%s", t.currentThreadID, t.turnID) @@ -129,11 +129,15 @@ func (t *Translator) observeMessageStart(event map[string]any) (Result, error) { } func (t *Translator) observeContentBlockStart(event map[string]any) (Result, error) { - t.activeBlockIndex++ + index := intField(event, "index") block, _ := event["content_block"].(map[string]any) blockType, _ := block["type"].(string) - t.activeBlockType = blockType - t.activeItemID = fmt.Sprintf("item-%s-%d", t.turnID, t.activeBlockIndex) + itemID := fmt.Sprintf("item-%s-%d", t.turnID, index) + + bs := &blockState{ + blockType: blockType, + itemID: itemID, + } var itemKind string switch blockType { @@ -143,32 +147,40 @@ func (t *Translator) observeContentBlockStart(event map[string]any) (Result, err itemKind = "reasoning_content" case "tool_use": itemKind = "command_execution" - t.activeToolName = stringField(block, "name") - t.activeToolUseID = stringField(block, "id") + bs.toolName = stringField(block, "name") + bs.toolUseID = stringField(block, "id") default: itemKind = blockType } + t.blocks[index] = bs + t.debugf("observe content_block_start: index=%d type=%s itemKind=%s tool=%s", - t.activeBlockIndex, blockType, itemKind, t.activeToolName) + index, blockType, itemKind, bs.toolName) - metadata := map[string]any{"blockIndex": t.activeBlockIndex} - if t.activeToolName != "" { - metadata["toolName"] = t.activeToolName - metadata["toolUseId"] = t.activeToolUseID + metadata := map[string]any{"blockIndex": index} + if bs.toolName != "" { + metadata["toolName"] = bs.toolName + metadata["toolUseId"] = bs.toolUseID } return Result{Events: []agentproto.Event{{ Kind: agentproto.EventItemStarted, ThreadID: t.currentThreadID, TurnID: t.turnID, - ItemID: t.activeItemID, + ItemID: itemID, ItemKind: itemKind, Metadata: metadata, }}}, nil } func (t *Translator) observeContentBlockDelta(event map[string]any) (Result, error) { + index := intField(event, "index") + bs, exists := t.blocks[index] + if !exists { + return Result{}, nil + } + delta, _ := event["delta"].(map[string]any) if delta == nil { return Result{}, nil @@ -188,6 +200,7 @@ func (t *Translator) observeContentBlockDelta(event map[string]any) (Result, err itemKind = "command_execution" text, _ = delta["partial_json"].(string) default: + // signature_delta and other unknown types -- silently skip return Result{}, nil } @@ -199,15 +212,21 @@ func (t *Translator) observeContentBlockDelta(event map[string]any) (Result, err Kind: agentproto.EventItemDelta, ThreadID: t.currentThreadID, TurnID: t.turnID, - ItemID: t.activeItemID, + ItemID: bs.itemID, ItemKind: itemKind, Delta: text, }}}, nil } func (t *Translator) observeContentBlockStop(event map[string]any) (Result, error) { + index := intField(event, "index") + bs, exists := t.blocks[index] + if !exists { + return Result{}, nil + } + itemKind := "agent_message" - switch t.activeBlockType { + switch bs.blockType { case "thinking": itemKind = "reasoning_content" case "tool_use": @@ -216,21 +235,17 @@ func (t *Translator) observeContentBlockStop(event map[string]any) (Result, erro itemKind = "agent_message" } - t.debugf("observe content_block_stop: index=%d type=%s", t.activeBlockIndex, t.activeBlockType) + t.debugf("observe content_block_stop: index=%d type=%s", index, bs.blockType) + + delete(t.blocks, index) - result := Result{Events: []agentproto.Event{{ + return Result{Events: []agentproto.Event{{ Kind: agentproto.EventItemCompleted, ThreadID: t.currentThreadID, TurnID: t.turnID, - ItemID: t.activeItemID, + ItemID: bs.itemID, ItemKind: itemKind, - }}} - - // Reset active tool state - t.activeToolName = "" - t.activeToolUseID = "" - - return result, nil + }}}, nil } // observeAssistant handles complete assistant messages. @@ -412,3 +427,8 @@ func stringField(m map[string]any, key string) string { v, _ := m[key].(string) return strings.TrimSpace(v) } + +func intField(m map[string]any, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/internal/adapter/claude/translator_replay_test.go b/internal/adapter/claude/translator_replay_test.go index 02ebe9cc..9cb28db8 100644 --- a/internal/adapter/claude/translator_replay_test.go +++ b/internal/adapter/claude/translator_replay_test.go @@ -10,55 +10,47 @@ import ( "github.com/kxn/codex-remote-feishu/internal/core/agentproto" ) -// TestTranslatorReplayHelloSimple replays a masked NDJSON fixture through -// the Claude translator and verifies the agentproto event sequence. -// The fixture is generated by the integration test (go test -tags integration). -func TestTranslatorReplayHelloSimple(t *testing.T) { - fixturePath := "testdata/hello_simple.ndjson" - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("fixture %s not found; run integration test first: go test -tags integration -run TestClaudeCLIIntegration", fixturePath) +// replayFixture loads a fixture and replays it through the translator, +// returning all collected events and outbound frames. +func replayFixture(t *testing.T, path string) (events []agentproto.Event, outbound [][]byte) { + t.Helper() + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Skipf("fixture %s not found; run: go test -tags integration -run TestClaude", path) } - - entries, err := clauderecord.LoadFixture(fixturePath) + entries, err := clauderecord.LoadFixture(path) if err != nil { t.Fatalf("load fixture: %v", err) } tr := claude.NewTranslator("replay-test") - tr.SetDebugLogger(func(format string, args ...any) { - t.Logf("translator: "+format, args...) - }) - - var allEvents []agentproto.Event - var allOutbound [][]byte for _, entry := range entries { switch entry.Direction { case clauderecord.DirRecv: result, err := tr.ObserveServer(entry.Frame) if err != nil { - t.Logf("observe seq=%d error: %v", entry.Seq, err) continue } - allEvents = append(allEvents, result.Events...) - allOutbound = append(allOutbound, result.OutboundToAgent...) + events = append(events, result.Events...) + outbound = append(outbound, result.OutboundToAgent...) case clauderecord.DirSend: result, err := tr.ObserveClient(entry.Frame) if err != nil { continue } - allEvents = append(allEvents, result.Events...) + events = append(events, result.Events...) } } + return +} - t.Logf("replayed %d entries -> %d events, %d outbound frames", len(entries), len(allEvents), len(allOutbound)) - for i, e := range allEvents { - t.Logf(" event[%d]: kind=%s threadId=%s itemKind=%s delta_len=%d", - i, e.Kind, e.ThreadID, e.ItemKind, len(e.Delta)) - } +// --- Replay: hello simple --- + +func TestTranslatorReplayHelloSimple(t *testing.T) { + events, _ := replayFixture(t, "testdata/hello_simple.ndjson") + logReplayEvents(t, events) - // Verify expected event kinds appear in order - assertEventOrder(t, allEvents, []agentproto.EventKind{ + assertEventOrder(t, events, []agentproto.EventKind{ agentproto.EventThreadDiscovered, agentproto.EventTurnStarted, agentproto.EventItemStarted, @@ -66,67 +58,187 @@ func TestTranslatorReplayHelloSimple(t *testing.T) { agentproto.EventTurnCompleted, }) - // Verify turn completed successfully - turnCompleted := findEventByKind(allEvents, agentproto.EventTurnCompleted) - if turnCompleted == nil { - t.Fatal("missing EventTurnCompleted") - } - if turnCompleted.Status != "completed" { - t.Errorf("turn status: got %q, want %q", turnCompleted.Status, "completed") - } + assertTurnCompleted(t, events, "completed") + assertDeltaTextContains(t, events, "agent_message", "hello") +} - // Verify deltas contain text - var fullText string - for _, e := range allEvents { - if e.Kind == agentproto.EventItemDelta && e.ItemKind == "agent_message" { - fullText += e.Delta +// --- Replay: tool use --- + +func TestTranslatorReplayToolUse(t *testing.T) { + events, _ := replayFixture(t, "testdata/tool_use_bash.ndjson") + logReplayEvents(t, events) + + assertEventOrder(t, events, []agentproto.EventKind{ + agentproto.EventTurnStarted, + agentproto.EventTurnCompleted, + }) + + assertTurnCompleted(t, events, "completed") + + // Verify tool use items exist + hasToolItem := false + for _, e := range events { + if e.Kind == agentproto.EventItemStarted && e.ItemKind == "command_execution" { + hasToolItem = true + if name, ok := e.Metadata["toolName"].(string); ok { + t.Logf("tool: %s", name) + } } } - if !strings.Contains(strings.ToLower(fullText), "hello") { - t.Errorf("expected delta text to contain 'hello', got %q", fullText) + if !hasToolItem { + // Tool use might be auto-executed without streaming items if + // the model used bypass permissions -- still valid if turn completed + t.Log("no command_execution items found (tool may have been auto-executed)") } - // Verify masked CWD propagated correctly - threadEvent := findEventByKind(allEvents, agentproto.EventThreadDiscovered) - if threadEvent != nil && threadEvent.CWD != "" { - if strings.Contains(threadEvent.CWD, "/home/") && !strings.Contains(threadEvent.CWD, "/test/") { - t.Errorf("CWD appears unmasked: %s", threadEvent.CWD) + assertDeltaTextContains(t, events, "", "test123") +} + +// --- Replay: thinking --- + +func TestTranslatorReplayThinking(t *testing.T) { + events, _ := replayFixture(t, "testdata/thinking.ndjson") + logReplayEvents(t, events) + + assertEventOrder(t, events, []agentproto.EventKind{ + agentproto.EventTurnStarted, + agentproto.EventTurnCompleted, + }) + + assertTurnCompleted(t, events, "completed") + + // Check for thinking items + hasThinking := false + for _, e := range events { + if e.Kind == agentproto.EventItemStarted && e.ItemKind == "reasoning_content" { + hasThinking = true } } + if hasThinking { + t.Log("thinking blocks detected in replay") + // Verify thinking deltas exist + hasDelta := false + for _, e := range events { + if e.Kind == agentproto.EventItemDelta && e.ItemKind == "reasoning_content" { + hasDelta = true + break + } + } + if !hasDelta { + t.Error("thinking item started but no thinking deltas found") + } + } + + assertDeltaTextContains(t, events, "", "391") } -// TestTranslatorReplayMCPHandshake verifies that MCP control_request messages -// during initialization produce OutboundToAgent frames (the MCP responses). -func TestTranslatorReplayMCPHandshake(t *testing.T) { - fixturePath := "testdata/hello_simple.ndjson" - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("fixture %s not found", fixturePath) +// --- Replay: multi-turn --- + +func TestTranslatorReplayMultiTurn(t *testing.T) { + events, _ := replayFixture(t, "testdata/multi_turn.ndjson") + logReplayEvents(t, events) + + // Should have exactly 2 turn completions + turnCompletions := filterEvents(events, agentproto.EventTurnCompleted) + if len(turnCompletions) < 2 { + t.Fatalf("expected at least 2 turn completions, got %d", len(turnCompletions)) } - entries, err := clauderecord.LoadFixture(fixturePath) - if err != nil { - t.Fatalf("load fixture: %v", err) + // Turn IDs should be different + if turnCompletions[0].TurnID == turnCompletions[1].TurnID { + t.Errorf("turn IDs should differ: %s vs %s", turnCompletions[0].TurnID, turnCompletions[1].TurnID) + } + + // Both turns should complete successfully + for i, tc := range turnCompletions { + if tc.Status != "completed" { + t.Errorf("turn %d status: %s", i, tc.Status) + } } - tr := claude.NewTranslator("mcp-test") + // Turn 2 should mention banana + // Find events after the first turn completion + afterFirstTurn := false + var turn2Deltas string + for _, e := range events { + if e.Kind == agentproto.EventTurnCompleted && !afterFirstTurn { + afterFirstTurn = true + continue + } + if afterFirstTurn && e.Kind == agentproto.EventItemDelta { + turn2Deltas += e.Delta + } + } + if !strings.Contains(strings.ToLower(turn2Deltas), "banana") { + t.Errorf("turn 2 should mention banana, got: %s", truncateStr(turn2Deltas, 200)) + } +} + +// --- Replay: MCP handshake --- + +func TestTranslatorReplayMCPHandshake(t *testing.T) { + _, outbound := replayFixture(t, "testdata/hello_simple.ndjson") + t.Logf("MCP handshake produced %d outbound frames", len(outbound)) +} + +// --- Replay: block index tracking --- - var mcpResponseCount int - for _, entry := range clauderecord.RecvEntries(entries) { - result, err := tr.ObserveServer(entry.Frame) - if err != nil { +func TestTranslatorReplayBlockIndexTracking(t *testing.T) { + // Replay any fixture with multiple blocks (thinking + text, or tool_use) + // and verify that item IDs are correctly assigned by block index + for _, fixture := range []string{"testdata/thinking.ndjson", "testdata/tool_use_bash.ndjson"} { + if _, err := os.Stat(fixture); os.IsNotExist(err) { continue } - mcpResponseCount += len(result.OutboundToAgent) + t.Run(fixture, func(t *testing.T) { + events, _ := replayFixture(t, fixture) + // Verify no duplicate item IDs for started items + seen := map[string]bool{} + for _, e := range events { + if e.Kind == agentproto.EventItemStarted && e.ItemID != "" { + if seen[e.ItemID] { + t.Errorf("duplicate item ID: %s", e.ItemID) + } + seen[e.ItemID] = true + } + } + // Verify each item.started has a matching item.completed + startedIDs := map[string]bool{} + completedIDs := map[string]bool{} + for _, e := range events { + if e.Kind == agentproto.EventItemStarted { + startedIDs[e.ItemID] = true + } + if e.Kind == agentproto.EventItemCompleted { + completedIDs[e.ItemID] = true + } + } + for id := range startedIDs { + if !completedIDs[id] { + t.Errorf("item %s started but never completed", id) + } + } + }) } +} + +// --- Helpers --- - // We expect at least one outbound frame if there were MCP setup messages. - // If the recording has no MCP setup (e.g. no MCP servers configured), - // this is still valid -- just log it. - t.Logf("MCP handshake produced %d outbound frames", mcpResponseCount) +func logReplayEvents(t *testing.T, events []agentproto.Event) { + t.Helper() + t.Logf("replayed %d events", len(events)) + for i, e := range events { + extra := "" + if e.ItemKind != "" { + extra += " itemKind=" + e.ItemKind + } + if len(e.Delta) > 0 { + extra += " delta_len=" + strings.Repeat(".", min(len(e.Delta)/10+1, 5)) + } + t.Logf(" event[%d]: kind=%s%s", i, e.Kind, extra) + } } -// assertEventOrder checks that the specified event kinds appear in order -// within the event list, allowing other events between them. func assertEventOrder(t *testing.T, events []agentproto.Event, expected []agentproto.EventKind) { t.Helper() pos := 0 @@ -145,6 +257,42 @@ func assertEventOrder(t *testing.T, events []agentproto.Event, expected []agentp } } +func assertTurnCompleted(t *testing.T, events []agentproto.Event, expectedStatus string) { + t.Helper() + tc := findEventByKind(events, agentproto.EventTurnCompleted) + if tc == nil { + t.Fatal("missing EventTurnCompleted") + } + if tc.Status != expectedStatus { + t.Errorf("turn status: got %q, want %q", tc.Status, expectedStatus) + } +} + +func assertDeltaTextContains(t *testing.T, events []agentproto.Event, itemKind, substr string) { + t.Helper() + var fullText string + for _, e := range events { + if e.Kind == agentproto.EventItemDelta { + if itemKind == "" || e.ItemKind == itemKind { + fullText += e.Delta + } + } + } + if !strings.Contains(strings.ToLower(fullText), strings.ToLower(substr)) { + t.Errorf("expected delta text (itemKind=%s) to contain %q, got %q", itemKind, substr, truncateStr(fullText, 200)) + } +} + +func filterEvents(events []agentproto.Event, kind agentproto.EventKind) []agentproto.Event { + var out []agentproto.Event + for _, e := range events { + if e.Kind == kind { + out = append(out, e) + } + } + return out +} + func findEventByKind(events []agentproto.Event, kind agentproto.EventKind) *agentproto.Event { for i := range events { if events[i].Kind == kind { @@ -153,3 +301,17 @@ func findEventByKind(events []agentproto.Event, kind agentproto.EventKind) *agen } return nil } + +func truncateStr(s string, max int) string { + if len(s) > max { + return s[:max] + "..." + } + return s +} + +func min(a, b int) int { + if a < b { + return a + } + return b +}