Files
tutti/packages/agent/daemon/runtime/codex_appserver_adapter_interactive_test.go
T
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

316 lines
11 KiB
Go

package agentruntime
import (
"context"
"errors"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"sync"
"testing"
)
func TestCodexAppServerAdapterCommandApprovalApprove(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.commandApproval = true
var streamed []activityshared.Event
var streamedMu sync.Mutex
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "clean the build dir",
}}, "", "turn-local-1", func(next []activityshared.Event) {
streamedMu.Lock()
streamed = append(streamed, next...)
streamedMu.Unlock()
}, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.getPendingRequest(session.AgentSessionID, "turn-local-1", "approval-1") != nil
})
state := adapter.SessionState(session)
if state.PendingInteractive == nil || state.PendingInteractive.Kind != "approval" {
t.Fatalf("pending interactive = %#v, want approval", state.PendingInteractive)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := adapter.SubmitInteractive(canceled, session, SubmitInteractiveInput{
TurnID: "turn-local-1",
RequestID: "approval-1",
OptionID: "approve",
}); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled SubmitInteractive error = %v, want context canceled", err)
}
if pending := adapter.getPendingRequest(session.AgentSessionID, "turn-local-1", "approval-1"); pending == nil || pending.disposition() != pendingInteractiveRequestStatePending {
t.Fatalf("pending disposition after canceled submit = %v, want pending", runtimeInteractiveDisposition(pending))
}
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
TurnID: "turn-local-1",
RequestID: "approval-1",
OptionID: "approve",
})
if err != nil {
t.Fatalf("SubmitInteractive: %v", err)
}
if !result.Accepted || result.OptionID != "approve" {
t.Fatalf("submit result = %#v", result)
}
<-execDone
waitForCondition(t, func() bool {
transport.server.mu.Lock()
defer transport.server.mu.Unlock()
return transport.server.approvalResponse != nil
})
waitForCondition(t, func() bool {
streamedMu.Lock()
defer streamedMu.Unlock()
return len(eventsOfType(streamed, activityshared.EventCallCompleted)) > 0
})
transport.server.mu.Lock()
response := transport.server.approvalResponse
transport.server.mu.Unlock()
resultPayload := payloadObject(response["result"])
if asString(resultPayload["decision"]) != "accept" {
t.Fatalf("approval response = %#v, want decision accept", response)
}
streamedMu.Lock()
streamedCopy := append([]activityshared.Event(nil), streamed...)
streamedMu.Unlock()
var sawWaiting bool
var sawRequested bool
for _, event := range streamedCopy {
if event.Type == activityshared.EventCallStarted &&
asString(event.Payload.Metadata["callType"]) == "approval" {
sawWaiting = true
}
if event.Type == activityshared.EventInteractionRequested &&
event.Payload.Interaction != nil &&
event.Payload.Interaction.RequestID == "approval-1" {
sawRequested = true
}
}
if !sawWaiting {
t.Fatalf("approval call.started was not streamed: %#v", streamedCopy)
}
if !sawRequested {
t.Fatalf("approval interaction.requested was not streamed: %#v", streamedCopy)
}
if completedCalls := eventsOfType(streamedCopy, activityshared.EventCallCompleted); len(completedCalls) == 0 {
t.Fatalf("approval resolution missing call.completed: %#v", streamedCopy)
}
}
func TestCodexAppServerAdapterServerRequestResolvedFailsPendingApprovalWithoutClaimingSuccess(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.commandApproval = true
var streamed []activityshared.Event
var streamedMu sync.Mutex
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "clean the build dir",
}}, "", "turn-local-1", func(next []activityshared.Event) {
streamedMu.Lock()
streamed = append(streamed, next...)
streamedMu.Unlock()
}, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.getPendingRequest(session.AgentSessionID, "turn-local-1", "approval-1") != nil
})
if state := adapter.SessionState(session); state.PendingInteractive == nil {
t.Fatalf("pending interactive should be visible before serverRequest/resolved")
}
transport.conn.notify(appServerNotifyServerRequestResolved, map[string]any{
"threadId": "codex-thread-1",
"requestId": "approval-1",
})
waitForCondition(t, func() bool {
return adapter.InteractiveDisposition(session, "turn-local-1", "approval-1") == InteractiveDispositionSuperseded
})
// The provider resolved this request without ever telling tutti the
// decision, so we must not claim the underlying call succeeded (that
// previously rendered a phantom "completed" file-output card even
// though nothing was actually written). It should surface as failed.
waitForCondition(t, func() bool {
streamedMu.Lock()
defer streamedMu.Unlock()
return len(eventsOfType(streamed, activityshared.EventCallFailed)) > 0
})
streamedMu.Lock()
if completedCalls := eventsOfType(streamed, activityshared.EventCallCompleted); len(completedCalls) > 0 {
streamedMu.Unlock()
t.Fatalf("serverRequest/resolved with no known decision must not emit call.completed: %#v", completedCalls)
}
streamedMu.Unlock()
if state := adapter.SessionState(session); state.PendingInteractive != nil {
t.Fatalf("pending interactive after serverRequest/resolved = %#v, want nil", state.PendingInteractive)
}
transport.server.mu.Lock()
response := transport.server.approvalResponse
transport.server.mu.Unlock()
if response != nil {
t.Fatalf("out-of-band resolved request should not send approval response, got %#v", response)
}
transport.server.completePendingTurn()
events := <-execDone
if failedCalls := eventsOfType(events, activityshared.EventCallFailed); len(failedCalls) == 0 {
t.Fatalf("serverRequest/resolved missing call.failed: %#v", events)
}
if completedCalls := eventsOfType(events, activityshared.EventCallCompleted); len(completedCalls) > 0 {
t.Fatalf("serverRequest/resolved with no known decision must not emit call.completed: %#v", completedCalls)
}
}
func TestCodexAppServerAdapterUnsupportedServerRequestFailsCall(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
var streamed []activityshared.Event
var streamedMu sync.Mutex
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "run it",
}}, "", "turn-local-1", func(next []activityshared.Event) {
streamedMu.Lock()
streamed = append(streamed, next...)
streamedMu.Unlock()
}, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
transport.conn.sendJSON(map[string]any{
"id": "unsupported-1",
"method": "item/unknown/requestApproval",
"params": map[string]any{
"threadId": "codex-thread-1",
"turnId": "turn-1",
},
})
waitForCondition(t, func() bool {
streamedMu.Lock()
defer streamedMu.Unlock()
return len(eventsOfType(streamed, activityshared.EventCallFailed)) > 0
})
events := <-execDone
failedCalls := eventsOfType(events, activityshared.EventCallFailed)
if len(failedCalls) == 0 {
t.Fatalf("unsupported server request missing call.failed: %#v", events)
}
errorPayload := payloadObject(failedCalls[0].Payload.Metadata["error"])
if got := asString(errorPayload["method"]); got != "item/unknown/requestApproval" {
t.Fatalf("unsupported request error method = %q", got)
}
transport.server.mu.Lock()
response := transport.server.approvalResponse
transport.server.mu.Unlock()
if response == nil || payloadObject(response["error"]) == nil {
t.Fatalf("unsupported server request response = %#v, want JSON-RPC error", response)
}
}
func TestCodexAppServerAdapterCommandApprovalDecisionMapping(t *testing.T) {
t.Parallel()
tests := map[string]string{
"approve_for_session": "acceptForSession",
"deny": "decline",
"abort": "cancel",
}
for optionID, wantDecision := range tests {
adapter, transport, session := startedAppServerAdapter(t)
transport.server.commandApproval = true
execDone := make(chan struct{})
go func() {
_, _ = adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "run it",
}}, "", "turn-local-1", nil, nil)
close(execDone)
}()
waitForCondition(t, func() bool {
return adapter.getPendingRequest(session.AgentSessionID, "turn-local-1", "approval-1") != nil
})
if _, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
TurnID: "turn-local-1",
RequestID: "approval-1",
OptionID: optionID,
}); err != nil {
t.Fatalf("SubmitInteractive(%s): %v", optionID, err)
}
<-execDone
transport.server.mu.Lock()
response := transport.server.approvalResponse
transport.server.mu.Unlock()
if got := asString(payloadObject(response["result"])["decision"]); got != wantDecision {
t.Fatalf("option %q decision = %q, want %q", optionID, got, wantDecision)
}
}
}
func TestCodexAppServerAdapterRequestUserInput(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.userInputRequest = true
execDone := make(chan struct{})
go func() {
_, _ = adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "set up storage",
}}, "", "turn-local-1", nil, nil)
close(execDone)
}()
waitForCondition(t, func() bool {
return adapter.getPendingRequest(session.AgentSessionID, "turn-local-1", "question-1") != nil
})
state := adapter.SessionState(session)
if state.PendingInteractive == nil || state.PendingInteractive.Kind != "ask-user" {
t.Fatalf("pending interactive = %#v, want ask-user", state.PendingInteractive)
}
// Mirror the GUI contract: `answers` is a flat display list and the
// per-question map lives under answersByQuestionId.
if _, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
TurnID: "turn-local-1",
RequestID: "question-1",
Action: "submit",
Payload: map[string]any{
"answers": []any{"postgres"},
"answersByQuestionId": map[string]any{"q1": "postgres"},
},
}); err != nil {
t.Fatalf("SubmitInteractive: %v", err)
}
<-execDone
transport.server.mu.Lock()
response := transport.server.approvalResponse
transport.server.mu.Unlock()
answers := payloadObject(payloadObject(response["result"])["answers"])
entry := payloadObject(answers["q1"])
values, _ := entry["answers"].([]any)
if len(values) != 1 || asString(values[0]) != "postgres" {
t.Fatalf("user input response = %#v", response)
}
}