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

425 lines
14 KiB
Go

package agentruntime
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"time"
)
func TestNormalizedApprovalInputCanonicalizesNestedToolCall(t *testing.T) {
t.Parallel()
input := normalizedApprovalInput(map[string]any{
"toolCallId": "tool-1",
"title": "Edit",
"kind": "edit",
"status": "pending",
"rawInput": map[string]any{
"file_path": "/workspace/app.ts",
},
"content": []any{
map[string]any{
"type": "diff",
"path": "/workspace/app.ts",
"oldText": "before",
"newText": "after",
},
},
"providerDebug": map[string]any{"large": "discarded"},
}, []map[string]any{{"optionId": "allow", "kind": "allow_once"}}, "request-1", nil)
toolCall := payloadMap(input, "toolCall")
if toolCall["toolCallId"] != "tool-1" || toolCall["kind"] != "edit" {
t.Fatalf("toolCall = %#v, want canonical identity", toolCall)
}
for _, key := range []string{"rawInput", "content", "providerDebug"} {
if _, exists := toolCall[key]; exists {
t.Fatalf("toolCall.%s retained: %#v", key, toolCall)
}
}
want := map[string]any{
"file_path": "/workspace/app.ts",
"filePath": "/workspace/app.ts",
"oldString": "before",
"newString": "after",
}
for key, value := range want {
if !reflect.DeepEqual(input[key], value) {
t.Fatalf("input.%s = %#v, want %#v (input=%#v)", key, input[key], value, input)
}
}
if !reflect.DeepEqual(toolCall["input"], want) {
t.Fatalf("toolCall.input = %#v, want canonical display input %#v", toolCall["input"], want)
}
}
func TestNormalizedApprovalInputKeepsClaudeWebFetchURLInCanonicalToolCall(t *testing.T) {
t.Parallel()
input := normalizedApprovalInput(map[string]any{
"toolCallId": "tool-web-fetch",
"name": "WebFetch",
"input": map[string]any{
"url": "https://example.com/docs",
"prompt": "Summarize this page",
},
}, []map[string]any{{"optionId": "allow_always", "kind": "allow_always"}}, "request-web-fetch", nil)
if input["url"] != "https://example.com/docs" {
t.Fatalf("input.url = %#v, want WebFetch URL", input["url"])
}
toolCallInput := payloadMap(payloadMap(input, "toolCall"), "input")
if toolCallInput["url"] != "https://example.com/docs" || toolCallInput["prompt"] != "Summarize this page" {
t.Fatalf("toolCall.input = %#v, want complete WebFetch input", toolCallInput)
}
}
func TestNormalizedApprovalInputRecoversTopLevelURL(t *testing.T) {
t.Parallel()
input := normalizedApprovalInput(map[string]any{
"toolCallId": "tool-web-fetch",
"name": "WebFetch",
"url": "https://example.com/fallback",
"prompt": "Summarize the fallback page",
}, nil, "request-web-fetch", nil)
if input["url"] != "https://example.com/fallback" {
t.Fatalf("input.url = %#v, want top-level fallback URL", input["url"])
}
if got := payloadMap(payloadMap(input, "toolCall"), "input")["url"]; got != "https://example.com/fallback" {
t.Fatalf("toolCall.input.url = %#v, want top-level fallback URL", got)
}
if got := payloadMap(payloadMap(input, "toolCall"), "input")["prompt"]; got != "Summarize the fallback page" {
t.Fatalf("toolCall.input.prompt = %#v, want top-level fallback prompt", got)
}
}
func TestNormalizedApprovalInputRecoversKnownInputWebAliases(t *testing.T) {
t.Parallel()
input := normalizedApprovalInput(map[string]any{
"toolCallId": "tool-web-fetch",
"name": "WebFetch",
}, nil, "request-web-fetch", map[string]any{
"uri": "https://example.com/known-input",
"instruction": "Extract the compatibility requirements",
})
toolCallInput := payloadMap(payloadMap(input, "toolCall"), "input")
if toolCallInput["uri"] != "https://example.com/known-input" || toolCallInput["instruction"] != "Extract the compatibility requirements" {
t.Fatalf("toolCall.input = %#v, want known-input web aliases", toolCallInput)
}
}
func TestPersistedApprovalInputDoesNotDuplicateDisplayFields(t *testing.T) {
input := normalizedApprovalInput(map[string]any{
"toolCallId": "tool-web-fetch",
"name": "WebFetch",
"input": map[string]any{
"url": "https://example.com/docs",
},
}, nil, "request-web-fetch", nil)
persisted := persistedInteractionInput("approval", input)
if _, duplicated := persisted["url"]; duplicated {
t.Fatalf("persisted approval duplicated root display input: %#v", persisted)
}
if got := payloadMap(payloadMap(persisted, "toolCall"), "input")["url"]; got != "https://example.com/docs" {
t.Fatalf("persisted toolCall.input.url = %#v", got)
}
}
func TestPendingInteractionTransitionProjectsSelfDescribingActions(t *testing.T) {
t.Parallel()
pending := &pendingInteractiveRequest{
requestID: "request-1", turnID: "turn-1", kind: "approval", toolName: "Approval",
options: []map[string]any{
{"optionId": "approve", "name": "Approve", "kind": "allow_once"},
{"optionId": "approve_for_session", "name": "Approve for session", "kind": "allow_always"},
{"optionId": "deny", "name": "Deny", "kind": "reject_once"},
{"optionId": "abort", "name": "Deny and stop", "kind": "reject_always"},
{"optionId": "provider-specific", "label": "Provider specific", "kind": "custom"},
},
}
transition := pendingInteractionTransition("turn-1", pending)
actions, ok := transition.Metadata["actions"].([]any)
if !ok || len(actions) != 5 {
t.Fatalf("actions = %#v", transition.Metadata["actions"])
}
wantSemantics := []string{"approve", "approve_always", "deny", "deny_and_stop", ""}
for index, want := range wantSemantics {
action, ok := actions[index].(map[string]any)
if !ok || action["semantic"] != want {
t.Fatalf("action[%d] = %#v, want semantic %q", index, actions[index], want)
}
if action["id"] == "" || action["label"] == "" {
t.Fatalf("action[%d] = %#v, want id and label", index, action)
}
}
}
func TestInteractiveApprovalOptionIDAcceptsActionFallback(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
input SubmitInteractiveInput
want string
}{
{name: "option wins", input: SubmitInteractiveInput{OptionID: "explicit", Action: "action", Payload: map[string]any{"optionId": "payload"}}, want: "explicit"},
{name: "action fallback", input: SubmitInteractiveInput{Action: "approve"}, want: "approve"},
{name: "payload fallback", input: SubmitInteractiveInput{Payload: map[string]any{"optionId": "deny"}}, want: "deny"},
} {
t.Run(test.name, func(t *testing.T) {
if got := interactiveApprovalOptionID(test.input); got != test.want {
t.Fatalf("interactiveApprovalOptionID() = %q, want %q", got, test.want)
}
})
}
}
func TestCodexAndClaudeApprovalOptionsProjectSemantics(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
options []map[string]any
}{
{name: "codex", options: appServerApprovalOptions(appServerMethodCommandApproval)},
{name: "claude-code", options: claudeSDKInteractiveOptions(nil, map[string]any{"name": "Bash"})},
} {
t.Run(test.name, func(t *testing.T) {
actions := normalizedInteractionActions(test.options)
if len(actions) == 0 {
t.Fatal("actions are empty")
}
semantics := map[string]bool{}
for _, raw := range actions {
action := raw.(map[string]any)
semantic, _ := action["semantic"].(string)
if semantic == "" {
t.Fatalf("action = %#v, want provider approval semantic", action)
}
semantics[semantic] = true
}
if !semantics["approve"] || !semantics["deny"] {
t.Fatalf("semantics = %#v, want approve and deny", semantics)
}
})
}
}
func TestInteractiveAdaptersClassifyMalformedResponsesConsistently(t *testing.T) {
t.Parallel()
session := standardTestSession(ProviderCodex)
adapters := []struct {
name string
submit func() error
}{
{
name: "codex",
submit: func() error {
_, err := (&CodexAppServerAdapter{}).SubmitInteractive(
context.Background(), session, SubmitInteractiveInput{},
)
return err
},
},
{
name: "standard-acp",
submit: func() error {
_, err := (&standardACPAdapter{}).SubmitInteractive(
context.Background(), session, SubmitInteractiveInput{},
)
return err
},
},
{
name: "claude-sdk",
submit: func() error {
_, err := NewClaudeCodeSDKAdapter(nil).SubmitInteractive(
context.Background(), session, SubmitInteractiveInput{},
)
return err
},
},
}
for _, adapter := range adapters {
t.Run(adapter.name, func(t *testing.T) {
if err := adapter.submit(); !errors.Is(err, ErrInteractiveResponseInvalid) {
t.Fatalf("SubmitInteractive error = %v, want ErrInteractiveResponseInvalid", err)
}
})
}
}
func TestPendingInteractiveRequestAllowsOnlyOnePendingToResolvingClaim(t *testing.T) {
t.Parallel()
pending := &pendingInteractiveRequest{requestID: "request-1"}
type claimResult struct {
state pendingInteractiveRequestState
claimed bool
}
results := make(chan claimResult, 2)
for range 2 {
go func() {
state, claimed := pending.beginResolving()
results <- claimResult{state: state, claimed: claimed}
}()
}
first := <-results
second := <-results
claims := 0
for _, result := range []claimResult{first, second} {
if result.state != pendingInteractiveRequestStateResolving {
t.Fatalf("claim state = %q, want resolving", result.state)
}
if result.claimed {
claims++
}
}
if claims != 1 {
t.Fatalf("claims = [%#v %#v], want exactly one owner", first, second)
}
if pending.disposition() != pendingInteractiveRequestStateResolving {
t.Fatalf("disposition = %q, want resolving", pending.disposition())
}
pending.finish(pendingInteractiveRequestStateAnswered)
if state, err := pending.waitForDisposition(context.Background()); err != nil || state != pendingInteractiveRequestStateAnswered {
t.Fatalf("terminal state=%q error=%v", state, err)
}
if state, claimed := pending.beginResolving(); claimed || state != pendingInteractiveRequestStateAnswered {
t.Fatalf("terminal beginResolving = (%q, %v), want answered without claim", state, claimed)
}
}
func TestPendingInteractiveRequestDispatchResponseIsAtomic(t *testing.T) {
t.Parallel()
pending := &pendingInteractiveRequest{
requestID: "request-1",
response: make(chan pendingInteractiveResponse, 1),
}
type dispatchResult struct {
state pendingInteractiveRequestState
err error
}
results := make(chan dispatchResult, 2)
for _, optionID := range []string{"first", "second"} {
go func() {
state, err := pending.dispatchResponse(context.Background(), pendingInteractiveResponse{optionID: optionID})
results <- dispatchResult{state: state, err: err}
}()
}
first := <-results
second := <-results
successes := 0
for _, result := range []dispatchResult{first, second} {
if result.err == nil {
successes++
if result.state != pendingInteractiveRequestStateResolving {
t.Fatalf("successful dispatch state = %q, want resolving", result.state)
}
}
}
if successes != 1 {
t.Fatalf("dispatch results = [%#v %#v], want exactly one success", first, second)
}
if len(pending.response) != 1 {
t.Fatalf("response channel length = %d, want 1", len(pending.response))
}
}
func TestPendingInteractiveRequestCanceledDispatchRemainsPending(t *testing.T) {
t.Parallel()
pending := &pendingInteractiveRequest{
requestID: "request-1",
response: make(chan pendingInteractiveResponse, 1),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
state, err := pending.dispatchResponse(ctx, pendingInteractiveResponse{optionID: "allow"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("dispatch error = %v, want context canceled", err)
}
if state != pendingInteractiveRequestStatePending || pending.disposition() != pendingInteractiveRequestStatePending {
t.Fatalf("state = %q disposition = %q, want pending", state, pending.disposition())
}
if len(pending.response) != 0 {
t.Fatalf("response channel length = %d, want 0", len(pending.response))
}
}
func TestPendingInteractiveRequestReleaseResolvingDoesNotOverwriteTerminal(t *testing.T) {
t.Parallel()
pending := &pendingInteractiveRequest{requestID: "request-1"}
if _, claimed := pending.beginResolving(); !claimed {
t.Fatal("beginResolving did not claim pending request")
}
if !pending.releaseResolving() || pending.disposition() != pendingInteractiveRequestStatePending {
t.Fatalf("release disposition = %q, want pending", pending.disposition())
}
if _, claimed := pending.beginResolving(); !claimed {
t.Fatal("beginResolving did not reclaim released request")
}
pending.finish(pendingInteractiveRequestStateSuperseded)
if pending.releaseResolving() {
t.Fatal("releaseResolving overwrote terminal state")
}
if pending.disposition() != pendingInteractiveRequestStateSuperseded {
t.Fatalf("terminal disposition = %q, want superseded", pending.disposition())
}
}
func TestPendingInteractiveRequestSupersedeUnblocksProviderWaiter(t *testing.T) {
t.Parallel()
pending := &pendingInteractiveRequest{
requestID: "request-1",
response: make(chan pendingInteractiveResponse, 1),
}
result := make(chan error, 1)
go func() {
_, err := pending.wait(context.Background())
result <- err
}()
pending.supersede(errPermissionRequestCanceled)
select {
case err := <-result:
if !errors.Is(err, errPermissionRequestCanceled) {
t.Fatalf("wait error = %v, want permission request canceled", err)
}
case <-time.After(time.Second):
t.Fatal("terminal request left provider waiter blocked")
}
}
func TestTerminalInteractiveDispositionStoreUsesFullIdentityAndIsBounded(t *testing.T) {
t.Parallel()
store := terminalInteractiveDispositionStore{}
store.put(newInteractiveRequestKey("session-1", "turn-1", "request-1"), InteractiveDispositionAnswered)
store.put(newInteractiveRequestKey("session-1", "turn-1", "request-1"), InteractiveDispositionSuperseded)
store.put(newInteractiveRequestKey("session-1", "turn-2", "request-1"), InteractiveDispositionSuperseded)
if got := store.get(newInteractiveRequestKey("session-1", "turn-1", "request-1")); got != InteractiveDispositionAnswered {
t.Fatalf("turn-1 disposition = %q, want answered", got)
}
if got := store.get(newInteractiveRequestKey("session-1", "turn-2", "request-1")); got != InteractiveDispositionSuperseded {
t.Fatalf("turn-2 disposition = %q, want superseded", got)
}
for index := 0; index < terminalInteractiveDispositionCapacity; index++ {
store.put(newInteractiveRequestKey("session-2", "turn", fmt.Sprintf("request-%d", index)), InteractiveDispositionAnswered)
}
if len(store.entries) != terminalInteractiveDispositionCapacity {
t.Fatalf("terminal entries = %d, want %d", len(store.entries), terminalInteractiveDispositionCapacity)
}
if got := store.get(newInteractiveRequestKey("session-1", "turn-1", "request-1")); got != InteractiveDispositionUnknown {
t.Fatalf("oldest disposition = %q, want bounded eviction", got)
}
}