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

525 lines
17 KiB
Go

package agentruntime
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func TestCodexAppServerAdapterExecSteersActiveTurn(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
execDone := make(chan struct{})
go func() {
_, _ = adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "long task",
}}, "", "turn-local-1", nil, nil)
close(execDone)
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
dispatches := make(chan ProviderDispatchResult, 1)
events, err := adapter.ExecWithProviderAcceptance(
context.Background(),
session,
[]PromptContentBlock{{
Type: "text", Text: "also update the docs",
}},
"",
"turn-local-2",
nil,
nil,
func(result ProviderDispatchResult) { dispatches <- result },
nil,
)
if err != nil {
t.Fatalf("steer Exec: %v", err)
}
dispatch := <-dispatches
if dispatch.Disposition != DispatchDispositionAppliedWithoutProviderTurn ||
dispatch.Acceptance != nil {
t.Fatalf("steer dispatch = %#v", dispatch)
}
steer := appServerRequestParams(t, transport.conn, appServerMethodTurnSteer)
if asString(steer["expectedTurnId"]) != "turn-1" {
t.Fatalf("turn/steer params = %#v", steer)
}
messages := eventsOfType(events, activityshared.EventMessageAppended)
if len(messages) != 1 || messages[0].Payload.Role != activityshared.MessageRoleUser {
t.Fatalf("steer events = %#v, want single user message", events)
}
// The controller relies on this marker (turnSteeredIntoActiveTurn) to
// settle the steer submission's turn record: no terminal event will ever
// arrive for a steered turn id.
if steered, ok := messages[0].Payload.Metadata["steered"].(bool); !ok || !steered {
t.Fatalf("steer message metadata = %#v, want steered=true", messages[0].Payload.Metadata)
}
if guidance, ok := messages[0].Payload.Metadata["guidance"].(bool); !ok || !guidance {
t.Fatalf("steer message metadata = %#v, want guidance=true", messages[0].Payload.Metadata)
}
transport.server.completePendingTurn()
select {
case <-execDone:
case <-time.After(5 * time.Second):
t.Fatalf("original Exec did not finish")
}
}
func TestCodexAppServerAdapterGuideActiveTurnUsesTurnSteer(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
execDone := make(chan struct{})
go func() {
_, _ = adapter.Exec(context.Background(), session, textPrompt("long task"), "", "turn-local-1", nil, nil)
close(execDone)
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
returned, err := adapter.GuideActiveTurn(
context.Background(), session, textPrompt("guide current turn"), "", "turn-local-1", nil, nil,
)
if err != nil {
t.Fatalf("GuideActiveTurn: %v", err)
}
steer := appServerRequestParams(t, transport.conn, appServerMethodTurnSteer)
if asString(steer["threadId"]) != "codex-thread-1" || asString(steer["expectedTurnId"]) != "turn-1" {
t.Fatalf("turn/steer params = %#v", steer)
}
if interrupts := appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt); len(interrupts) != 0 {
t.Fatalf("turn/interrupt requests = %#v, want non-interrupting guidance", interrupts)
}
if starts := appServerRequestParamsList(t, transport.conn, appServerMethodTurnStart); len(starts) != 1 {
t.Fatalf("turn/start requests = %#v, want only the original provider turn", starts)
}
messages := eventsOfType(returned, activityshared.EventMessageAppended)
if len(messages) != 1 || messages[0].Payload.Role != activityshared.MessageRoleUser {
t.Fatalf("guidance events = %#v, want one user message", returned)
}
if messages[0].Payload.Metadata["guidance"] != true || messages[0].Payload.Metadata["steered"] != true {
t.Fatalf("guidance metadata = %#v, want guidance+steered", messages[0].Payload.Metadata)
}
transport.server.completePendingTurn()
select {
case <-execDone:
case <-time.After(5 * time.Second):
t.Fatalf("original Exec did not finish")
}
}
func TestCodexAppServerAdapterGuidancePreflightFailureIsNotDispatched(t *testing.T) {
adapter := NewCodexAppServerAdapter(nil)
dispatches := make(chan ProviderDispatchResult, 1)
_, err := adapter.GuideActiveTurnWithProviderDispatch(
t.Context(),
standardTestSession(ProviderCodex),
textPrompt("guide current turn"),
"",
"turn-guidance",
nil,
nil,
func(result ProviderDispatchResult) { dispatches <- result },
)
if !errors.Is(err, ErrSessionDisconnected) {
t.Fatalf("GuideActiveTurn error = %v, want ErrSessionDisconnected", err)
}
if dispatch := <-dispatches; dispatch.Disposition != DispatchDispositionNotDispatched {
t.Fatalf("guidance dispatch = %#v, want not dispatched", dispatch)
}
}
func TestCodexAppServerAdapterGuideMaterializesRemoteImageAtProviderBoundary(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
imageURL, materializer := testRemotePromptImageMaterializer(t)
adapter.promptImageMaterializer = materializer
transport.server.holdTurn = true
execDone := make(chan struct{})
go func() {
_, _ = adapter.Exec(context.Background(), session, textPrompt("long task"), "", "turn-local-1", nil, nil)
close(execDone)
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
if _, err := adapter.GuideActiveTurn(context.Background(), session, []PromptContentBlock{
{Type: "text", Text: "use this screenshot"},
{Type: "image", MimeType: "image/png", URL: imageURL},
}, "", "turn-local-1", nil, nil); err != nil {
t.Fatalf("GuideActiveTurn: %v", err)
}
steer := appServerRequestParams(t, transport.conn, appServerMethodTurnSteer)
input, _ := steer["input"].([]any)
if len(input) != 2 {
t.Fatalf("guidance turn/steer input = %#v, want text+image", steer["input"])
}
image := payloadObject(input[1])
if got := asString(image["url"]); got != "data:image/png;base64,aGk=" {
t.Fatalf("guidance turn/steer image URL = %q, want inline data URL", got)
}
if interrupts := appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt); len(interrupts) != 0 {
t.Fatalf("turn/interrupt requests = %#v, want non-interrupting guidance", interrupts)
}
if starts := appServerRequestParamsList(t, transport.conn, appServerMethodTurnStart); len(starts) != 1 {
t.Fatalf("turn/start requests = %#v, want only the original provider turn", starts)
}
transport.server.completePendingTurn()
select {
case <-execDone:
case <-time.After(5 * time.Second):
t.Fatal("original Exec did not finish after guidance steer")
}
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == ""
})
}
func TestCodexAppServerAdapterGuidanceStartsProviderContinuationOnSameRootTurn(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
if _, err := adapter.Exec(context.Background(), session, textPrompt("delegate work"), "", "root-turn-1", nil, nil); err != nil {
t.Fatalf("initial Exec: %v", err)
}
if got := adapter.sessionActiveTurnID(session.AgentSessionID); got != "" {
t.Fatalf("provider turn id after completion = %q, want empty", got)
}
_, childEvents := adapter.rememberAppServerChildThreads(
session,
session.ProviderSessionID,
session.AgentSessionID,
"root-turn-1",
session.AgentSessionID,
"root-turn-1",
map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"tool": "spawnAgent",
"receiverThreadIds": []any{"child-thread-1"},
},
)
if len(childEvents) != 1 || childEvents[0].Type != activityshared.EventSessionStarted {
t.Fatalf("child creation events = %#v", childEvents)
}
var mu sync.Mutex
var streamed []activityshared.Event
returned, err := adapter.GuideActiveTurn(
context.Background(),
session,
textPrompt("include the child's findings"),
"",
"root-turn-1",
func(events []activityshared.Event) {
mu.Lock()
streamed = append(streamed, events...)
mu.Unlock()
},
nil,
)
if err != nil {
t.Fatalf("GuideActiveTurn: %v", err)
}
if len(returned) != 1 || returned[0].Type != activityshared.EventRootProviderTurnStarted ||
returned[0].Payload.TurnID != "root-turn-1" {
t.Fatalf("guidance return events = %#v, want provisional provider continuation", returned)
}
waitForCondition(t, func() bool {
mu.Lock()
defer mu.Unlock()
for _, event := range streamed {
if event.Type == activityshared.EventRootProviderTurnCompleted && event.Payload.TurnID == "root-turn-1" {
return true
}
}
return false
})
if requests := appServerRequestParamsList(t, transport.conn, appServerMethodTurnStart); len(requests) < 2 {
t.Fatalf("turn/start requests = %#v, want initial turn and same-root continuation", requests)
}
}
func TestCodexAppServerAdapterGuidanceContinuationMaterializationFailureClosesProvisionalProviderTurn(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
if _, err := adapter.Exec(context.Background(), session, textPrompt("delegate work"), "", "root-turn-1", nil, nil); err != nil {
t.Fatalf("initial Exec: %v", err)
}
_, childEvents := adapter.rememberAppServerChildThreads(
session,
session.ProviderSessionID,
session.AgentSessionID,
"root-turn-1",
session.AgentSessionID,
"root-turn-1",
map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"tool": "spawnAgent",
"receiverThreadIds": []any{"child-thread-1"},
},
)
if len(childEvents) != 1 || childEvents[0].Type != activityshared.EventSessionStarted {
t.Fatalf("child creation events = %#v", childEvents)
}
adapter.promptImageMaterializer = func(context.Context, []PromptContentBlock) ([]PromptContentBlock, error) {
return nil, errors.New("signed image expired")
}
var mu sync.Mutex
var streamed []activityshared.Event
returned, err := adapter.GuideActiveTurn(
context.Background(),
session,
[]PromptContentBlock{
{Type: "text", Text: "include this image"},
{Type: "image", MimeType: "image/png", URL: "https://public.example/image.png"},
},
"",
"root-turn-1",
func(events []activityshared.Event) {
mu.Lock()
streamed = append(streamed, events...)
mu.Unlock()
},
nil,
)
if err != nil {
t.Fatalf("GuideActiveTurn: %v", err)
}
if len(returned) != 1 || returned[0].Type != activityshared.EventRootProviderTurnStarted {
t.Fatalf("guidance return events = %#v, want provisional provider continuation", returned)
}
attemptID := returned[0].Payload.ProviderTurnID
waitForCondition(t, func() bool {
mu.Lock()
defer mu.Unlock()
for _, event := range streamed {
if event.Type == activityshared.EventRootProviderTurnCompleted &&
event.Payload.ProviderTurnID == attemptID &&
event.Payload.TurnOutcome == string(activityshared.TurnOutcomeFailed) {
return true
}
}
return false
})
if requests := appServerRequestParamsList(t, transport.conn, appServerMethodTurnStart); len(requests) != 1 {
t.Fatalf("turn/start requests = %#v, want no continuation provider request", requests)
}
}
func TestCodexAppServerAdapterConcurrentGuidancePublishesSingleProvisionalContinuation(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
if _, err := adapter.Exec(context.Background(), session, textPrompt("delegate work"), "", "root-turn-1", nil, nil); err != nil {
t.Fatalf("initial Exec: %v", err)
}
_, childEvents := adapter.rememberAppServerChildThreads(
session,
session.ProviderSessionID,
session.AgentSessionID,
"root-turn-1",
session.AgentSessionID,
"root-turn-1",
map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"tool": "spawnAgent",
"receiverThreadIds": []any{"child-thread-1"},
},
)
if len(childEvents) != 1 || childEvents[0].Type != activityshared.EventSessionStarted {
t.Fatalf("child creation events = %#v", childEvents)
}
transport.server.holdTurn = true
type guidanceCallResult struct {
events []activityshared.Event
err error
}
var streamedMu sync.Mutex
var streamed []activityshared.Event
emit := func(events []activityshared.Event) {
streamedMu.Lock()
defer streamedMu.Unlock()
streamed = append(streamed, events...)
}
start := make(chan struct{})
results := make(chan guidanceCallResult, 2)
for index := 0; index < 2; index++ {
index := index
go func() {
<-start
events, err := adapter.GuideActiveTurn(
context.Background(),
session,
textPrompt(fmt.Sprintf("guidance %d", index)),
"",
"root-turn-1",
emit,
nil,
)
results <- guidanceCallResult{events: events, err: err}
}()
}
close(start)
provisionalStarts := 0
for index := 0; index < 2; index++ {
select {
case result := <-results:
for _, event := range result.events {
if event.Type == activityshared.EventRootProviderTurnStarted &&
strings.HasPrefix(event.Payload.ProviderTurnID, "continuation:") {
provisionalStarts++
}
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent guidance")
}
}
if provisionalStarts != 1 {
t.Fatalf("provisional provider starts = %d, want exactly 1", provisionalStarts)
}
waitForCondition(t, func() bool {
streamedMu.Lock()
defer streamedMu.Unlock()
started := 0
for _, event := range streamed {
if event.Type == activityshared.EventTurnStarted {
started++
}
}
return started >= 1
})
streamedMu.Lock()
started := 0
for _, event := range streamed {
if event.Type == activityshared.EventTurnStarted {
started++
}
}
streamedMu.Unlock()
if started != 1 {
t.Fatalf("streamed turn starts = %d, want exactly 1 admitted continuation", started)
}
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) != ""
})
transport.server.completePendingTurn()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == ""
})
}
func TestCodexAppServerAdapterRejectedContinuationAdmissionEmitsNoTurnStart(t *testing.T) {
t.Parallel()
adapter, _, session := startedAppServerAdapter(t)
blockingTurn := &codexAppServerActiveTurn{turnID: "blocking-turn"}
if !adapter.beginActiveTurn(session.AgentSessionID, blockingTurn) {
t.Fatal("failed to reserve blocking turn")
}
defer adapter.endActiveTurn(session.AgentSessionID, blockingTurn)
var streamed []activityshared.Event
continuation := newCodexGuidanceContinuationAdmission("continuation:rejected")
events, err := adapter.execBlocking(
context.Background(),
session,
textPrompt("guidance"),
"",
"root-turn-1",
func(next []activityshared.Event) {
streamed = append(streamed, next...)
},
nil,
codexTurnExecOptions{continuation: continuation},
)
if !errors.Is(err, ErrSessionActiveTurn) {
t.Fatalf("execBlocking error = %v, want ErrSessionActiveTurn", err)
}
if admittedErr := <-continuation.admitted; !errors.Is(admittedErr, ErrSessionActiveTurn) {
t.Fatalf("admission error = %v, want ErrSessionActiveTurn", admittedErr)
}
if len(events) != 0 || len(streamed) != 0 {
t.Fatalf("rejected continuation events = %#v, streamed = %#v; want none", events, streamed)
}
}
func TestCodexAppServerAdapterSteerRoutesAgentTargetMention(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
execDone := make(chan struct{})
go func() {
_, _ = adapter.Exec(context.Background(), session, textPrompt("long task"), "", "turn-local-1", nil, nil)
close(execDone)
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
prompt := "让 [@Codex](mention://agent-target/local:codex?workspaceId=workspace-1) 来看下"
events, err := adapter.Exec(context.Background(), session, textPrompt(prompt), "", "turn-agent-target-steer", nil, nil)
if err != nil {
t.Fatalf("steer Exec: %v", err)
}
steer := appServerRequestParams(t, transport.conn, appServerMethodTurnSteer)
if asString(steer["expectedTurnId"]) != "turn-1" {
t.Fatalf("turn/steer params = %#v", steer)
}
input, _ := steer["input"].([]any)
if len(input) != 2 {
t.Fatalf("turn/steer input = %#v, want user prompt plus internal routing prompt", steer["input"])
}
first, _ := input[0].(map[string]any)
if asString(first["text"]) != prompt {
t.Fatalf("turn/steer user text = %q, want %q", asString(first["text"]), prompt)
}
last, _ := input[len(input)-1].(map[string]any)
if asString(last["text"]) != tuttiAgentMentionRoutingReminder {
t.Fatalf("turn/steer routing text = %q, want %q", asString(last["text"]), tuttiAgentMentionRoutingReminder)
}
userContent := firstUserMessageContent(t, events)
if userContent != prompt || strings.Contains(userContent, "system-reminder") {
t.Fatalf("user activity content = %q, want original prompt only", userContent)
}
transport.server.completePendingTurn()
select {
case <-execDone:
case <-time.After(5 * time.Second):
t.Fatalf("original Exec did not finish")
}
}