Files
tutti/packages/agent/daemon/runtime/claude_sdk_event_projection_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1975 lines
75 KiB
Go

package agentruntime
import (
"bytes"
"context"
"errors"
"log/slog"
"reflect"
"strings"
"sync"
"testing"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
)
func TestClaudeCodeSDKAdapterLogsAPIRetryDiagnostics(t *testing.T) {
previousLogger := slog.Default()
var output bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
t.Cleanup(func() {
slog.SetDefault(previousLogger)
})
adapter := NewClaudeCodeSDKAdapter(nil)
adapterSession := &claudeSDKAdapterSession{
providerSessionID: "provider-session-retry",
rootTurnID: "turn-retry",
}
adapter.logClaudeSDKLifecycleEvent(
"agent-session-retry",
adapterSession,
claudeSDKSidecarEvent{
Type: "sdk_lifecycle_observed",
Payload: map[string]any{
"sdkMessageType": "system",
"sdkMessageSubtype": "api_retry",
"apiRetry": true,
"sdkConnectionError": true,
"sdkRetryAttempt": 2,
"sdkMaxRetries": 3,
"sdkRetryDelayMs": 400,
"sdkAssistantError": "unknown",
},
},
)
logged := output.String()
for _, expected := range []string{
"sdk_message_subtype=api_retry",
"api_retry=true",
"sdk_connection_error=true",
"sdk_retry_attempt=2",
"sdk_max_retries=3",
"sdk_retry_delay_ms=400",
"sdk_assistant_error=unknown",
} {
if !strings.Contains(logged, expected) {
t.Fatalf("log = %q, want %q", logged, expected)
}
}
}
func TestClaudeCodeSDKAdapterMapsSyntheticTurnStarted(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{liveState: newClaudeSDKLiveState()}
adapter.beginClaudeSDKRootTurn(adapterSession, "root-turn-1", "provider-turn-1")
events, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "synthetic-1", claudeSDKSidecarEvent{
Type: "turn_started",
Payload: map[string]any{
"turnId": "synthetic-1",
"synthetic": true,
},
})
if err != nil || terminal {
t.Fatalf("turn_started err=%v terminal=%v", err, terminal)
}
if len(events) != 1 || events[0].Type != activityshared.EventRootProviderTurnStarted {
t.Fatalf("events = %#v, want root provider turn start", events)
}
if events[0].Payload.TurnID != "root-turn-1" || events[0].Payload.ProviderTurnID != "synthetic-1" ||
string(events[0].Payload.ProviderTurnBindingJSON) != `{"schemaVersion":1}` ||
events[0].Payload.TurnPhase != string(activityshared.TurnPhaseRunning) {
t.Fatalf("turn started payload = %#v", events[0].Payload)
}
if events[0].Payload.Metadata["synthetic"] != true {
t.Fatalf("turn metadata = %#v, want synthetic=true", events[0].Payload.Metadata)
}
}
func TestClaudeSDKLifecycleLogArgsKeepsZeroCounts(t *testing.T) {
got := claudeSDKLifecycleLogArgs(map[string]any{
"sdkMessageOrigin": "task-notification",
"state": "idle",
"backgroundTasksObservedCount": float64(6),
"backgroundTasksRunningCount": float64(0),
"backgroundTasksNoLongerLiveCount": float64(6),
"delegatedTasksKnownCount": float64(6),
"delegatedTasksRunningCount": float64(3),
"delegatedTasksCompletedCount": float64(3),
"delegatedTasksFailedCount": float64(0),
"delegatedTasksStoppedCount": float64(0),
})
want := []any{
"sdk_message_origin", "task-notification",
"state", "idle",
"background_tasks_observed", int64(6),
"background_tasks_running", int64(0),
"background_tasks_no_longer_live", int64(6),
"delegated_tasks_known", int64(6),
"delegated_tasks_running", int64(3),
"delegated_tasks_completed", int64(3),
"delegated_tasks_failed", int64(0),
"delegated_tasks_stopped", int64(0),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("log args = %#v, want %#v", got, want)
}
}
func TestClaudeSDKLifecycleLogArgsIncludesTaskUsage(t *testing.T) {
got := claudeSDKLifecycleLogArgs(map[string]any{
"taskId": "task-1",
"usage": map[string]any{
"total_tokens": float64(12_345),
"input_tokens": float64(2_000),
"output_tokens": float64(345),
"cache_read_input_tokens": float64(9_000),
"cache_creation_input_tokens": float64(1_000),
"tool_uses": float64(7),
"duration_ms": float64(4_200),
},
})
want := []any{
"task_id", "task-1",
"usage_total_tokens", int64(12_345),
"usage_input_tokens", int64(2_000),
"usage_output_tokens", int64(345),
"usage_cache_read_input_tokens", int64(9_000),
"usage_cache_creation_input_tokens", int64(1_000),
"usage_tool_uses", int64(7),
"usage_duration_ms", int64(4_200),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("log args = %#v, want %#v", got, want)
}
}
func TestClaudeSDKContinuationDelayUsesWarningLogLevel(t *testing.T) {
if got := claudeSDKLifecycleEventLogLevel("continuation_delayed"); got != slog.LevelWarn {
t.Fatalf("log level = %v, want warning", got)
}
if got := claudeSDKLifecycleEventLogLevel("background_tasks_changed"); got != slog.LevelInfo {
t.Fatalf("ordinary lifecycle log level = %v, want info", got)
}
}
func TestClaudeCodeSDKAdapterMapsObservedProviderTurnIdentity(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{liveState: newClaudeSDKLiveState()}
adapter.beginClaudeSDKRootTurn(adapterSession, "canonical-turn-1", "")
events, terminal, err := adapter.sidecarTurnEvents(
adapterSession,
session,
"canonical-turn-1",
claudeSDKSidecarEvent{
Type: "provider_turn_identity_resolved",
Payload: map[string]any{
"turnId": "canonical-turn-1",
"providerTurnId": "persisted-claude-user-uuid",
},
},
)
if err != nil || terminal {
t.Fatalf("provider_turn_identity_resolved err=%v terminal=%v", err, terminal)
}
if len(events) != 1 ||
events[0].Type != activityshared.EventRootProviderTurnStarted ||
events[0].Payload.TurnID != "canonical-turn-1" ||
events[0].Payload.ProviderTurnID != "persisted-claude-user-uuid" ||
string(events[0].Payload.ProviderTurnBindingJSON) != `{"schemaVersion":1}` {
t.Fatalf("events = %#v, want observed provider identity", events)
}
if adapter.claudeSDKRootTurnID(adapterSession, "") != "canonical-turn-1" {
t.Fatalf("root turn id = %q", adapter.claudeSDKRootTurnID(adapterSession, ""))
}
if !adapter.consumeClaudeSDKRootProviderTurn(
adapterSession,
"persisted-claude-user-uuid",
) {
t.Fatal("observed provider identity was not registered")
}
}
func TestClaudeCodeSDKAdapterMapsProviderTurnCheckpoint(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{liveState: newClaudeSDKLiveState()}
adapter.beginClaudeSDKRootTurn(
adapterSession,
"canonical-turn-1",
"provider-prompt-1",
)
events, terminal, err := adapter.sidecarTurnEvents(
adapterSession,
session,
"canonical-turn-1",
claudeSDKSidecarEvent{
Type: "provider_turn_checkpoint",
Payload: map[string]any{
"turnId": "canonical-turn-1",
"providerTurnId": "provider-prompt-1",
"providerCheckpointMessageId": "provider-system-1",
},
},
)
if err != nil || terminal {
t.Fatalf("provider_turn_checkpoint err=%v terminal=%v", err, terminal)
}
if len(events) != 1 ||
events[0].Type != activityshared.EventRootProviderTurnCheckpoint ||
events[0].Payload.TurnID != "canonical-turn-1" ||
events[0].Payload.ProviderTurnID != "provider-prompt-1" ||
string(events[0].Payload.ProviderTurnBindingJSON) !=
`{"schemaVersion":1,"checkpointMessageId":"provider-system-1"}` {
t.Fatalf("events = %#v, want provider checkpoint", events)
}
}
func TestClaudeCodeSDKAdapterCompletesCanonicalTurnByProviderIdentity(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
session: session,
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.beginClaudeSDKRootTurn(
adapterSession,
"canonical-turn-1",
"provider-prompt-1",
)
var published []activityshared.Event
adapter.SetSessionEventSink(func(agentSessionID string, events []activityshared.Event) {
if agentSessionID == session.AgentSessionID {
published = append(published, events...)
}
})
_ = adapter.dispatchClaudeSDKEvent(
session.AgentSessionID,
adapterSession,
claudeSDKSidecarEvent{
Type: "turn_completed",
Payload: map[string]any{
"turnId": "canonical-turn-1",
"providerTurnId": "provider-prompt-1",
"stopReason": "end_turn",
},
},
)
var completion *activityshared.Event
for index := range published {
if published[index].Type == activityshared.EventRootProviderTurnCompleted {
completion = &published[index]
break
}
}
if completion == nil ||
completion.Payload.TurnID != "canonical-turn-1" ||
completion.Payload.ProviderTurnID != "provider-prompt-1" {
t.Fatalf("published=%#v, want canonical/provider completion identities", published)
}
if adapter.consumeClaudeSDKRootProviderTurn(adapterSession, "provider-prompt-1") {
t.Fatal("provider turn identity remained live after terminal dispatch")
}
}
func TestClaudeCodeSDKAdapterUsesSidecarAssistantMessageID(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{liveState: newClaudeSDKLiveState()}
var events []activityshared.Event
for _, input := range []struct {
messageID string
content string
}{
{messageID: "claude-sdk:assistant:msg-1:live:0", content: "Before tool."},
{messageID: "claude-sdk:assistant:msg-1:live:1", content: "After tool."},
} {
next, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-interleaved", claudeSDKSidecarEvent{
Type: "assistant_completed",
Payload: map[string]any{
"turnId": "turn-interleaved",
"messageId": input.messageID,
"content": input.content,
},
})
if err != nil || terminal {
t.Fatalf("assistant_completed err=%v terminal=%v", err, terminal)
}
events = append(events, next...)
}
messages := activityMessagesWithRole(events, activityshared.MessageRoleAssistant)
if len(messages) != 2 {
t.Fatalf("assistant messages = %#v, want two distinct sidecar messages", messages)
}
if messages[0].EventID == messages[1].EventID ||
messages[0].Payload.Content != "Before tool." ||
messages[1].Payload.Content != "After tool." {
t.Fatalf("assistant messages = %#v, want distinct ids and contents", messages)
}
}
func TestClaudeCodeSDKAdapterMapsAssistantSDKErrorToFailedMessage(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{liveState: newClaudeSDKLiveState()}
events, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-auth", claudeSDKSidecarEvent{
Type: "assistant_failed",
Payload: map[string]any{
"turnId": "turn-auth",
"messageId": "claude-sdk:assistant:auth-error:fallback:0",
"content": "Failed to authenticate. API Error: 401",
},
})
if err != nil || terminal {
t.Fatalf("assistant_failed err=%v terminal=%v", err, terminal)
}
if len(events) != 1 ||
events[0].Type != activityshared.EventMessageAppended ||
events[0].EventID != "claude-sdk:assistant:auth-error:fallback:0" ||
events[0].Payload.Role != activityshared.MessageRoleAssistant ||
events[0].Payload.Metadata["streamState"] != messageStreamStateFailed ||
events[0].Payload.Content != "Failed to authenticate. API Error: 401" {
t.Fatalf("assistant failed events = %#v", events)
}
}
func TestClaudeCodeSDKAdapterApprovalDoesNotMergeWithApprovedToolCall(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.beginClaudeSDKRootTurn(
adapterSession,
"turn-web",
"provider-turn-web",
)
approvalEvents, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-web", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "turn-web",
"requestId": "approval-web",
"toolCallId": "call_web",
"toolName": "WebSearch",
"input": map[string]any{"query": "current weather in Tokyo Japan"},
},
})
if err != nil || terminal || len(approvalEvents) != 3 || approvalEvents[2].Type != activityshared.EventInteractionRequested {
t.Fatalf("approval events=%#v terminal=%v err=%v", approvalEvents, terminal, err)
}
toolEvents, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-web", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "turn-web",
"toolCallId": "call_web",
"toolName": "WebSearch",
"input": map[string]any{"query": "current weather in Tokyo Japan"},
},
})
if err != nil || terminal || len(toolEvents) != 1 {
t.Fatalf("tool events=%#v terminal=%v err=%v", toolEvents, terminal, err)
}
approvalUpdate, ok := callMessageUpdateFromSessionEvent(
canonical.EventSource{Provider: ProviderClaudeCode},
approvalEvents[1],
session.AgentSessionID,
approvalEvents[1].OccurredAtUnixMS,
)
if !ok {
t.Fatal("approval event did not convert to message update")
}
toolUpdate, ok := callMessageUpdateFromSessionEvent(
canonical.EventSource{Provider: ProviderClaudeCode},
toolEvents[0],
session.AgentSessionID,
toolEvents[0].OccurredAtUnixMS,
)
if !ok {
t.Fatal("tool event did not convert to message update")
}
if approvalUpdate.MessageID == toolUpdate.MessageID {
t.Fatalf("message id = %q for both approval and tool; want separate timeline rows", approvalUpdate.MessageID)
}
if approvalUpdate.Payload["callType"] != "approval" || approvalUpdate.Payload["toolName"] != "Approval" || approvalUpdate.Status != "waiting_approval" {
t.Fatalf("approval update = %#v, want approval waiting row", approvalUpdate)
}
if toolUpdate.Payload["callType"] != "tool" || toolUpdate.Payload["toolName"] != "WebSearch" {
t.Fatalf("tool update = %#v, want web search tool row", toolUpdate)
}
}
func TestClaudeCodeSDKAdapterPreservesSubagentParentToolUseID(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
events, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-read",
"toolName": "Read",
"callType": "tool",
"input": map[string]any{"file_path": "/repo/README.md"},
"output": map[string]any{"text": "Read README"},
"metadata": map[string]any{
"parentToolUseId": "toolu-task",
},
},
})
if err != nil || terminal || len(events) != 1 {
t.Fatalf("tool_completed events=%#v terminal=%v err=%v, want one nonterminal call event", events, terminal, err)
}
if events[0].Payload.Metadata["parentToolUseId"] != "toolu-task" {
t.Fatalf("event metadata = %#v, want parentToolUseId", events[0].Payload.Metadata)
}
update, ok := callMessageUpdateFromSessionEvent(
canonical.EventSource{Provider: ProviderClaudeCode},
events[0],
session.AgentSessionID,
events[0].OccurredAtUnixMS,
)
if !ok {
t.Fatal("tool event did not convert to message update")
}
metadata := payloadMap(update.Payload, "metadata")
if metadata["parentToolUseId"] != "toolu-task" {
t.Fatalf("message update payload = %#v, want nested metadata parentToolUseId", update.Payload)
}
}
func TestClaudeCodeSDKAdapterCreatesAndSettlesChildSession(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{
"description": "Explore codebase structure",
"prompt": "Find relevant files",
},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": "agent-1",
"subagentAgentId": "agent-1",
},
},
})
if err != nil || terminal {
t.Fatalf("tool_completed terminal=%v err=%v", terminal, err)
}
if len(started) != 3 || started[0].Type != activityshared.EventCallCompleted ||
started[1].Type != activityshared.EventSessionStarted || started[1].SessionKind != "child" ||
started[2].Type != activityshared.EventTurnStarted {
t.Fatalf("started events = %#v, want root call plus child session/turn start", started)
}
childSessionID := started[1].AgentSessionID
childTurnID := started[1].Payload.TurnID
if started[1].RootAgentSessionID != session.AgentSessionID || started[1].RootTurnID != "turn-task" ||
started[1].ParentAgentSessionID != session.AgentSessionID || started[1].ParentTurnID != "turn-task" ||
started[1].ParentToolCallID != "toolu-agent" {
t.Fatalf("child relation = %#v", started[1])
}
completed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "task_completed",
Payload: map[string]any{
"taskId": "task-1",
"agentId": "agent-1",
"parentToolUseId": "toolu-agent",
"status": "completed",
"summary": "Explore codebase structure",
},
})
if err != nil || terminal {
t.Fatalf("task_completed terminal=%v err=%v", terminal, err)
}
if len(completed) != 3 || completed[0].Type != activityshared.EventMessageAppended ||
completed[0].Payload.Content != "Explore codebase structure" ||
completed[1].Type != activityshared.EventActivityCompleted ||
completed[2].Type != activityshared.EventTurnCompleted {
t.Fatalf("completed events = %#v, want child result, activity, and turn completion", completed)
}
for _, event := range completed {
if event.AgentSessionID != childSessionID || event.Payload.TurnID != childTurnID || event.RootTurnID != "turn-task" {
t.Fatalf("child completion scope = %#v, want child session=%q turn=%q", event, childSessionID, childTurnID)
}
}
resultUpdated, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "task_result_updated",
Payload: map[string]any{
"taskId": "task-1",
"agentId": "agent-1",
"parentToolUseId": "toolu-agent",
"status": "completed",
"summary": "Found relevant files",
},
})
if err != nil || terminal {
t.Fatalf("task_result_updated terminal=%v err=%v", terminal, err)
}
if len(resultUpdated) != 1 || resultUpdated[0].Type != activityshared.EventMessageAppended ||
resultUpdated[0].Payload.Content != "Found relevant files" ||
resultUpdated[0].AgentSessionID != childSessionID ||
resultUpdated[0].Payload.TurnID != childTurnID {
t.Fatalf("result update = %#v, want final child result on settled child", resultUpdated)
}
if payloadString(completed[0].Payload.Metadata, "messageId") != payloadString(resultUpdated[0].Payload.Metadata, "messageId") {
t.Fatalf("message ids differ: completed=%#v updated=%#v", completed[0].Payload.Metadata, resultUpdated[0].Payload.Metadata)
}
if completed[0].EventID == resultUpdated[0].EventID {
t.Fatalf("event ids must differ so the final snapshot is not deduplicated: %q", completed[0].EventID)
}
}
func TestClaudeCodeSDKAdapterKeepsChildRunningWhenBackgroundProcessStillRunning(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
events, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"status": "completed",
"input": map[string]any{
"description": "Touch file requiring approval",
"prompt": "touch /tmp/example",
"run_in_background": false,
},
"metadata": map[string]any{
"adapter": "claude-agent-sdk",
"toolName": "Agent",
"backgroundProcess": map[string]any{
"taskId": "task-sync-1",
"status": "running",
},
},
},
})
if err != nil || terminal {
t.Fatalf("tool_completed terminal=%v err=%v", terminal, err)
}
if len(events) < 2 || events[1].Type != activityshared.EventSessionStarted || events[1].SessionKind != "child" {
t.Fatalf("events = %#v, want child session start while background process is still running", events)
}
child := adapterSession.claudeSDKChildByKey("toolu-agent")
if child.Status != string(activityshared.ActivityStatusRunning) {
t.Fatalf("child status = %q, want running while backgroundProcess.status=running", child.Status)
}
if !child.Async {
t.Fatalf("child async = false, want true when backgroundProcess is still running")
}
}
func TestClaudeCodeSDKAdapterScopesChildApprovalBySDKAgentID(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
conn := &recordingClaudeSDKConnection{}
adapterSession := &claudeSDKAdapterSession{
conn: conn,
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.beginClaudeSDKRootTurn(adapterSession, "turn-task", "provider-turn-task")
_, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "provider-turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{"description": "Write a probe file"},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": "agent-1",
"subagentAgentId": "agent-1",
},
},
})
if err != nil || terminal {
t.Fatalf("child launch terminal=%v err=%v", terminal, err)
}
child := adapterSession.claudeSDKChildByKey("agent-1")
if child.AgentSessionID == "" || child.TurnID == "" {
t.Fatalf("child = %#v, want canonical child identity", child)
}
requested, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-task", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "provider-turn-task",
"requestId": "approval-child-write",
"toolCallId": "toolu-child-write",
"toolName": "Write",
"agentId": "agent-1",
"input": map[string]any{"file_path": "/repo/permission-probe.txt", "content": "hello"},
},
})
if err != nil || terminal || len(requested) != 3 {
t.Fatalf("approval requested events=%#v terminal=%v err=%v", requested, terminal, err)
}
for _, event := range requested {
if event.AgentSessionID != child.AgentSessionID || event.Payload.TurnID != child.TurnID {
t.Fatalf("approval request scope = %#v, want child session=%q turn=%q", event, child.AgentSessionID, child.TurnID)
}
}
pending := adapter.getClaudeSDKPendingRequest(child.AgentSessionID, child.TurnID, "approval-child-write")
if pending == nil || pending.agentSessionID != child.AgentSessionID || pending.providerTurnID != "provider-turn-task" {
t.Fatalf("pending = %#v, want child-owned request", pending)
}
submitDone := make(chan claudeSDKSubmitTestResult, 1)
go func() {
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
AgentSessionID: child.AgentSessionID,
TurnID: child.TurnID,
RequestID: "approval-child-write",
OptionID: "allow",
})
submitDone <- claudeSDKSubmitTestResult{result: result, err: err}
}()
waitForCondition(t, func() bool { return len(conn.sentRequests()) == 1 })
submittedRequest := conn.sentRequests()[0]
if submittedRequest.Type != "submit_interactive" || submittedRequest.Payload["agentSessionId"] != session.AgentSessionID || submittedRequest.Payload["turnId"] != "provider-turn-task" {
t.Fatalf("child approval sidecar request = %#v, want root runtime session and provider turn", submittedRequest)
}
resolved, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-task", claudeSDKSidecarEvent{
Type: "approval_resolved",
Payload: map[string]any{
"turnId": "provider-turn-task",
"requestId": "approval-child-write",
"optionId": "allow",
"action": "submit",
},
})
if err != nil || terminal || len(resolved) != 2 {
t.Fatalf("approval resolved events=%#v terminal=%v err=%v", resolved, terminal, err)
}
if submitted := <-submitDone; submitted.err != nil || !submitted.result.Accepted {
t.Fatalf("SubmitInteractive result=%#v error=%v, want accepted child approval", submitted.result, submitted.err)
}
for _, event := range resolved {
if event.AgentSessionID != child.AgentSessionID || event.Payload.TurnID != child.TurnID {
t.Fatalf("approval resolution scope = %#v, want child session=%q turn=%q", event, child.AgentSessionID, child.TurnID)
}
}
replayed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-task", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "provider-turn-task",
"requestId": "approval-child-write",
"toolCallId": "toolu-child-write",
"toolName": "Write",
"agentId": "agent-1",
"input": map[string]any{"file_path": "/repo/permission-probe.txt", "content": "hello"},
},
})
if err != nil || terminal {
t.Fatalf("replayed child approval terminal=%v err=%v", terminal, err)
}
if len(replayed) != 0 {
t.Fatalf("replayed child approval events=%#v, want none", replayed)
}
if got := adapter.InteractiveDispositionForTarget(session, child.AgentSessionID, child.TurnID, "approval-child-write"); got != InteractiveDispositionAnswered {
t.Fatalf("child disposition after replay=%q, want answered", got)
}
}
func TestClaudeCodeSDKAdapterScopesChildApprovalAckEventsToChild(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
conn := &recordingClaudeSDKConnection{}
adapterSession := &claudeSDKAdapterSession{
conn: conn,
pendingRequests: make(map[string]*pendingInteractiveRequest),
pendingResponses: make(map[string]chan claudeSDKSidecarEvent),
readerStarted: true,
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.beginClaudeSDKRootTurn(adapterSession, "turn-task", "provider-turn-task")
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "provider-turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{"description": "Write a probe file"},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": "agent-1",
"subagentAgentId": "agent-1",
},
},
}); err != nil {
t.Fatalf("child launch: %v", err)
}
child := adapterSession.claudeSDKChildByKey("agent-1")
if child.AgentSessionID == "" || child.TurnID == "" {
t.Fatalf("child = %#v, want canonical child identity", child)
}
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-task", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "provider-turn-task",
"requestId": "approval-child-write",
"toolCallId": "toolu-child-write",
"toolName": "Write",
"agentId": "agent-1",
},
}); err != nil {
t.Fatalf("approval requested: %v", err)
}
type emittedBatch struct {
agentSessionID string
events []activityshared.Event
}
emitted := make(chan emittedBatch, 1)
adapter.SetSessionEventSink(func(agentSessionID string, events []activityshared.Event) {
emitted <- emittedBatch{agentSessionID: agentSessionID, events: events}
})
submitDone := make(chan claudeSDKSubmitTestResult, 1)
go func() {
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
AgentSessionID: child.AgentSessionID,
TurnID: child.TurnID,
RequestID: "approval-child-write",
OptionID: "allow",
})
submitDone <- claudeSDKSubmitTestResult{result: result, err: err}
}()
waitForCondition(t, func() bool { return len(conn.sentRequests()) == 1 })
request := conn.sentRequests()[0]
_ = adapter.dispatchClaudeSDKEvent(session.AgentSessionID, adapterSession, claudeSDKSidecarEvent{
ID: request.ID,
Type: "ok",
Payload: map[string]any{
"disposition": "answered",
},
})
if submitted := <-submitDone; submitted.err != nil || !submitted.result.Accepted {
t.Fatalf("SubmitInteractive result=%#v error=%v, want accepted child approval", submitted.result, submitted.err)
}
batch := <-emitted
if batch.agentSessionID != session.AgentSessionID {
t.Fatalf("shared runtime sink session = %q, want root %q", batch.agentSessionID, session.AgentSessionID)
}
if len(batch.events) != 2 {
t.Fatalf("ack events = %#v, want completed call and working turn", batch.events)
}
for _, event := range batch.events {
if event.AgentSessionID != child.AgentSessionID || event.ProviderSessionID != "agent-1" || event.SessionKind != "child" || event.Payload.TurnID != child.TurnID {
t.Fatalf("ack event scope = %#v, want canonical child identity", event)
}
}
report := reportActivityInput(session, batch.events)
if len(report.StatePatches) != 1 || report.StatePatches[0].AgentSessionID != child.AgentSessionID || report.StatePatches[0].Kind != "child" {
t.Fatalf("ack state patches = %#v, want child-owned patch", report.StatePatches)
}
if len(report.MessageUpdates) != 1 || report.MessageUpdates[0].AgentSessionID != child.AgentSessionID {
t.Fatalf("ack message updates = %#v, want child-owned completion", report.MessageUpdates)
}
}
func TestClaudeCodeSDKAdapterKeepsDelegationCompletionOnParentAndUpdatesChildTitle(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{"toolName": "Agent"},
},
})
if err != nil || terminal || len(started) != 3 {
t.Fatalf("tool_started events=%#v terminal=%v err=%v", started, terminal, err)
}
if started[0].Type != activityshared.EventCallStarted || started[0].AgentSessionID != session.AgentSessionID ||
started[0].Payload.TurnID != "turn-task" {
t.Fatalf("parent delegation start = %#v", started[0])
}
childSessionID := started[1].AgentSessionID
childTurnID := started[1].Payload.TurnID
if started[1].Type != activityshared.EventSessionStarted || started[1].Payload.Title != "" ||
started[2].Type != activityshared.EventTurnStarted {
t.Fatalf("child start events = %#v", started[1:])
}
completed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{
"description": "Inspect the repository",
"prompt": "Find the relevant files and summarize them.",
"run_in_background": false,
},
"output": map[string]any{"text": "Inspection complete"},
},
})
if err != nil || terminal || len(completed) != 3 {
t.Fatalf("tool_completed events=%#v terminal=%v err=%v", completed, terminal, err)
}
if completed[0].Type != activityshared.EventCallCompleted || completed[0].AgentSessionID != session.AgentSessionID ||
completed[0].Payload.TurnID != "turn-task" || payloadString(completed[0].Payload.Metadata, "callId") != "toolu-agent" ||
payloadString(payloadMap(completed[0].Payload.Metadata, "input"), "description") != "Inspect the repository" {
t.Fatalf("parent delegation completion = %#v", completed[0])
}
if completed[1].Type != activityshared.EventSessionUpdated || completed[1].AgentSessionID != childSessionID ||
completed[1].Payload.Title != "Inspect the repository" {
t.Fatalf("child title update = %#v", completed[1])
}
if completed[2].Type != activityshared.EventTurnCompleted || completed[2].AgentSessionID != childSessionID ||
completed[2].Payload.TurnID != childTurnID {
t.Fatalf("child terminal = %#v", completed[2])
}
settled, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "turn_completed",
Payload: map[string]any{
"turnId": "turn-task",
"providerTurnId": "provider-turn-task",
},
})
if err != nil || !terminal {
t.Fatalf("turn_completed events=%#v terminal=%v err=%v", settled, terminal, err)
}
for _, event := range settled {
if event.Type == activityshared.EventCallFailed {
t.Fatalf("parent delegation was left dangling: %#v", settled)
}
}
}
func TestClaudeCodeSDKAdapterSettlesDetachedProcessLaunchWithoutCreatingChildWork(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-bash", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "turn-bash",
"toolCallId": "toolu-bash",
"toolName": "Bash",
"callType": "tool",
"input": map[string]any{
"command": "python3 -m http.server 8000",
"run_in_background": true,
},
},
})
if err != nil || terminal || len(started) != 1 || started[0].Type != activityshared.EventCallStarted {
t.Fatalf("tool_started events=%#v terminal=%v err=%v", started, terminal, err)
}
if len(adapterSession.childSessions) != 0 {
t.Fatalf("detached process created child sessions: %#v", adapterSession.childSessions)
}
completed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-bash", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-bash",
"toolCallId": "toolu-bash",
"toolName": "Bash",
"callType": "tool",
"status": "completed",
"input": map[string]any{
"command": "python3 -m http.server 8000",
"run_in_background": true,
},
"metadata": map[string]any{
"backgroundProcess": map[string]any{"status": "running"},
},
},
})
if err != nil || terminal || len(completed) != 1 || completed[0].Type != activityshared.EventCallCompleted {
t.Fatalf("tool_completed events=%#v terminal=%v err=%v", completed, terminal, err)
}
settled, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-bash", claudeSDKSidecarEvent{
Type: "turn_completed",
Payload: map[string]any{
"turnId": "turn-bash",
"providerTurnId": "provider-turn-bash",
},
})
if err != nil || !terminal {
t.Fatalf("turn_completed events=%#v terminal=%v err=%v", settled, terminal, err)
}
for _, event := range settled {
if event.Type == activityshared.EventCallFailed {
t.Fatalf("detached process launch was left dangling: %#v", settled)
}
}
if len(adapterSession.childSessions) != 0 {
t.Fatalf("completed detached process created child sessions: %#v", adapterSession.childSessions)
}
}
func TestClaudeCodeSDKAdapterCreatesNestedChildUnderParentChildTurn(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{liveState: newClaudeSDKLiveState()}
adapter.beginClaudeSDKRootTurn(adapterSession, "root-turn-1", "provider-turn-1")
rootChildEvents, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-1", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "provider-turn-1",
"toolCallId": "toolu-parent",
"callType": "subagent",
"toolName": "Task",
"input": map[string]any{"description": "parent", "run_in_background": true},
},
})
if err != nil || len(rootChildEvents) != 3 {
t.Fatalf("parent child events=%#v err=%v", rootChildEvents, err)
}
parent := adapterSession.claudeSDKChildByKey("toolu-parent")
nestedEvents, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-1", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "provider-turn-1",
"toolCallId": "toolu-child",
"callType": "subagent",
"toolName": "Task",
"input": map[string]any{"description": "nested", "run_in_background": true},
"metadata": map[string]any{"parentToolUseId": "toolu-parent"},
},
})
if err != nil || len(nestedEvents) != 3 {
t.Fatalf("nested child events=%#v err=%v", nestedEvents, err)
}
nested := adapterSession.claudeSDKChildByKey("toolu-child")
if nested.RootAgentSessionID != session.AgentSessionID || nested.RootTurnID != "root-turn-1" ||
nested.ParentAgentSessionID != parent.AgentSessionID || nested.ParentTurnID != parent.TurnID {
t.Fatalf("nested relation = %#v; parent = %#v", nested, parent)
}
if nestedEvents[0].AgentSessionID != parent.AgentSessionID || nestedEvents[0].Payload.TurnID != parent.TurnID ||
nestedEvents[1].AgentSessionID != nested.AgentSessionID {
t.Fatalf("nested launch scopes = %#v", nestedEvents)
}
}
func TestClaudeCodeSDKAdapterUpdatesChildSessionByProviderAlias(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
for _, startedAgent := range []struct {
parentToolUseID string
agentID string
}{
{parentToolUseID: "toolu-agent-1", agentID: "agent-1"},
{parentToolUseID: "toolu-agent-2", agentID: "agent-2"},
} {
_, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": startedAgent.parentToolUseID,
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{"description": "Generate number"},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": startedAgent.agentID,
"subagentAgentId": startedAgent.agentID,
},
},
})
if err != nil || terminal {
t.Fatalf("tool_completed terminal=%v err=%v", terminal, err)
}
}
progress, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "task_started",
Payload: map[string]any{
"taskId": "task-2",
"agentId": "agent-2",
"description": "Generate number",
"status": "running",
},
})
if err != nil || terminal {
t.Fatalf("task_started terminal=%v err=%v", terminal, err)
}
if len(adapterSession.childSessions) != 2 {
t.Fatalf("child sessions = %#v, want two", adapterSession.childSessions)
}
childTwo := adapterSession.claudeSDKChildByKey("task-2")
if childTwo.AgentID != "agent-2" || childTwo.TaskID != "task-2" || childTwo.Status != "running" {
t.Fatalf("task_started child = %#v", childTwo)
}
if len(progress) != 1 || progress[0].Type != activityshared.EventActivityStarted ||
progress[0].AgentSessionID != childTwo.AgentSessionID || progress[0].Payload.TurnID != childTwo.TurnID {
t.Fatalf("task_started events = %#v, want child-two scope", progress)
}
completed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "task_completed",
Payload: map[string]any{
"taskId": "task-2",
"status": "completed",
"summary": "7",
},
})
if err != nil || terminal {
t.Fatalf("task_completed terminal=%v err=%v", terminal, err)
}
if first := adapterSession.claudeSDKChildByKey("toolu-agent-1"); first.Status != "running" {
t.Fatalf("child one = %#v, want running", first)
}
childTwo = adapterSession.claudeSDKChildByKey("toolu-agent-2")
if childTwo.Status != "completed" || len(completed) != 3 || completed[0].Type != activityshared.EventMessageAppended ||
completed[0].AgentSessionID != childTwo.AgentSessionID || completed[0].Payload.TurnID != childTwo.TurnID ||
completed[0].Payload.Content != "7" || completed[2].Type != activityshared.EventTurnCompleted ||
completed[2].AgentSessionID != childTwo.AgentSessionID || completed[2].Payload.TurnID != childTwo.TurnID {
t.Fatalf("completed child/events = %#v / %#v", childTwo, completed)
}
}
func TestClaudeCodeSDKAdapterEndsUnresolvedChildrenWhenBackgroundTasksQuiesce(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
for _, launch := range []struct {
parentToolUseID string
agentID string
}{
{parentToolUseID: "toolu-agent-1", agentID: "agent-1"},
{parentToolUseID: "toolu-agent-2", agentID: "agent-2"},
} {
_, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": launch.parentToolUseID,
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{"description": "Generate number"},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": launch.agentID,
"subagentAgentId": launch.agentID,
},
},
})
if err != nil || terminal {
t.Fatalf("tool_completed terminal=%v err=%v", terminal, err)
}
}
_, _, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "task_completed",
Payload: map[string]any{
"turnId": "turn-task",
"agentId": "agent-1",
"parentToolUseId": "toolu-agent-1",
"status": "completed",
},
})
if err != nil {
t.Fatalf("task_completed: %v", err)
}
ended, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "background_tasks_quiesced",
Payload: map[string]any{
"turnId": "turn-task",
"runningCount": 0,
},
})
if err != nil || terminal {
t.Fatalf("background_tasks_quiesced terminal=%v err=%v", terminal, err)
}
first := adapterSession.claudeSDKChildByKey("toolu-agent-1")
second := adapterSession.claudeSDKChildByKey("toolu-agent-2")
if first.Status != "completed" || second.Status != "interrupted" {
t.Fatalf("children=%#v / %#v", first, second)
}
if len(ended) != 2 ||
ended[0].Type != activityshared.EventActivityCompleted ||
ended[1].Type != activityshared.EventTurnCompleted ||
ended[1].Payload.TurnOutcome != string(activityshared.TurnOutcomeInterrupted) ||
ended[1].AgentSessionID != second.AgentSessionID {
t.Fatalf("ended events=%#v", ended)
}
}
func TestClaudeCodeSDKAdapterKeepsChildSessionsSeparateOnAliasConflict(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
// A poisoned upstream binding attaches agent-2's task id to the first
// Agent tool call.
_, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "task_started",
Payload: map[string]any{
"turnId": "turn-task",
"taskId": "agent-2",
"parentToolUseId": "toolu-agent-1",
"description": "Generate number",
"status": "running",
},
})
if err != nil || terminal {
t.Fatalf("task_started terminal=%v err=%v", terminal, err)
}
// The second Agent launch carries its own parent tool call id; it must
// not merge into toolu-agent-1 through the poisoned agent-2 alias.
launched, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent-2",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{"description": "Generate number"},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": "agent-2",
"subagentAgentId": "agent-2",
},
},
})
if err != nil || terminal {
t.Fatalf("tool_completed terminal=%v err=%v", terminal, err)
}
if len(adapterSession.childSessions) != 2 {
t.Fatalf("child sessions = %#v, want separate sessions", adapterSession.childSessions)
}
first := adapterSession.claudeSDKChildByKey("toolu-agent-1")
second := adapterSession.claudeSDKChildByKey("toolu-agent-2")
if first.AgentSessionID == "" || second.AgentSessionID == "" || first.AgentSessionID == second.AgentSessionID ||
first.Status != "running" || second.Status != "running" {
t.Fatalf("children = %#v / %#v, want separate running sessions", first, second)
}
if len(launched) != 3 || launched[1].AgentSessionID != second.AgentSessionID {
t.Fatalf("second launch events = %#v", launched)
}
completed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "task_completed",
Payload: map[string]any{
"taskId": "agent-2",
"agentId": "agent-2",
"parentToolUseId": "toolu-agent-2",
"status": "completed",
"summary": "Generated number",
},
})
if err != nil || terminal {
t.Fatalf("task_completed terminal=%v err=%v", terminal, err)
}
first = adapterSession.claudeSDKChildByKey("toolu-agent-1")
second = adapterSession.claudeSDKChildByKey("toolu-agent-2")
if first.Status != "running" || second.Status != "completed" || len(completed) != 3 ||
completed[0].Type != activityshared.EventMessageAppended ||
completed[0].Payload.Content != "Generated number" ||
completed[2].AgentSessionID != second.AgentSessionID {
t.Fatalf("settled children/events = %#v / %#v / %#v", first, second, completed)
}
}
func TestClaudeCodeSDKAdapterKeepsLateChildEventsOnOriginalChildTurn(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{
"description": "Explore codebase structure",
"prompt": "Find relevant files",
},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": "agent-1",
"subagentAgentId": "agent-1",
},
},
})
if err != nil || terminal {
t.Fatalf("tool_completed terminal=%v err=%v", terminal, err)
}
if len(started) != 3 || started[1].Type != activityshared.EventSessionStarted {
t.Fatalf("started events = %#v, want child session creation", started)
}
childSessionID := started[1].AgentSessionID
childTurnID := started[1].Payload.TurnID
// Claude may finish the root provider turn before its background child.
// That weak ordering remains valid unless the exact child was canceled too.
adapter.markClaudeSDKTurnClosed(adapterSession, "turn-task", "completed")
completed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "task_completed",
Payload: map[string]any{
"turnId": "turn-task",
"taskId": "task-1",
"agentId": "agent-1",
"parentToolUseId": "toolu-agent",
"status": "completed",
"summary": "Found relevant files",
},
})
if err != nil || terminal {
t.Fatalf("late task_completed terminal=%v err=%v", terminal, err)
}
if len(completed) != 3 || completed[0].Type != activityshared.EventMessageAppended ||
completed[0].Payload.Content != "Found relevant files" ||
completed[2].Type != activityshared.EventTurnCompleted {
t.Fatalf("late task_completed events = %#v, want result + activity + child turn completion", completed)
}
for _, event := range completed {
if event.AgentSessionID != childSessionID || event.Payload.TurnID != childTurnID || event.RootTurnID != "turn-task" {
t.Fatalf("late child event scope = %#v", event)
}
}
}
func TestClaudeCodeSDKAdapterCompletesParentCallAfterChildSettles(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{
"description": "Inspect lifecycle ownership",
"prompt": "Find the first failed boundary",
"run_in_background": true,
},
},
})
if err != nil || terminal || len(started) != 3 {
t.Fatalf("tool_started events=%#v terminal=%v err=%v", started, terminal, err)
}
child := adapterSession.claudeSDKChildByKey("toolu-agent")
if child.AgentSessionID == "" || child.TurnID == "" || child.Status != "running" {
t.Fatalf("started child=%#v, want running child", child)
}
completedChild, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "task_completed",
Payload: map[string]any{
"turnId": "turn-task",
"taskId": "task-1",
"agentId": "agent-1",
"parentToolUseId": "toolu-agent",
"status": "completed",
"summary": "Lifecycle owner found",
},
})
if err != nil || terminal || len(completedChild) != 3 {
t.Fatalf("task_completed events=%#v terminal=%v err=%v", completedChild, terminal, err)
}
child = adapterSession.claudeSDKChildByKey("toolu-agent")
adapter.markClaudeSDKTurnClosed(adapterSession, child.TurnID, "completed")
if child.Status != "completed" || !adapter.turnAlreadySettled(adapterSession, child.TurnID) {
t.Fatalf("completed child=%#v settled=%v", child, adapter.turnAlreadySettled(adapterSession, child.TurnID))
}
completedParent, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-task", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-task",
"toolCallId": "toolu-agent",
"toolName": "Agent",
"callType": "subagent",
"status": "completed",
"input": map[string]any{
"description": "Inspect lifecycle ownership",
"prompt": "Find the first failed boundary",
"run_in_background": true,
},
"output": map[string]any{"text": "Lifecycle owner found"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "completed",
"taskId": "task-1",
"agentId": "agent-1",
"subagentAgentId": "agent-1",
},
},
})
if err != nil || terminal {
t.Fatalf("tool_completed terminal=%v err=%v", terminal, err)
}
if len(completedParent) != 1 ||
completedParent[0].Type != activityshared.EventCallCompleted ||
completedParent[0].AgentSessionID != session.AgentSessionID ||
completedParent[0].Payload.TurnID != "turn-task" {
t.Fatalf("parent completion events=%#v, want one root call completion", completedParent)
}
child = adapterSession.claudeSDKChildByKey("toolu-agent")
if child.Status != "completed" {
t.Fatalf("child status=%q, want terminal status to remain completed", child.Status)
}
}
func TestClaudeCodeSDKAdapterDropsLateChildFailureAfterTargetedCancel(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
conn := &ackClaudeSDKConnection{}
session, adapterSession := newClaudeSDKLifecycleTestSession(t, adapter, conn)
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-cancel", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-cancel",
"toolCallId": "toolu-agent-cancel",
"toolName": "Agent",
"callType": "subagent",
"input": map[string]any{
"description": "Inspect cancellation",
"prompt": "Check the target",
},
"output": map[string]any{"text": "Async agent launched successfully"},
"metadata": map[string]any{
"subagentAsync": true,
"subagentStatus": "running",
"agentId": "agent-cancel",
"subagentAgentId": "agent-cancel",
},
},
})
if err != nil || terminal || len(started) != 3 {
t.Fatalf("child launch events=%#v terminal=%v err=%v", started, terminal, err)
}
child := adapterSession.claudeSDKChildByKey("toolu-agent-cancel")
if child.AgentSessionID == "" || child.TurnID == "" || child.Status != "running" {
t.Fatalf("child = %#v, want running child", child)
}
result, err := adapter.CancelTargets(context.Background(), session, []CancelTarget{
{AgentSessionID: session.AgentSessionID, TurnID: "turn-cancel"},
{AgentSessionID: child.AgentSessionID, TurnID: child.TurnID},
}, "user")
if err != nil {
t.Fatalf("CancelTargets: %v", err)
}
if len(result.ConfirmedTargets) != 2 || !adapter.turnAlreadySettled(adapterSession, child.TurnID) {
t.Fatalf("cancel result=%#v childClosed=%v", result, adapter.turnAlreadySettled(adapterSession, child.TurnID))
}
failed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-cancel", claudeSDKSidecarEvent{
Type: "tool_failed",
Payload: map[string]any{
"turnId": "turn-cancel",
"toolCallId": "toolu-agent-cancel",
"toolName": "Agent",
"callType": "subagent",
"error": "user_interrupt",
},
})
if err != nil || terminal || len(failed) != 0 {
t.Fatalf("late tool_failed events=%#v terminal=%v err=%v, want dropped", failed, terminal, err)
}
stopped, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-cancel", claudeSDKSidecarEvent{
Type: "task_completed",
Payload: map[string]any{
"turnId": "turn-cancel",
"taskId": "agent-cancel",
"agentId": "agent-cancel",
"parentToolUseId": "toolu-agent-cancel",
"status": "stopped",
},
})
if err != nil || terminal || len(stopped) != 0 {
t.Fatalf("late task_completed events=%#v terminal=%v err=%v, want dropped", stopped, terminal, err)
}
}
func TestClaudeCodeSDKAdapterMapsAskUserQuestionInteractive(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.beginClaudeSDKRootTurn(
adapterSession,
"turn-ask",
"provider-turn-ask",
)
events, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-ask", claudeSDKSidecarEvent{
Type: "user_input_requested",
Payload: map[string]any{
"turnId": "turn-ask",
"requestId": "ask-1",
"toolCallId": "toolu-ask",
"toolName": "AskUserQuestion",
"input": map[string]any{
"questions": []any{map[string]any{"question": "Pick one", "header": "Choice"}},
},
},
})
if err != nil || terminal {
t.Fatalf("user_input_requested err=%v terminal=%v", err, terminal)
}
if len(events) != 3 || events[1].Payload.CallType != "interactive" || events[2].Type != activityshared.EventInteractionRequested {
t.Fatalf("events = %#v, want interactive call", events)
}
prompt := adapter.SessionState(session).PendingInteractive
if prompt == nil || prompt.Kind != "ask-user" || prompt.ToolName != "AskUserQuestion" {
t.Fatalf("pending prompt = %#v, want ask-user", prompt)
}
}
func TestClaudeCodeSDKAdapterMapsExitPlanModeInteractive(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.beginClaudeSDKRootTurn(
adapterSession,
"turn-plan",
"provider-turn-plan",
)
events, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-plan", claudeSDKSidecarEvent{
Type: "user_input_requested",
Payload: map[string]any{
"turnId": "turn-plan",
"requestId": "plan-1",
"toolCallId": "toolu-plan",
"toolName": "ExitPlanMode",
"input": map[string]any{"plan": "1. Inspect\n2. Implement"},
"options": []any{
map[string]any{"kind": "allow_once", "name": "Yes", "optionId": "default"},
map[string]any{"kind": "reject_once", "name": "No", "optionId": "plan"},
},
},
})
if err != nil || terminal {
t.Fatalf("exit plan request err=%v terminal=%v", err, terminal)
}
if len(events) != 3 || events[1].Payload.CallType != "interactive" || events[2].Type != activityshared.EventInteractionRequested {
t.Fatalf("events = %#v, want interactive exit plan call", events)
}
prompt := adapter.SessionState(session).PendingInteractive
if prompt == nil || prompt.Kind != "exit-plan" || prompt.Input["plan"] != "1. Inspect\n2. Implement" {
t.Fatalf("pending prompt = %#v, want exit-plan", prompt)
}
resolved, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-plan", claudeSDKSidecarEvent{
Type: "user_input_resolved",
Payload: map[string]any{
"turnId": "turn-plan",
"requestId": "plan-1",
"optionId": "plan",
"action": "deny",
},
})
if err != nil || terminal {
t.Fatalf("exit plan resolved err=%v terminal=%v", err, terminal)
}
if len(resolved) != 2 || resolved[0].Type != activityshared.EventCallCompleted || resolved[0].Payload.Output["selectedId"] != "plan" {
t.Fatalf("resolved = %#v, want completed plan selection", resolved)
}
}
func TestClaudeCodeSDKAdapterCancelClearsPendingInteractive(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
conn := &ackClaudeSDKConnection{}
session, adapterSession := newClaudeSDKLifecycleTestSession(t, adapter, conn)
adapter.beginClaudeSDKRootTurn(
adapterSession,
"turn-cancel",
"provider-turn-cancel",
)
// A turn parked on an approval has a live Exec waiter in the registry; the
// interrupted terminal is stamped for that registered turnID, not for the
// cancel argument (which is the reason).
adapter.registerClaudeSDKTurn(adapterSession, "turn-cancel", nil)
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-cancel", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "turn-cancel",
"requestId": "approval-cancel",
"toolName": "Bash",
"input": map[string]any{"command": "sleep 10"},
},
}); err != nil {
t.Fatalf("approval_requested: %v", err)
}
if prompt := adapter.SessionState(session).PendingInteractive; prompt == nil {
t.Fatal("pending prompt missing before cancel")
}
events, err := adapter.Cancel(context.Background(), session, "user")
if err != nil {
t.Fatalf("Cancel: %v", err)
}
if len(events) != 2 || events[0].Type != activityshared.EventInteractionSuperseded || events[1].Type != activityshared.EventCallFailed {
t.Fatalf("cancel events = %#v, want pending interaction closure while provider cancellation is unconfirmed", events)
}
if prompt := adapter.SessionState(session).PendingInteractive; prompt != nil {
t.Fatalf("pending prompt after cancel = %#v, want nil", prompt)
}
}
func TestClaudeCodeSDKAdapterCancelFailsOpenToolCalls(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
conn := &ackClaudeSDKConnection{}
session, adapterSession := newClaudeSDKLifecycleTestSession(t, adapter, conn)
adapter.registerClaudeSDKTurn(adapterSession, "turn-write", nil)
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-write", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "turn-write",
"toolCallId": "toolu-write",
"toolName": "Write",
"name": "Write",
"input": map[string]any{
"file_path": "/tmp/out.txt",
"content": "partial",
},
},
})
if err != nil || terminal {
t.Fatalf("tool_started err=%v terminal=%v", err, terminal)
}
if len(started) != 1 || started[0].Type != activityshared.EventCallStarted {
t.Fatalf("started = %#v, want call.started", started)
}
events, err := adapter.Cancel(context.Background(), session, "user")
if err != nil {
t.Fatalf("Cancel: %v", err)
}
if len(events) != 1 {
t.Fatalf("cancel events = %#v, want failed open Write while provider cancellation is unconfirmed", events)
}
if events[0].Type != activityshared.EventCallFailed ||
events[0].EventID != "claude-sdk:tool:toolu-write" ||
events[0].Payload.Status != SessionStatusCanceled {
t.Fatalf("open tool cancel event = %#v, want call.failed with canceled status", events[0])
}
late, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-write", claudeSDKSidecarEvent{
Type: "tool_completed",
Payload: map[string]any{
"turnId": "turn-write",
"toolCallId": "toolu-write",
"toolName": "Write",
"name": "Write",
"output": map[string]any{"text": "done"},
},
})
if err != nil || terminal || len(late) != 0 {
t.Fatalf("late tool_completed after cancel = events=%#v terminal=%v err=%v, want dropped", late, terminal, err)
}
}
func TestClaudeCodeSDKAdapterCancelFailsOpenThinking(t *testing.T) {
// Mirrors the open-Write cancel path: thinking must leave the shared turn
// normalizer so Stop does not leave a forever-"thinking" disclosure.
adapter := NewClaudeCodeSDKAdapter(nil)
conn := &ackClaudeSDKConnection{}
session, adapterSession := newClaudeSDKLifecycleTestSession(t, adapter, conn)
adapter.registerClaudeSDKTurn(adapterSession, "turn-think", nil)
streaming, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-think", claudeSDKSidecarEvent{
Type: "thinking_delta",
Payload: map[string]any{
"turnId": "turn-think",
"snapshot": "Still reasoning about the change.",
},
})
if err != nil || terminal {
t.Fatalf("thinking_delta err=%v terminal=%v", err, terminal)
}
if len(streaming) != 1 ||
streaming[0].Payload.Role != activityshared.MessageRoleAssistantThinking ||
streaming[0].EventID != "claude-sdk:thinking:turn-think" ||
streaming[0].Payload.Metadata["streamState"] != messageStreamStateStreaming {
t.Fatalf("streaming thinking = %#v, want stable streaming thinking row", streaming)
}
events, err := adapter.Cancel(context.Background(), session, "user")
if err != nil {
t.Fatalf("Cancel: %v", err)
}
if len(events) != 1 {
t.Fatalf("cancel events = %#v, want failed open thinking while provider cancellation is unconfirmed", events)
}
if events[0].Type != activityshared.EventMessageAppended ||
events[0].EventID != "claude-sdk:thinking:turn-think" ||
events[0].Payload.Role != activityshared.MessageRoleAssistantThinking ||
events[0].Payload.Metadata["streamState"] != messageStreamStateFailed ||
events[0].Payload.Content != "Still reasoning about the change." {
t.Fatalf("open thinking cancel event = %#v, want failed thinking snapshot", events[0])
}
}
func TestClaudeCodeSDKAdapterGuidanceInterruptStopsOpenThinkingWithoutEndingTurn(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.registerClaudeSDKTurn(adapterSession, "turn-guidance", nil)
streaming, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-guidance", claudeSDKSidecarEvent{
Type: "thinking_delta",
Payload: map[string]any{
"turnId": "turn-guidance",
"messageId": "thinking-before-guidance",
"snapshot": "Still reasoning about the old request.",
},
})
if err != nil || terminal {
t.Fatalf("thinking_delta err=%v terminal=%v", err, terminal)
}
if len(streaming) != 1 || streaming[0].Payload.Metadata["streamState"] != messageStreamStateStreaming {
t.Fatalf("streaming thinking = %#v, want one streaming row", streaming)
}
interrupted, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-guidance", claudeSDKSidecarEvent{
Type: "guidance_interrupted",
Payload: map[string]any{
"turnId": "turn-guidance",
},
})
if err != nil || terminal {
t.Fatalf("guidance_interrupted err=%v terminal=%v", err, terminal)
}
if len(interrupted) != 1 ||
interrupted[0].EventID != "thinking-before-guidance" ||
interrupted[0].Payload.Metadata["streamState"] != messageStreamStateFailed {
t.Fatalf("interrupted thinking = %#v, want the old row settled", interrupted)
}
next, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-guidance", claudeSDKSidecarEvent{
Type: "thinking_delta",
Payload: map[string]any{
"turnId": "turn-guidance",
"messageId": "thinking-after-guidance",
"snapshot": "Reasoning about the guidance.",
},
})
if err != nil || terminal {
t.Fatalf("next thinking_delta err=%v terminal=%v", err, terminal)
}
if len(next) != 1 ||
next[0].EventID != "thinking-after-guidance" ||
next[0].Payload.Metadata["streamState"] != messageStreamStateStreaming {
t.Fatalf("next thinking = %#v, want a fresh stream on the same turn", next)
}
}
func TestClaudeCodeSDKAdapterCancelFailsOpenToolsAfterWaiterUnregistered(t *testing.T) {
// Mirrors controller Cancel ordering: active.cancel() makes Exec unregister
// its waiter before adapter.Cancel runs. Open tools must still close.
adapter := NewClaudeCodeSDKAdapter(nil)
conn := &ackClaudeSDKConnection{}
session, adapterSession := newClaudeSDKLifecycleTestSession(t, adapter, conn)
waiter := adapter.registerClaudeSDKTurn(adapterSession, "turn-write", nil)
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-write", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "turn-write",
"toolCallId": "toolu-write",
"toolName": "Write",
"name": "Write",
"input": map[string]any{"file_path": "/tmp/out.txt", "content": "partial"},
},
}); err != nil {
t.Fatalf("tool_started: %v", err)
}
adapter.unregisterClaudeSDKTurn(adapterSession, "turn-write", waiter)
if got := adapter.liveClaudeSDKTurnIDs(adapterSession); len(got) != 0 {
t.Fatalf("live turns after unregister = %#v, want empty", got)
}
events, err := adapter.Cancel(context.Background(), session, "user")
if err != nil {
t.Fatalf("Cancel: %v", err)
}
if len(events) != 1 ||
events[0].Type != activityshared.EventCallFailed ||
events[0].EventID != "claude-sdk:tool:toolu-write" ||
events[0].Payload.Status != SessionStatusCanceled {
t.Fatalf("cancel events = %#v, want failed open Write even with no live waiter", events)
}
}
func TestClaudeCodeSDKAdapterTurnCanceledFailsOpenToolCalls(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{liveState: newClaudeSDKLiveState()}
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-write", claudeSDKSidecarEvent{
Type: "tool_started",
Payload: map[string]any{
"turnId": "turn-write",
"toolCallId": "toolu-write",
"toolName": "Write",
"name": "Write",
"input": map[string]any{"file_path": "/tmp/out.txt", "content": "partial"},
},
}); err != nil {
t.Fatalf("tool_started: %v", err)
}
events, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-write", claudeSDKSidecarEvent{
Type: "turn_canceled",
Payload: map[string]any{
"turnId": "turn-write",
"providerTurnId": "turn-write",
},
})
if err != nil || !terminal {
t.Fatalf("turn_canceled err=%v terminal=%v", err, terminal)
}
if len(events) < 2 ||
events[0].Type != activityshared.EventCallFailed ||
events[0].EventID != "claude-sdk:tool:toolu-write" ||
events[0].Payload.Status != SessionStatusCanceled {
t.Fatalf("turn_canceled events = %#v, want failed open Write then provider cancellation", events)
}
if events[1].Type != activityshared.EventRootProviderTurnCompleted ||
events[1].Payload.TurnOutcome != string(activityshared.TurnOutcomeCanceled) ||
events[1].Payload.ProviderTurnID != "turn-write" {
t.Fatalf("provider terminal = %#v, want confirmed canceled", events[1])
}
}
// TestClaudeCodeSDKAdapterReaderFailureConvergesPendingInteractiveAndProviderTurn
// guards the disconnect path while a permission dialog is unanswered. The
// authoritative sink must settle the interaction, call, and provider turn
// before Exec is released; otherwise its stale return batch can be rejected
// and leave the durable root turn running forever.
func TestClaudeCodeSDKAdapterReaderFailureConvergesPendingInteractiveAndProviderTurn(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
conn: &recordingClaudeSDKConnection{},
session: session,
pendingRequests: make(map[string]*pendingInteractiveRequest),
pendingResponses: make(map[string]chan claudeSDKSidecarEvent),
turns: make(map[string]*claudeSDKTurnWaiter),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.beginClaudeSDKRootTurn(
adapterSession,
"turn-disconnect",
"provider-turn-disconnect",
)
waiter := adapter.registerClaudeSDKTurn(adapterSession, "turn-disconnect", nil)
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-disconnect", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "turn-disconnect",
"requestId": "approval-disconnect",
"toolName": "Bash",
"input": map[string]any{"command": "sleep 10"},
},
}); err != nil {
t.Fatalf("approval_requested: %v", err)
}
if prompt := adapter.SessionState(session).PendingInteractive; prompt == nil {
t.Fatal("pending prompt missing before disconnect")
}
var mu sync.Mutex
var received []activityshared.Event
waiterReleasedBeforeSink := false
adapter.SetSessionEventSink(func(_ string, events []activityshared.Event) {
mu.Lock()
defer mu.Unlock()
select {
case <-waiter.done:
waiterReleasedBeforeSink = true
default:
}
received = append(received, events...)
})
adapter.failClaudeSDKReader(session.AgentSessionID, adapterSession, errors.New("sidecar connection lost"))
mu.Lock()
events := append([]activityshared.Event(nil), received...)
releasedBeforeSink := waiterReleasedBeforeSink
mu.Unlock()
if releasedBeforeSink {
t.Fatal("turn waiter was released before authoritative disconnect events reached the session sink")
}
if len(events) != 3 ||
events[0].Type != activityshared.EventInteractionSuperseded ||
events[1].Type != activityshared.EventCallFailed ||
events[2].Type != activityshared.EventRootProviderTurnCompleted {
t.Fatalf("disconnect events = %#v, want superseded interaction, failed pending approval, and failed provider turn", events)
}
if msg, _ := events[1].Payload.Error["message"].(string); msg != "sidecar connection lost" {
t.Fatalf("failed approval error = %#v, want the disconnect reason", events[1].Payload.Error)
}
if events[2].Payload.TurnID != "turn-disconnect" ||
events[2].Payload.ProviderTurnID != "provider-turn-disconnect" ||
events[2].Payload.TurnOutcome != string(activityshared.TurnOutcomeFailed) {
t.Fatalf("provider failure = %#v, want matching root/provider identities and failed outcome", events[2])
}
select {
case result := <-waiter.done:
if result.err == nil || result.err.Error() != "sidecar connection lost" {
t.Fatalf("waiter error = %v, want the disconnect reason", result.err)
}
default:
t.Fatal("turn waiter was not released after terminal session events were emitted")
}
if duplicate := adapter.claudeSDKRootProviderFailureEvents(
adapterSession,
session,
"turn-disconnect",
"provider-turn-disconnect",
errors.New("sidecar connection lost"),
); len(activityEventsWithType(duplicate, activityshared.EventRootProviderTurnCompleted)) != 0 {
t.Fatalf("duplicate provider terminal events = %#v, want none after reader failure convergence", duplicate)
}
if adapter.getSession(session.AgentSessionID) != nil {
t.Fatal("session should be removed after the reader fails")
}
}