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

714 lines
26 KiB
Go

package agentruntime
import (
"context"
"testing"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func TestCodexAppServerAdapterCancelInterruptsActiveTurn(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "long task",
}}, "", "turn-local-1", nil, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
if _, err := adapter.Cancel(context.Background(), session, "user requested"); err != nil {
t.Fatalf("Cancel: %v", err)
}
interrupt := appServerRequestParams(t, transport.conn, appServerMethodTurnInterrupt)
if asString(interrupt["threadId"]) != "codex-thread-1" || asString(interrupt["turnId"]) != "turn-1" {
t.Fatalf("turn/interrupt params = %#v", interrupt)
}
select {
case events := <-execDone:
if completed := eventsOfType(events, activityshared.EventRootProviderTurnCompleted); len(completed) != 1 ||
completed[0].Payload.TurnOutcome != string(activityshared.TurnOutcomeCanceled) {
t.Fatalf("expected interrupted turn outcome, got %#v", events)
}
case <-time.After(5 * time.Second):
t.Fatalf("Exec did not finish after interrupt")
}
}
func TestCodexAppServerAdapterCancelInterruptsLinkedChildThreads(t *testing.T) {
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
_, _ = adapter.rememberAppServerChildThreads(session, "codex-thread-1", session.AgentSessionID, "turn-local-1", session.AgentSessionID, "turn-local-1", map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"receiverThreadIds": []any{"child-thread-1", "child-thread-2"},
})
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "long parent task",
}}, "", "turn-local-1", nil, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
childOne, _ := adapter.appServerChildThread(session.AgentSessionID, "child-thread-1")
childTwo, _ := adapter.appServerChildThread(session.AgentSessionID, "child-thread-2")
cancelResult, err := adapter.CancelTargets(context.Background(), session, []CancelTarget{
{AgentSessionID: childOne.agentSessionID, TurnID: childOne.turnID},
{AgentSessionID: childTwo.agentSessionID, TurnID: childTwo.turnID},
{AgentSessionID: session.AgentSessionID, TurnID: "turn-local-1"},
}, "user requested")
if err != nil {
t.Fatalf("Cancel: %v", err)
}
if len(cancelResult.Events) != 2 {
t.Fatalf("cancel events = %#v, want two child cancel-request transitions", cancelResult.Events)
}
for _, event := range cancelResult.Events {
if event.Type != activityshared.EventTurnUpdated || event.Payload.Metadata["cancelRequested"] != true {
t.Fatalf("cancel event = %#v, want non-terminal cancel request", event)
}
}
if len(cancelResult.ConfirmedTargets) != 3 {
t.Fatalf("confirmed targets = %#v, want both children and root", cancelResult.ConfirmedTargets)
}
waitForCondition(t, func() bool {
return len(appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt)) == 3
})
requests := appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt)
byThread := map[string]map[string]any{}
for _, request := range requests {
byThread[asString(request["threadId"])] = request
}
if asString(byThread["codex-thread-1"]["turnId"]) != "turn-1" {
t.Fatalf("parent interrupt requests = %#v", requests)
}
if asString(byThread["child-thread-1"]["turnId"]) != "" ||
asString(byThread["child-thread-2"]["turnId"]) != "" {
t.Fatalf("child interrupt requests = %#v, want empty turnId startup interrupts", requests)
}
select {
case <-execDone:
case <-time.After(5 * time.Second):
t.Fatalf("Exec did not finish after interrupt")
}
}
func TestControllerCodexCancelInterruptsRootAndKnownChildBeforeLocalCleanup(t *testing.T) {
t.Parallel()
transport := newScriptedAppServerTransport()
transport.server.holdTurn = true
adapter := NewCodexAppServerAdapter(transport)
controller := NewController([]Adapter{adapter}, nil)
started, err := controller.Start(context.Background(), StartInput{
RoomID: "room-1",
AgentSessionID: "agent-session-1",
Provider: ProviderCodex,
CWD: "/workspace/room-1",
Title: "Codex",
})
if err != nil {
t.Fatalf("Start: %v", err)
}
execResult, err := controller.Exec(context.Background(), ExecInput{
RoomID: started.Session.RoomID,
AgentSessionID: started.Session.AgentSessionID,
Content: textPrompt("spawn children serially"),
})
if err != nil {
t.Fatalf("Exec: %v", err)
}
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(started.Session.AgentSessionID) == "turn-1"
})
_, _ = adapter.rememberAppServerChildThreads(
started.Session,
started.Session.ProviderSessionID,
started.Session.AgentSessionID,
execResult.TurnID,
started.Session.AgentSessionID,
execResult.TurnID,
map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"receiverThreadIds": []any{"child-thread-1"},
},
)
child, ok := adapter.appServerChildThread(started.Session.AgentSessionID, "child-thread-1")
if !ok {
t.Fatal("known child thread was not registered")
}
cancelResult, err := controller.Cancel(context.Background(), CancelInput{
RoomID: started.Session.RoomID,
RootAgentSessionID: started.Session.AgentSessionID,
Targets: []CancelTarget{
{AgentSessionID: child.agentSessionID, TurnID: child.turnID},
{AgentSessionID: started.Session.AgentSessionID, TurnID: execResult.TurnID},
},
Reason: "user requested",
})
if err != nil {
t.Fatalf("Cancel: %v", err)
}
if len(cancelResult.ConfirmedTargets) != 2 {
t.Fatalf("confirmed targets = %#v, want known child and root", cancelResult.ConfirmedTargets)
}
requests := appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt)
if len(requests) != 2 {
t.Fatalf("turn/interrupt requests = %#v, want child and root", requests)
}
if asString(requests[0]["threadId"]) != started.Session.ProviderSessionID ||
asString(requests[1]["threadId"]) != "child-thread-1" {
t.Fatalf("turn/interrupt order = %#v, want root before known child", requests)
}
byThread := map[string]map[string]any{}
for _, request := range requests {
byThread[asString(request["threadId"])] = request
}
if asString(byThread["child-thread-1"]["turnId"]) != "" {
t.Fatalf("child interrupt = %#v, want startup interrupt without turn id", byThread["child-thread-1"])
}
if asString(byThread[started.Session.ProviderSessionID]["turnId"]) != "turn-1" {
t.Fatalf("root interrupt = %#v, want live provider turn", byThread[started.Session.ProviderSessionID])
}
}
func TestCodexAppServerAdapterCancelAfterTurnCompletedStillMarksChildrenCanceled(t *testing.T) {
adapter, transport, session := startedAppServerAdapter(t)
_, _ = adapter.rememberAppServerChildThreads(session, "codex-thread-1", session.AgentSessionID, "turn-local-1", session.AgentSessionID, "turn-local-1", map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"receiverThreadIds": []any{"child-thread-1"},
})
// Run a turn to completion so no active turn remains, but children keep
// running (spawned agents outlive the parent turn).
if _, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "parent task",
}}, "", "turn-local-1", nil, nil); err != nil {
t.Fatalf("Exec: %v", err)
}
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == ""
})
if got := adapter.sessionActiveTurnID(session.AgentSessionID); got != "" {
t.Fatalf("active turn id after completion = %q, want empty", got)
}
child, _ := adapter.appServerChildThread(session.AgentSessionID, "child-thread-1")
cancelResult, err := adapter.CancelTargets(context.Background(), session, []CancelTarget{
{AgentSessionID: child.agentSessionID, TurnID: child.turnID},
}, "user requested")
if err != nil {
t.Fatalf("Cancel: %v", err)
}
if len(cancelResult.Events) != 1 || cancelResult.Events[0].Type != activityshared.EventTurnUpdated || cancelResult.Events[0].Payload.Metadata["cancelRequested"] != true {
t.Fatalf("cancel events = %#v, want one non-terminal child cancel request", cancelResult.Events)
}
if len(cancelResult.ConfirmedTargets) != 1 {
t.Fatalf("confirmed targets = %#v, want child", cancelResult.ConfirmedTargets)
}
_ = transport
}
func TestCodexAppServerAdapterEmitsLateChildCompletionAfterRootTurnCompleted(t *testing.T) {
adapter, transport, session := startedAppServerAdapter(t)
_, _ = adapter.rememberAppServerChildThreads(session, "codex-thread-1", session.AgentSessionID, "turn-local-1", session.AgentSessionID, "turn-local-1", map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"receiverThreadIds": []any{"child-thread-1"},
})
child, ok := adapter.appServerChildThread(session.AgentSessionID, "child-thread-1")
if !ok {
t.Fatal("child thread was not registered")
}
// The parent finishes first, leaving the provider child alive without an
// active root-turn emitter attached to the session-level message handler.
if _, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "parent task",
}}, "", "turn-local-1", nil, nil); err != nil {
t.Fatalf("Exec: %v", err)
}
if activeTurn := adapter.sessionActiveTurn(session.AgentSessionID); activeTurn != nil {
t.Fatal("root turn remained active after completion")
}
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: append([]activityshared.Event(nil), events...),
}
})
transport.conn.notify(appServerNotifyTurnCompleted, map[string]any{
"threadId": "child-thread-1",
"turn": map[string]any{"id": "child-provider-turn-1", "status": "completed"},
})
select {
case batch := <-emitted:
if batch.agentSessionID != session.AgentSessionID {
t.Fatalf("sink session = %q, want root %q", batch.agentSessionID, session.AgentSessionID)
}
completed := eventsOfType(batch.events, activityshared.EventTurnCompleted)
if len(completed) != 1 || completed[0].AgentSessionID != child.agentSessionID || completed[0].Payload.TurnID != child.turnID {
t.Fatalf("late child completion events = %#v", batch.events)
}
lifecycle, stamped := activityshared.TurnLifecycleSnapshotFromEvent(completed[0])
if !stamped || lifecycle.Phase != string(activityshared.TurnPhaseSettled) {
t.Fatalf("late child lifecycle = %#v, stamped=%v", lifecycle, stamped)
}
case <-time.After(5 * time.Second):
t.Fatal("late child completion did not reach the session event sink")
}
}
func TestCodexAppServerAdapterKeepsLateChildCompletionOutOfNewRootTurnEmitter(t *testing.T) {
adapter, transport, session := startedAppServerAdapter(t)
_, _ = adapter.rememberAppServerChildThreads(session, "codex-thread-1", session.AgentSessionID, "turn-local-1", session.AgentSessionID, "turn-local-1", map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-child-1",
"receiverThreadIds": []any{"child-thread-1"},
})
child, ok := adapter.appServerChildThread(session.AgentSessionID, "child-thread-1")
if !ok {
t.Fatal("child thread was not registered")
}
if _, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "parent task A",
}}, "", "turn-local-1", nil, nil); err != nil {
t.Fatalf("Exec turn A: %v", err)
}
if activeTurn := adapter.sessionActiveTurn(session.AgentSessionID); activeTurn != nil {
t.Fatal("root turn A remained active after completion")
}
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: append([]activityshared.Event(nil), events...),
}
})
transport.server.holdTurn = true
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "parent task B",
}}, "", "turn-local-2", nil, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
activeTurn := adapter.sessionActiveTurn(session.AgentSessionID)
return activeTurn != nil && activeTurn.turnID == "turn-local-2"
})
transport.conn.notify(appServerNotifyTurnCompleted, map[string]any{
"threadId": "child-thread-1",
"turn": map[string]any{"id": "child-provider-turn-1", "status": "completed"},
})
select {
case batch := <-emitted:
if batch.agentSessionID != session.AgentSessionID {
t.Fatalf("sink session = %q, want root %q", batch.agentSessionID, session.AgentSessionID)
}
completed := eventsOfType(batch.events, activityshared.EventTurnCompleted)
if len(completed) != 1 ||
completed[0].AgentSessionID != child.agentSessionID ||
completed[0].RootTurnID != "turn-local-1" {
t.Fatalf("detached child completion events = %#v", batch.events)
}
case <-time.After(5 * time.Second):
t.Fatal("late child completion was coupled to root turn B")
}
transport.server.completePendingTurn()
select {
case events := <-execDone:
for _, event := range events {
if event.AgentSessionID == child.agentSessionID {
t.Fatalf("root turn B captured detached child event: %#v", event)
}
}
case <-time.After(5 * time.Second):
t.Fatal("root turn B did not finish")
}
}
func TestCodexAppServerAdapterCancelInterruptsLateUnownedRootTurn(t *testing.T) {
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
execDone := make(chan struct{})
go func() {
_, _ = adapter.Exec(context.Background(), session, textPrompt("delegate work"), "", "root-turn-1", nil, nil)
close(execDone)
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
if _, err := adapter.CancelTargets(context.Background(), session, []CancelTarget{{
AgentSessionID: session.AgentSessionID,
TurnID: "root-turn-1",
}}, "user requested"); err != nil {
t.Fatalf("CancelTargets: %v", err)
}
select {
case <-execDone:
case <-time.After(5 * time.Second):
t.Fatal("Exec did not finish after cancellation")
}
appSession := adapter.getSession(session.AgentSessionID)
reduction := newCodexAppServerReducer(adapter).ReduceNotification(appSession.client, session, "", acpMessage{
Method: appServerNotifyTurnStarted,
Params: mustJSONRawMessage(t, map[string]any{
"threadId": session.ProviderSessionID,
"turn": map[string]any{"id": "turn-after-cancel", "status": "inProgress", "items": []any{}},
}),
}, nil, nil)
if len(reduction.Events) != 0 {
t.Fatalf("late unowned turn events = %#v, want none", reduction.Events)
}
if adapter.sessionActiveTurn(session.AgentSessionID) != nil {
t.Fatal("late unowned turn was adopted after root cancellation")
}
waitForCondition(t, func() bool {
for _, request := range appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt) {
if asString(request["threadId"]) == session.ProviderSessionID && asString(request["turnId"]) == "turn-after-cancel" {
return true
}
}
return false
})
}
func TestCodexAppServerAdapterCancelInterruptsLateChildWithoutCreatingSession(t *testing.T) {
items := []struct {
name string
item map[string]any
}{
{
name: "collaboration tool call",
item: map[string]any{
"type": "collabAgentToolCall",
"id": "spawn-after-cancel",
"tool": "spawnAgent",
"status": "completed",
"receiverThreadIds": []any{"child-after-cancel"},
},
},
{
name: "sub-agent activity",
item: map[string]any{
"type": "subAgentActivity",
"id": "spawn-after-cancel",
"agentThreadId": "child-after-cancel",
"agentPath": "/root/reviewer",
"kind": "started",
},
},
}
for _, test := range items {
t.Run(test.name, func(t *testing.T) {
adapter, transport, session := startedAppServerAdapter(t)
adapter.markRootTurnCanceled(session.AgentSessionID, "root-turn-1")
appSession := adapter.getSession(session.AgentSessionID)
reduction := newCodexAppServerReducer(adapter).ReduceNotification(appSession.client, session, "root-turn-1", acpMessage{
Method: appServerNotifyItemCompleted,
Params: mustJSONRawMessage(t, map[string]any{
"threadId": session.ProviderSessionID,
"turnId": "provider-turn-1",
"item": test.item,
}),
}, newACPTurnNormalizer(), nil)
if len(reduction.Events) != 0 {
t.Fatalf("late child events = %#v, want none", reduction.Events)
}
if _, ok := adapter.appServerChildThread(session.AgentSessionID, "child-after-cancel"); ok {
t.Fatal("late child received a canonical child session context")
}
waitForCondition(t, func() bool {
for _, request := range appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt) {
if asString(request["threadId"]) == "child-after-cancel" {
return true
}
}
return false
})
})
}
}
func TestCodexAppServerAdapterNewCanonicalTurnClearsCancelBoundary(t *testing.T) {
adapter, _, session := startedAppServerAdapter(t)
adapter.markRootTurnCanceled(session.AgentSessionID, "root-turn-1")
turn := &codexAppServerActiveTurn{turnID: "root-turn-2"}
if !adapter.beginActiveTurn(session.AgentSessionID, turn) {
t.Fatal("beginActiveTurn failed")
}
defer adapter.endActiveTurn(session.AgentSessionID, turn)
if canceledRootTurnID, canceled := adapter.rootTurnCanceled(session.AgentSessionID); canceled {
t.Fatalf("cancel boundary = %q, want cleared for a new canonical turn", canceledRootTurnID)
}
}
func TestCodexAppServerAdapterCancelForceClosesWedgedTurn(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
adapter.cancelGraceWindow = 150 * time.Millisecond
transport.server.holdTurn = true
// Wedged codex: it acks turn/interrupt but never sends turn/completed.
transport.server.ignoreInterrupt = true
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "long task",
}}, "", "turn-local-1", nil, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
// Decision: terminate-then-respond. Cancel must return only after the turn
// is actually terminated, and within grace + margin even though codex never
// honored the interrupt.
cancelReturned := make(chan error, 1)
go func() {
_, err := adapter.Cancel(context.Background(), session, "user requested")
cancelReturned <- err
}()
select {
case err := <-cancelReturned:
if err != nil {
t.Fatalf("Cancel: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatalf("Cancel did not return; force-close fallback missing")
}
// The wedged turn must actually end, surfaced as canceled (interrupted
// outcome), never as a failure.
select {
case events := <-execDone:
completed := eventsOfType(events, activityshared.EventRootProviderTurnCompleted)
if len(completed) != 1 ||
completed[0].Payload.TurnOutcome != string(activityshared.TurnOutcomeCanceled) {
t.Fatalf("expected interrupted (canceled) outcome, got %#v", events)
}
if failed := eventsOfType(events, activityshared.EventTurnFailed); len(failed) != 0 {
t.Fatalf("force-cancel must not surface as failed, got %#v", events)
}
case <-time.After(2 * time.Second):
t.Fatalf("Exec did not finish after force cancel")
}
}
func TestCodexAppServerAdapterCancelForceCloseIsBoundedByGrace(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
adapter.cancelGraceWindow = 150 * time.Millisecond
transport.server.holdTurn = true
// Fully wedged: codex never even acknowledges the turn/interrupt RPC, so a
// synchronous interrupt call would block on its own ~10s timeout.
transport.server.hangInterrupt = true
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "long task",
}}, "", "turn-local-1", nil, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
cancelReturned := make(chan error, 1)
go func() {
_, err := adapter.Cancel(context.Background(), session, "user requested")
cancelReturned <- err
}()
// Time-to-force must be bounded by the grace window, not by the hung
// interrupt RPC's own timeout.
select {
case err := <-cancelReturned:
if err != nil {
t.Fatalf("Cancel: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatalf("Cancel blocked on the hung interrupt RPC instead of bounding by grace")
}
select {
case <-execDone:
case <-time.After(2 * time.Second):
t.Fatalf("Exec did not finish after force cancel")
}
}
// TestCodexAppServerAdapterCancelRetriesInterruptOnStaleTurnID reproduces a
// real production incident: our own turn bookkeeping settles a turn locally
// as soon as its Go context is canceled (Cancel/interruptActiveTurn), without
// waiting for the app-server to confirm the turn actually stopped. When a
// slow-to-terminate tool call (live case: wait_agent blocking on several
// dispatched sub-agents) keeps the real app-server turn alive past that
// point, the *next* interrupt we send — aimed at the turn id we believe is
// active — gets rejected with "expected active turn id X but found Y"
// (live-captured, codex 0.142.5, JSON-RPC -32600). Left unhandled, the real
// stale turn is never actually interrupted and keeps running/emitting items
// on its own timeline, which is what left a session stuck reporting "regulat-
// ing next step" long after the visible conversation looked finished. The
// adapter must retry the interrupt against the turn id codex reports as
// actually active.
func TestCodexAppServerAdapterCancelRetriesInterruptOnStaleTurnID(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
// codex reports "turn-stale" as its real active turn, not the "turn-1" id
// our own bookkeeping expects — mirrors the daemon racing ahead of the
// app-server's turn teardown.
transport.server.interruptTurnIDMismatch = "turn-stale"
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "long task",
}}, "", "turn-local-1", nil, nil)
execDone <- events
}()
waitForCondition(t, func() bool {
return adapter.sessionActiveTurnID(session.AgentSessionID) == "turn-1"
})
cancelReturned := make(chan error, 1)
go func() {
_, err := adapter.Cancel(context.Background(), session, "user requested")
cancelReturned <- err
}()
select {
case err := <-cancelReturned:
if err != nil {
t.Fatalf("Cancel: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatalf("Cancel did not return")
}
waitForCondition(t, func() bool {
transport.server.mu.Lock()
defer transport.server.mu.Unlock()
return len(transport.server.interruptAttempts) >= 2
})
transport.server.mu.Lock()
attempts := append([]string(nil), transport.server.interruptAttempts...)
transport.server.mu.Unlock()
if len(attempts) != 2 || attempts[0] != "turn-1" || attempts[1] != "turn-stale" {
t.Fatalf("interrupt attempts = %#v, want [turn-1 turn-stale]", attempts)
}
select {
case events := <-execDone:
completed := eventsOfType(events, activityshared.EventRootProviderTurnCompleted)
if len(completed) != 1 ||
completed[0].Payload.TurnOutcome != string(activityshared.TurnOutcomeCanceled) {
t.Fatalf("expected interrupted outcome, got %#v", events)
}
case <-time.After(5 * time.Second):
t.Fatalf("Exec did not finish after retried interrupt")
}
}
func TestCodexAppServerAdapterCancelQueuesInterruptUntilTurnIDArrives(t *testing.T) {
t.Parallel()
adapter, transport, session := startedAppServerAdapter(t)
transport.server.holdTurn = true
transport.server.turnStartEntered = make(chan struct{})
transport.server.turnStartRelease = make(chan struct{})
execDone := make(chan []activityshared.Event, 1)
go func() {
events, _ := adapter.Exec(context.Background(), session, []PromptContentBlock{{
Type: "text", Text: "long task",
}}, "", "turn-local-1", nil, nil)
execDone <- events
}()
select {
case <-transport.server.turnStartEntered:
case <-time.After(5 * time.Second):
t.Fatalf("turn/start was not sent")
}
waitForCondition(t, func() bool {
return adapter.sessionActiveTurn(session.AgentSessionID) != nil &&
adapter.sessionActiveTurnID(session.AgentSessionID) == ""
})
if _, err := adapter.Cancel(context.Background(), session, "user requested"); err != nil {
t.Fatalf("Cancel before provider turn id: %v", err)
}
close(transport.server.turnStartRelease)
waitForCondition(t, func() bool {
return len(appServerRequestParamsList(t, transport.conn, appServerMethodTurnInterrupt)) == 1
})
interrupt := appServerRequestParams(t, transport.conn, appServerMethodTurnInterrupt)
if asString(interrupt["threadId"]) != "codex-thread-1" || asString(interrupt["turnId"]) != "turn-1" {
t.Fatalf("turn/interrupt params = %#v", interrupt)
}
select {
case events := <-execDone:
if completed := eventsOfType(events, activityshared.EventRootProviderTurnCompleted); len(completed) != 1 ||
completed[0].Payload.TurnOutcome != string(activityshared.TurnOutcomeCanceled) {
t.Fatalf("expected interrupted turn outcome, got %#v", events)
}
case <-time.After(5 * time.Second):
t.Fatalf("Exec did not finish after queued interrupt")
}
}
func TestCodexAppServerAdapterCancelWithoutActiveTurnFails(t *testing.T) {
t.Parallel()
adapter, _, session := startedAppServerAdapter(t)
if _, err := adapter.Cancel(context.Background(), session, "user requested"); err == nil {
t.Fatalf("Cancel without active turn returned nil error")
}
}