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
1975 lines
75 KiB
Go
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")
|
|
}
|
|
}
|