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
377 lines
13 KiB
Go
377 lines
13 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func TestACPRetriableTurnTailError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
text string
|
|
wantLine string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "cursor retriable tail",
|
|
text: "\n\nError: RetriableError: [canceled] http/2 stream closed with error code CANCEL (0x8)",
|
|
wantLine: "Error: RetriableError: [canceled] http/2 stream closed with error code CANCEL (0x8)",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "connect error tail after normal text",
|
|
text: "Checking the repo.\nError: ConnectError: [unavailable] upstream connect error",
|
|
wantLine: "Error: ConnectError: [unavailable] upstream connect error",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "error line followed by recovery is not a tail",
|
|
text: "Error: RetriableError: hiccup\nall good now",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "plain completion",
|
|
text: "Done. The PR is open.",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty",
|
|
text: "",
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
line, ok := acpRetriableTurnTailError(tc.text)
|
|
if ok != tc.want || line != tc.wantLine {
|
|
t.Fatalf("acpRetriableTurnTailError(%q) = %q, %v; want %q, %v", tc.text, line, ok, tc.wantLine, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestACPAutoContinueHasUsefulProgress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
assistantText string
|
|
toolCallCount int
|
|
want bool
|
|
}{
|
|
{
|
|
name: "error-only text is zero progress",
|
|
assistantText: "\n\nError: RetriableError: [aborted] Client network socket disconnected before secure TLS connection was established",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty text is zero progress",
|
|
assistantText: "",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "prior assistant text is useful progress",
|
|
assistantText: "Checking the repo.\nError: RetriableError: [canceled] http/2 stream closed",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "tool call alone is useful progress",
|
|
assistantText: "\n\nError: RetriableError: [canceled] http/2 stream closed",
|
|
toolCallCount: 1,
|
|
want: true,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := acpAutoContinueHasUsefulProgress(tc.assistantText, tc.toolCallCount); got != tc.want {
|
|
t.Fatalf("acpAutoContinueHasUsefulProgress(...) = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestACPAutoContinuePromptContentBranches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
zero := asString(acpAutoContinuePromptContent(false)[0]["text"])
|
|
if !strings.Contains(zero, "Answer the user's most recent message normally") {
|
|
t.Fatalf("zero-progress prompt = %q", zero)
|
|
}
|
|
if strings.Contains(zero, "Continue exactly where you left off") {
|
|
t.Fatalf("zero-progress prompt must not use mid-task continue wording: %q", zero)
|
|
}
|
|
|
|
mid := asString(acpAutoContinuePromptContent(true)[0]["text"])
|
|
if !strings.Contains(mid, "Continue exactly where you left off") {
|
|
t.Fatalf("mid-task prompt = %q", mid)
|
|
}
|
|
}
|
|
|
|
func acpTestPromptText(params map[string]any) string {
|
|
blocks, _ := params["prompt"].([]any)
|
|
var b strings.Builder
|
|
for _, raw := range blocks {
|
|
block, _ := raw.(map[string]any)
|
|
if asString(block["type"]) == "text" {
|
|
b.WriteString(asString(block["text"]))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// A cursor turn that ends "successfully" right after streaming a transient
|
|
// network error with no useful prior output must auto-continue with the
|
|
// zero-progress prompt (answer the last user message), not mid-task continue.
|
|
func TestCursorAdapterAutoContinuesAfterRetriableTurnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Cursor Agent", "cursor-session-1")
|
|
transport.conn.retriableErrorPrompts = 1
|
|
adapter := newCursorAdapterWithHostMetadata(transport, LegacyHostMetadata(), nil)
|
|
session := standardTestSession(ProviderCursor)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "cursor-session-1"
|
|
|
|
events, err := adapter.Exec(context.Background(), session, textPrompt("你好"), "", "turn-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
transport.conn.mu.Lock()
|
|
promptCalls := transport.conn.promptCallCount
|
|
snapshots := append([]map[string]any(nil), transport.conn.promptParamsSnapshots...)
|
|
transport.conn.mu.Unlock()
|
|
if promptCalls != 2 {
|
|
t.Fatalf("prompt calls = %d, want the auto-continue to send a second prompt", promptCalls)
|
|
}
|
|
text := acpTestPromptText(snapshots[1])
|
|
if !strings.Contains(text, "Answer the user's most recent message normally") {
|
|
t.Fatalf("continue prompt = %q, want zero-progress wording", text)
|
|
}
|
|
if strings.Contains(text, "Continue exactly where you left off") {
|
|
t.Fatalf("zero-progress continue must not use mid-task wording: %q", text)
|
|
}
|
|
|
|
var sawRetryNotice, sawCompleted, sawFailed bool
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventMessageAppended &&
|
|
asString(event.Payload.Metadata["noticeKind"]) == "transport_retry" {
|
|
sawRetryNotice = true
|
|
}
|
|
if event.Type == activityshared.EventRootProviderTurnCompleted {
|
|
sawCompleted = event.Payload.TurnOutcome == string(activityshared.TurnOutcomeCompleted)
|
|
sawFailed = event.Payload.TurnOutcome == string(activityshared.TurnOutcomeFailed)
|
|
}
|
|
}
|
|
if !sawRetryNotice {
|
|
t.Fatalf("events = %#v, want a transport_retry system notice", activityEventTypeCounts(events))
|
|
}
|
|
if !sawCompleted || sawFailed {
|
|
t.Fatalf("turn terminal events completed=%v failed=%v, want completed only", sawCompleted, sawFailed)
|
|
}
|
|
}
|
|
|
|
// When the failed attempt already streamed useful assistant text, auto-continue
|
|
// keeps mid-task wording so the model resumes rather than restarting.
|
|
func TestCursorAdapterAutoContinueMidTaskUsesContinuePrompt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Cursor Agent", "cursor-session-mid")
|
|
transport.conn.retriableErrorPrompts = 1
|
|
transport.conn.retriableErrorPriorText = "Checking the repo next."
|
|
adapter := newCursorAdapterWithHostMetadata(transport, LegacyHostMetadata(), nil)
|
|
session := standardTestSession(ProviderCursor)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "cursor-session-mid"
|
|
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("build the report"), "", "turn-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
transport.conn.mu.Lock()
|
|
snapshots := append([]map[string]any(nil), transport.conn.promptParamsSnapshots...)
|
|
transport.conn.mu.Unlock()
|
|
if len(snapshots) < 2 {
|
|
t.Fatalf("prompt snapshots = %d, want at least 2", len(snapshots))
|
|
}
|
|
text := acpTestPromptText(snapshots[1])
|
|
if !strings.Contains(text, "Continue exactly where you left off") {
|
|
t.Fatalf("continue prompt = %q, want mid-task wording", text)
|
|
}
|
|
}
|
|
|
|
// A continuation attempt that recovers with tool calls only (no new assistant
|
|
// text) must not re-detect the previous attempt's error tail: the turn
|
|
// completes after a single auto-continue instead of burning the remaining
|
|
// retries on stale text and surfacing a false failure.
|
|
func TestCursorAdapterAutoContinueToolOnlyContinuationCompletes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Cursor Agent", "cursor-session-1")
|
|
transport.conn.retriableErrorPrompts = 1
|
|
transport.conn.omitAssistantTextInPromptResults = true
|
|
adapter := newCursorAdapterWithHostMetadata(transport, LegacyHostMetadata(), nil)
|
|
session := standardTestSession(ProviderCursor)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "cursor-session-1"
|
|
|
|
events, err := adapter.Exec(context.Background(), session, textPrompt("build the report"), "", "turn-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
transport.conn.mu.Lock()
|
|
promptCalls := transport.conn.promptCallCount
|
|
transport.conn.mu.Unlock()
|
|
if promptCalls != 2 {
|
|
t.Fatalf("prompt calls = %d, want exactly one auto-continue for a tool-only recovery", promptCalls)
|
|
}
|
|
|
|
var sawCompleted, sawFailed bool
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventRootProviderTurnCompleted {
|
|
sawCompleted = event.Payload.TurnOutcome == string(activityshared.TurnOutcomeCompleted)
|
|
sawFailed = event.Payload.TurnOutcome == string(activityshared.TurnOutcomeFailed)
|
|
}
|
|
}
|
|
if !sawCompleted || sawFailed {
|
|
t.Fatalf("turn terminal events completed=%v failed=%v, want completed only", sawCompleted, sawFailed)
|
|
}
|
|
}
|
|
|
|
// When every continue attempt is also cut short, the turn must surface as
|
|
// failed instead of a silent "completed" that strands the conversation.
|
|
func TestCursorAdapterAutoContinueExhaustedMarksTurnFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Cursor Agent", "cursor-session-1")
|
|
transport.conn.retriableErrorPrompts = 100
|
|
adapter := newCursorAdapterWithHostMetadata(transport, LegacyHostMetadata(), nil)
|
|
session := standardTestSession(ProviderCursor)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "cursor-session-1"
|
|
|
|
events, err := adapter.Exec(context.Background(), session, textPrompt("build the report"), "", "turn-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
transport.conn.mu.Lock()
|
|
promptCalls := transport.conn.promptCallCount
|
|
transport.conn.mu.Unlock()
|
|
if want := 1 + acpAutoContinueMaxAttempts; promptCalls != want {
|
|
t.Fatalf("prompt calls = %d, want %d (original + bounded retries)", promptCalls, want)
|
|
}
|
|
|
|
var failedError string
|
|
var sawCompleted bool
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventRootProviderTurnCompleted {
|
|
switch event.Payload.TurnOutcome {
|
|
case string(activityshared.TurnOutcomeFailed):
|
|
failedError = asString(event.Payload.Metadata["error"])
|
|
case string(activityshared.TurnOutcomeCompleted):
|
|
sawCompleted = true
|
|
}
|
|
}
|
|
}
|
|
if !strings.Contains(failedError, "RetriableError") {
|
|
t.Fatalf("turn failed error = %q, want the retriable error line", failedError)
|
|
}
|
|
if sawCompleted {
|
|
t.Fatal("turn must not also report completion after exhausting retries")
|
|
}
|
|
}
|
|
|
|
// Providers without the auto-continue opt-in must keep the old behavior: the
|
|
// error tail stays a plain completed turn and no synthetic prompt is sent.
|
|
func TestStandardACPAdapterWithoutOptInDoesNotAutoContinue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Hermes Agent", "hermes-session-1")
|
|
transport.conn.retriableErrorPrompts = 1
|
|
adapter := newHermesExtensionTestAdapter(transport)
|
|
session := standardTestSession(hermesExtensionTestProvider)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "hermes-session-1"
|
|
|
|
events, err := adapter.Exec(context.Background(), session, textPrompt("build the report"), "", "turn-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
transport.conn.mu.Lock()
|
|
promptCalls := transport.conn.promptCallCount
|
|
transport.conn.mu.Unlock()
|
|
if promptCalls != 1 {
|
|
t.Fatalf("prompt calls = %d, want no auto-continue without the opt-in", promptCalls)
|
|
}
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventRootProviderTurnCompleted &&
|
|
event.Payload.TurnOutcome == string(activityshared.TurnOutcomeFailed) {
|
|
t.Fatalf("event = %#v, want the legacy completed turn", event)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cursor free-plan / payment gates may fail session/prompt with fixed copy.
|
|
// Soft-settle as a warning notice + completed turn so retries are not a red
|
|
// turn-failed card.
|
|
func TestCursorAdapterSoftSettlesPlanLimitPromptError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Cursor Agent", "cursor-session-plan-limit")
|
|
transport.conn.planLimitPromptError = true
|
|
adapter := newCursorAdapterWithHostMetadata(transport, LegacyHostMetadata(), nil)
|
|
session := standardTestSession(ProviderCursor)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "cursor-session-plan-limit"
|
|
|
|
events, err := adapter.Exec(context.Background(), session, textPrompt("hello"), "", "turn-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
var sawCompleted bool
|
|
var sawFailed bool
|
|
var noticeTitle string
|
|
for _, event := range events {
|
|
switch event.Type {
|
|
case activityshared.EventRootProviderTurnCompleted:
|
|
sawCompleted = event.Payload.TurnOutcome == string(activityshared.TurnOutcomeCompleted)
|
|
sawFailed = event.Payload.TurnOutcome == string(activityshared.TurnOutcomeFailed)
|
|
if event.Payload.Metadata["planLimit"] != true {
|
|
t.Fatalf("completed metadata = %#v, want planLimit true", event.Payload.Metadata)
|
|
}
|
|
case activityshared.EventMessageAppended:
|
|
if asString(event.Payload.Metadata["kind"]) == "agent_system_notice" {
|
|
noticeTitle = asString(event.Payload.Metadata["title"])
|
|
}
|
|
}
|
|
}
|
|
if !sawCompleted || sawFailed {
|
|
t.Fatalf("turn terminal completed=%v failed=%v, want completed only", sawCompleted, sawFailed)
|
|
}
|
|
if noticeTitle != "Upgrade your plan to continue" {
|
|
t.Fatalf("plan-limit notice title = %q", noticeTitle)
|
|
}
|
|
}
|