Files
tutti/packages/agent/daemon/runtime/claude_sdk_adapter_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

611 lines
25 KiB
Go

package agentruntime
import (
"context"
"errors"
"strings"
"testing"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func TestDefaultControllerUsesClaudeSDKAdapterByDefault(t *testing.T) {
controller := NewDefaultControllerWithProcessTransport(nil, nil)
if _, ok := controller.adapters[ProviderClaudeCode].(*ClaudeCodeSDKAdapter); !ok {
t.Fatalf("claude-code adapter = %T, want *ClaudeCodeSDKAdapter", controller.adapters[ProviderClaudeCode])
}
}
func TestClaudeCodeSDKAdapterInteractiveApprovalRoundTripIgnoresReplayedTerminalRequest(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-approval", "provider-turn-approval")
started, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-approval", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "turn-approval",
"requestId": "approval-1",
"toolCallId": "toolu-approval",
"toolName": "Bash",
"input": map[string]any{"command": "touch approval.txt"},
"options": []any{
map[string]any{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
map[string]any{"kind": "reject_once", "name": "Reject", "optionId": "reject"},
},
},
})
if err != nil || terminal {
t.Fatalf("approval_requested err=%v terminal=%v", err, terminal)
}
if len(started) != 3 || started[1].Type != activityshared.EventCallStarted || started[2].Type != activityshared.EventInteractionRequested {
t.Fatalf("started events = %#v, want turn update, call started, and interaction requested", started)
}
if started[1].Payload.CallType != "approval" || started[1].Payload.Status != string(activityshared.TurnPhaseWaitingApproval) {
t.Fatalf("approval event payload = %#v", started[1].Payload)
}
if started[1].Payload.CallID != "approval:approval-1" {
t.Fatalf("approval call id = %q, want synthetic request-scoped id", started[1].Payload.CallID)
}
input := started[1].Payload.Input
toolCall := payloadMap(input, "toolCall")
if toolCall["toolCallId"] != "toolu-approval" {
t.Fatalf("approval toolCall = %#v, want original tool use id preserved", toolCall)
}
if prompt := adapter.SessionState(session).PendingInteractive; prompt == nil || prompt.Kind != "approval" || prompt.RequestID != "approval-1" {
t.Fatalf("pending prompt = %#v, want approval", prompt)
}
pending := adapter.getClaudeSDKPendingRequest(
session.AgentSessionID,
"turn-approval",
"approval-1",
)
if pending == nil ||
pending.providerTurnID != "provider-turn-approval" ||
pending.transportTurnID != "turn-approval" {
t.Fatalf("pending identities = %#v", pending)
}
type submitResult struct {
result SubmitInteractiveResult
err error
}
submitDone := make(chan submitResult, 1)
go func() {
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{TurnID: "turn-approval", RequestID: "approval-1", OptionID: "allow"})
submitDone <- submitResult{result: result, err: err}
}()
waitForCondition(t, func() bool { return len(conn.sentRequests()) == 1 })
sent := conn.sentRequests()
if len(sent) != 1 || sent[0].Type != "submit_interactive" || sent[0].Payload["requestId"] != "approval-1" || sent[0].Payload["optionId"] != "allow" || sent[0].Payload["turnId"] != "turn-approval" {
t.Fatalf("sent requests = %#v", sent)
}
resolved, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-approval", claudeSDKSidecarEvent{
Type: "approval_resolved",
Payload: map[string]any{
"turnId": "turn-approval",
"requestId": "approval-1",
"optionId": "allow",
},
})
if err != nil || terminal {
t.Fatalf("approval_resolved err=%v terminal=%v", err, terminal)
}
submitted := <-submitDone
if submitted.err != nil || !submitted.result.Accepted || submitted.result.OptionID != "allow" {
t.Fatalf("SubmitInteractive result = %#v error=%v", submitted.result, submitted.err)
}
if len(resolved) != 2 || resolved[0].Type != activityshared.EventCallCompleted || resolved[0].Payload.Output["selectedId"] != "allow" {
t.Fatalf("resolved events = %#v, want completed approval", resolved)
}
if prompt := adapter.SessionState(session).PendingInteractive; prompt != nil {
t.Fatalf("pending prompt after resolve = %#v, want nil", prompt)
}
if got := adapter.InteractiveDisposition(session, "turn-approval", "approval-1"); got != InteractiveDispositionAnswered {
t.Fatalf("disposition after resolve = %q, want answered", got)
}
replayed, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-approval", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "turn-approval",
"requestId": "approval-1",
"toolCallId": "toolu-approval",
"toolName": "Bash",
"input": map[string]any{"command": "touch approval.txt"},
"options": []any{
map[string]any{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
map[string]any{"kind": "reject_once", "name": "Reject", "optionId": "reject"},
},
},
})
if err != nil || terminal {
t.Fatalf("replayed approval_requested err=%v terminal=%v", err, terminal)
}
if len(replayed) != 0 {
t.Fatalf("replayed events = %#v, want none after the request is answered", replayed)
}
if got := adapter.InteractiveDisposition(session, "turn-approval", "approval-1"); got != InteractiveDispositionAnswered {
t.Fatalf("disposition after replay = %q, want answered", got)
}
if prompt := adapter.SessionState(session).PendingInteractive; prompt != nil {
t.Fatalf("pending prompt after replay = %#v, want nil", prompt)
}
}
func TestClaudeCodeSDKAdapterCanceledSubmissionLeavesRequestPendingForRetry(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-approval", "provider-turn-approval")
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-approval", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "provider-turn-approval",
"requestId": "approval-1",
"toolCallId": "toolu-approval",
"toolName": "Bash",
"options": []any{
map[string]any{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
},
},
}); err != nil {
t.Fatalf("approval_requested: %v", err)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := adapter.SubmitInteractive(canceled, session, SubmitInteractiveInput{TurnID: "turn-approval", RequestID: "approval-1", OptionID: "allow"}); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled SubmitInteractive error = %v, want context canceled", err)
}
pending := adapter.getClaudeSDKPendingRequest(session.AgentSessionID, "turn-approval", "approval-1")
if pending == nil || pending.disposition() != pendingInteractiveRequestStatePending {
t.Fatalf("pending disposition = %v, want pending", runtimeInteractiveDisposition(pending))
}
if len(conn.sentRequests()) != 0 {
t.Fatalf("sent requests = %#v, want none before retry", conn.sentRequests())
}
type submitResult struct {
result SubmitInteractiveResult
err error
}
submitDone := make(chan submitResult, 1)
go func() {
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{TurnID: "turn-approval", RequestID: "approval-1", OptionID: "allow"})
submitDone <- submitResult{result: result, err: err}
}()
waitForCondition(t, func() bool { return len(conn.sentRequests()) == 1 })
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-approval", claudeSDKSidecarEvent{
Type: "approval_resolved",
Payload: map[string]any{
"turnId": "provider-turn-approval",
"requestId": "approval-1",
"optionId": "allow",
},
}); err != nil {
t.Fatalf("approval_resolved: %v", err)
}
result := <-submitDone
if result.err != nil || !result.result.Accepted {
t.Fatalf("retried SubmitInteractive result = %#v error=%v", result.result, result.err)
}
}
func TestClaudeCodeSDKAdapterSidecarRejectsInteractiveSubmission(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-approval", "provider-turn-approval")
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-approval", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "provider-turn-approval",
"requestId": "approval-1",
"toolCallId": "toolu-approval",
"toolName": "Bash",
"options": []any{
map[string]any{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
},
},
}); err != nil {
t.Fatalf("approval_requested: %v", err)
}
done := make(chan error, 1)
go func() {
_, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
TurnID: "turn-approval", RequestID: "approval-1", OptionID: "allow",
})
done <- err
}()
waitForCondition(t, func() bool { return len(conn.sentRequests()) == 1 })
request := conn.sentRequests()[0]
if err := adapter.dispatchClaudeSDKEvent(session.AgentSessionID, adapterSession, claudeSDKSidecarEvent{
ID: request.ID, Type: "error", Payload: map[string]any{"error": "interactive request is no longer live"},
}); err != nil {
t.Fatalf("dispatch submit error: %v", err)
}
if err := <-done; err == nil || !strings.Contains(err.Error(), "no longer live") {
t.Fatalf("SubmitInteractive error = %v, want sidecar rejection", err)
}
if got := adapter.InteractiveDisposition(session, "turn-approval", "approval-1"); got != InteractiveDispositionSuperseded {
t.Fatalf("disposition = %q, want superseded", got)
}
adapter.removeSession(session.AgentSessionID, adapterSession)
if got := adapter.InteractiveDisposition(session, "turn-approval", "approval-1"); got != InteractiveDispositionSuperseded {
t.Fatalf("disposition after session removal = %q, want superseded tombstone", got)
}
}
func TestClaudeCodeSDKAdapterRecoversAnsweredDispositionAfterLostSubmitAck(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
adapter.interactiveAckTimeout = 20 * time.Millisecond
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-approval", "provider-turn-approval")
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-approval", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "provider-turn-approval",
"requestId": "approval-1",
"toolCallId": "toolu-approval",
"toolName": "Bash",
"options": []any{
map[string]any{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
},
},
}); err != nil {
t.Fatalf("approval_requested: %v", err)
}
emitted := make(chan []activityshared.Event, 1)
adapter.SetSessionEventSink(func(_ string, events []activityshared.Event) {
emitted <- events
})
done := make(chan struct {
result SubmitInteractiveResult
err error
}, 1)
go func() {
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
TurnID: "turn-approval", RequestID: "approval-1", OptionID: "allow",
})
done <- struct {
result SubmitInteractiveResult
err error
}{result: result, err: err}
}()
waitForCondition(t, func() bool { return len(conn.sentRequests()) >= 2 })
requests := conn.sentRequests()
if requests[0].Type != "submit_interactive" || requests[1].Type != "interactive_disposition" {
t.Fatalf("sent requests = %#v, want submit followed by disposition query", requests)
}
if requests[0].Payload["turnId"] != "provider-turn-approval" || requests[1].Payload["turnId"] != "provider-turn-approval" {
t.Fatalf("sidecar turn ids = (%v, %v), want provider-turn-approval", requests[0].Payload["turnId"], requests[1].Payload["turnId"])
}
if err := adapter.dispatchClaudeSDKEvent(session.AgentSessionID, adapterSession, claudeSDKSidecarEvent{
ID: requests[1].ID, Type: "ok", Payload: map[string]any{"disposition": "answered"},
}); err != nil {
t.Fatalf("dispatch disposition: %v", err)
}
submitted := <-done
if submitted.err != nil || !submitted.result.Accepted || submitted.result.Disposition != InteractiveDispositionAnswered {
t.Fatalf("SubmitInteractive result = %#v error=%v", submitted.result, submitted.err)
}
events := <-emitted
if len(events) != 2 || events[0].Type != activityshared.EventCallCompleted {
t.Fatalf("emitted events = %#v, want completed call and working turn", events)
}
}
func TestClaudeCodeSDKAdapterDispositionQueryErrorRemainsResolving(t *testing.T) {
adapter, session, adapterSession, conn, done := startClaudeSDKLostAckTest(t)
waitForCondition(t, func() bool { return len(conn.sentRequests()) >= 2 })
query := conn.sentRequests()[1]
if err := adapter.dispatchClaudeSDKEvent(session.AgentSessionID, adapterSession, claudeSDKSidecarEvent{
ID: query.ID, Type: "error", Payload: map[string]any{"error": "query handler failed"},
}); err != nil {
t.Fatalf("dispatch query error: %v", err)
}
result := <-done
if result.err == nil || !strings.Contains(result.err.Error(), "query handler failed") {
t.Fatalf("SubmitInteractive error = %v, want query failure", result.err)
}
if result.result.Disposition != InteractiveDispositionResolving {
t.Fatalf("disposition = %q, want resolving", result.result.Disposition)
}
if got := adapter.InteractiveDisposition(session, "turn-approval", "approval-1"); got != InteractiveDispositionResolving {
t.Fatalf("runtime disposition = %q, want resolving", got)
}
}
func TestClaudeCodeSDKAdapterPendingDispositionQueryReturnsForRetry(t *testing.T) {
adapter, session, adapterSession, conn, done := startClaudeSDKLostAckTest(t)
waitForCondition(t, func() bool { return len(conn.sentRequests()) >= 2 })
query := conn.sentRequests()[1]
_ = adapter.dispatchClaudeSDKEvent(session.AgentSessionID, adapterSession, claudeSDKSidecarEvent{
ID: query.ID, Type: "ok", Payload: map[string]any{"disposition": "pending"},
})
select {
case result := <-done:
if result.err == nil {
t.Fatal("SubmitInteractive unexpectedly succeeded")
}
if result.result.Disposition != InteractiveDispositionPending {
t.Fatalf("disposition = %q, want pending", result.result.Disposition)
}
case <-time.After(time.Second):
t.Fatal("SubmitInteractive kept waiting after authoritative pending query")
}
if got := adapter.InteractiveDisposition(session, "turn-approval", "approval-1"); got != InteractiveDispositionPending {
t.Fatalf("runtime disposition = %q, want pending", got)
}
}
type claudeSDKSubmitTestResult struct {
result SubmitInteractiveResult
err error
}
func startClaudeSDKLostAckTest(t *testing.T) (
*ClaudeCodeSDKAdapter,
Session,
*claudeSDKAdapterSession,
*recordingClaudeSDKConnection,
<-chan claudeSDKSubmitTestResult,
) {
t.Helper()
adapter := NewClaudeCodeSDKAdapter(nil)
adapter.interactiveAckTimeout = 20 * time.Millisecond
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-approval", "provider-turn-approval")
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "provider-turn-approval", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "provider-turn-approval",
"requestId": "approval-1",
"toolCallId": "toolu-approval",
"toolName": "Bash",
"options": []any{
map[string]any{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
},
},
}); err != nil {
t.Fatalf("approval_requested: %v", err)
}
done := make(chan claudeSDKSubmitTestResult, 1)
go func() {
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
TurnID: "turn-approval", RequestID: "approval-1", OptionID: "allow",
})
done <- claudeSDKSubmitTestResult{result: result, err: err}
}()
return adapter, session, adapterSession, conn, done
}
func TestClaudeCodeSDKAdapterStaleRemovalDoesNotDeleteReplacementSession(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
terminalStarted := make(chan struct{})
releaseTerminal := make(chan struct{})
pending := &pendingInteractiveRequest{
agentSessionID: session.AgentSessionID,
turnID: "turn-old",
requestID: "request-old",
onTerminal: func(_ *pendingInteractiveRequest, _ pendingInteractiveRequestState) {
close(terminalStarted)
<-releaseTerminal
},
}
oldSession := &claudeSDKAdapterSession{
pendingRequests: map[string]*pendingInteractiveRequest{
claudeSDKPendingRequestKey(pending.turnID, pending.requestID): pending,
},
}
newSession := &claudeSDKAdapterSession{pendingRequests: make(map[string]*pendingInteractiveRequest)}
adapter.storeSession(session.AgentSessionID, oldSession)
removed := make(chan bool, 1)
go func() {
removed <- adapter.removeSession(session.AgentSessionID, oldSession)
}()
<-terminalStarted
adapter.storeSession(session.AgentSessionID, newSession)
close(releaseTerminal)
if !<-removed {
t.Fatal("old session was not detached")
}
if got := adapter.getSession(session.AgentSessionID); got != newSession {
t.Fatalf("current session = %p, want replacement %p", got, newSession)
}
if adapter.removeSession(session.AgentSessionID, oldSession) {
t.Fatal("stale removal unexpectedly detached replacement session")
}
}
func TestClaudeCodeSDKAdapterDoesNotRestoreInvalidOrOverwriteReplacementSession(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
invalidPrevious := &claudeSDKAdapterSession{pendingRequests: make(map[string]*pendingInteractiveRequest)}
adapter.storeSession(session.AgentSessionID, invalidPrevious)
adapter.removeSession(session.AgentSessionID, invalidPrevious)
if adapter.restorePreviousSession(session.AgentSessionID, invalidPrevious) {
t.Fatal("restored an invalid previous session")
}
validPrevious := &claudeSDKAdapterSession{pendingRequests: make(map[string]*pendingInteractiveRequest)}
replacement := &claudeSDKAdapterSession{pendingRequests: make(map[string]*pendingInteractiveRequest)}
adapter.storeSession(session.AgentSessionID, replacement)
if adapter.restorePreviousSession(session.AgentSessionID, validPrevious) {
t.Fatal("previous session overwrote a concurrent replacement")
}
if got := adapter.getSession(session.AgentSessionID); got != replacement {
t.Fatalf("current session = %p, want replacement %p", got, replacement)
}
}
func TestClaudeCodeSDKAdapterConfirmedReaderFailureSupersedesResolvingRequest(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
pending := &pendingInteractiveRequest{
agentSessionID: session.AgentSessionID,
turnID: "turn-approval",
requestID: "approval-1",
eventID: "event-1",
callID: "approval:approval-1",
callType: "approval",
name: "Bash",
toolName: "Bash",
response: make(chan pendingInteractiveResponse, 1),
}
adapterSession := &claudeSDKAdapterSession{
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
adapter.storeClaudeSDKPendingRequest(adapterSession, pending)
if _, claimed := pending.beginResolving(); !claimed {
t.Fatal("failed to move request to resolving")
}
events := adapter.claudeSDKPendingRequestFailureEvents(adapterSession, session, "turn-approval", errors.New("reader exited"))
if got := adapter.InteractiveDisposition(session, "turn-approval", "approval-1"); got != InteractiveDispositionSuperseded {
t.Fatalf("disposition = %q, want superseded after confirmed session death", got)
}
if len(events) != 2 || events[0].Type != activityshared.EventInteractionSuperseded || events[1].Type != activityshared.EventCallFailed {
t.Fatalf("failure events = %#v", events)
}
}
func TestClaudeCodeSDKAdapterKeepsSameRequestIDIsolatedAcrossTurns(t *testing.T) {
adapter := NewClaudeCodeSDKAdapter(nil)
session := standardTestSession(ProviderClaudeCode)
adapterSession := &claudeSDKAdapterSession{
pendingRequests: make(map[string]*pendingInteractiveRequest),
liveState: newClaudeSDKLiveState(),
}
adapter.storeSession(session.AgentSessionID, adapterSession)
for _, turnID := range []string{"turn-1", "turn-2"} {
adapter.beginClaudeSDKRootTurn(adapterSession, turnID, turnID)
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, turnID, claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": turnID,
"requestId": "shared-request",
"toolCallId": "tool-" + turnID,
"toolName": "Bash",
},
}); err != nil {
t.Fatalf("approval_requested(%s): %v", turnID, err)
}
}
first := adapter.getClaudeSDKPendingRequest(session.AgentSessionID, "turn-1", "shared-request")
second := adapter.getClaudeSDKPendingRequest(session.AgentSessionID, "turn-2", "shared-request")
if first == nil || second == nil || first == second {
t.Fatalf("pending requests = (%p, %p), want distinct turn-scoped requests", first, second)
}
resolved := adapter.claudeSDKInteractiveResolved(adapterSession, session, "turn-1", map[string]any{
"turnId": "turn-1", "requestId": "shared-request", "optionId": "allow",
})
if len(resolved) != 2 || resolved[0].Type != activityshared.EventCallCompleted {
t.Fatalf("resolved events = %#v", resolved)
}
if adapter.getClaudeSDKPendingRequest(session.AgentSessionID, "turn-1", "shared-request") != nil {
t.Fatal("resolved first-turn request remained live")
}
if got := adapter.getClaudeSDKPendingRequest(session.AgentSessionID, "turn-2", "shared-request"); got != second {
t.Fatalf("second-turn request = %p, want %p", got, second)
}
}
func TestClaudeCodeSDKAdapterApprovalResolvedUsesStoredTurnIDWhenEventTurnMissing(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-approval",
"provider-turn-approval",
)
if _, _, err := adapter.sidecarTurnEvents(adapterSession, session, "turn-approval", claudeSDKSidecarEvent{
Type: "approval_requested",
Payload: map[string]any{
"turnId": "turn-approval",
"requestId": "approval-1",
"toolCallId": "toolu-approval",
"toolName": "Bash",
"input": map[string]any{"command": "touch approval.txt"},
"options": []any{
map[string]any{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
},
},
}); err != nil {
t.Fatalf("approval_requested: %v", err)
}
resolved, terminal, err := adapter.sidecarTurnEvents(adapterSession, session, "", claudeSDKSidecarEvent{
Type: "approval_resolved",
Payload: map[string]any{
"requestId": "approval-1",
"optionId": "allow",
},
})
if err != nil || terminal {
t.Fatalf("approval_resolved err=%v terminal=%v", err, terminal)
}
if len(resolved) != 2 || resolved[0].Type != activityshared.EventCallCompleted {
t.Fatalf("resolved events = %#v, want completed approval", resolved)
}
if resolved[0].Payload.TurnID != "turn-approval" {
t.Fatalf("resolved turnID = %q, want stored pending turn id", resolved[0].Payload.TurnID)
}
}