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
622 lines
23 KiB
Go
622 lines
23 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCodexAppServerAdapterSlashCompact(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
dispatches := make(chan ProviderDispatchResult, 1)
|
|
events, err := adapter.ExecWithProviderAcceptance(
|
|
context.Background(),
|
|
session,
|
|
[]PromptContentBlock{{
|
|
Type: "text", Text: "/compact",
|
|
}},
|
|
"",
|
|
"turn-local-1",
|
|
nil,
|
|
nil,
|
|
func(result ProviderDispatchResult) { dispatches <- result },
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
dispatch := <-dispatches
|
|
if dispatch.Disposition != DispatchDispositionAppliedWithoutProviderTurn ||
|
|
dispatch.Acceptance != nil {
|
|
t.Fatalf("compact dispatch = %#v", dispatch)
|
|
}
|
|
compact := appServerRequestParams(t, transport.conn, appServerMethodThreadCompact)
|
|
if asString(compact["threadId"]) != "codex-thread-1" {
|
|
t.Fatalf("compact params = %#v", compact)
|
|
}
|
|
if requests := appServerRequestParamsList(t, transport.conn, appServerMethodTurnStart); len(requests) != 0 {
|
|
t.Fatalf("turn/start should not run for /compact")
|
|
}
|
|
// "Context compacted." must arrive via item/completed through the
|
|
// session-level handler — not as a locally-emitted terminal message.
|
|
var gotCompactedBanner bool
|
|
bannerIndex := -1
|
|
progressIndex := -1
|
|
terminalIndex := -1
|
|
for index, event := range events {
|
|
if event.Payload.Content == "Compacting context." && progressIndex == -1 {
|
|
progressIndex = index
|
|
}
|
|
if event.Payload.Content == "Context compacted." {
|
|
gotCompactedBanner = true
|
|
bannerIndex = index
|
|
}
|
|
if event.Type == activityshared.EventTurnCompleted && terminalIndex == -1 {
|
|
terminalIndex = index
|
|
}
|
|
}
|
|
if !gotCompactedBanner {
|
|
t.Fatalf("expected 'Context compacted.' banner in compact events; got %#v", events)
|
|
}
|
|
if progressIndex == -1 || progressIndex > bannerIndex {
|
|
t.Fatalf("compacting progress banner index = %d, completed banner index = %d, events = %#v", progressIndex, bannerIndex, events)
|
|
}
|
|
if terminalIndex == -1 || bannerIndex == -1 || bannerIndex > terminalIndex {
|
|
t.Fatalf("compact banner index = %d, terminal index = %d, events = %#v", bannerIndex, terminalIndex, events)
|
|
}
|
|
if completed := eventsOfType(events, activityshared.EventTurnCompleted); len(completed) != 1 {
|
|
t.Fatalf("compact turn completed events = %d, want 1", len(completed))
|
|
}
|
|
}
|
|
|
|
// Codex app-server frequently finishes thread/compact/start (turn/started →
|
|
// turn/completed) without ever streaming a contextCompaction item/started or
|
|
// item/completed notification. This used to leave /compact completely
|
|
// invisible in the transcript: no "Compacting context." banner (nothing
|
|
// rendered while it ran) and no "Context compacted." banner (nothing showed
|
|
// it finished either), because both banners were driven exclusively by the
|
|
// server's item notifications. The client must show the progress banner up
|
|
// front and settle it at turn completion even when the server stays silent.
|
|
func TestCodexAppServerAdapterSlashCompactWhenServerStaysSilent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
transport.server.compactSilent = true
|
|
events, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "/compact",
|
|
}}, "", "turn-local-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
var progressCount, completedCount int
|
|
var progressMessageID, completedMessageID string
|
|
for _, event := range events {
|
|
switch event.Payload.Content {
|
|
case "Compacting context.":
|
|
progressCount++
|
|
progressMessageID = asString(event.Payload.Metadata["messageId"])
|
|
case "Context compacted.":
|
|
completedCount++
|
|
completedMessageID = asString(event.Payload.Metadata["messageId"])
|
|
}
|
|
}
|
|
if progressCount != 1 {
|
|
t.Fatalf("progress banners = %d, want exactly 1 (silent server must not leave /compact invisible); events = %#v", progressCount, events)
|
|
}
|
|
if completedCount != 1 {
|
|
t.Fatalf("completed banners = %d, want exactly 1 (silent server must still settle the banner); events = %#v", completedCount, events)
|
|
}
|
|
if progressMessageID == "" || progressMessageID != completedMessageID {
|
|
t.Fatalf("messageId mismatch: progress %q, completed %q", progressMessageID, completedMessageID)
|
|
}
|
|
if completed := eventsOfType(events, activityshared.EventTurnCompleted); len(completed) != 1 {
|
|
t.Fatalf("compact turn completed events = %d, want 1", len(completed))
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashReview(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
dispatches := make(chan ProviderDispatchResult, 1)
|
|
events, err := adapter.ExecWithProviderAcceptance(
|
|
context.Background(),
|
|
session,
|
|
[]PromptContentBlock{{
|
|
Type: "text", Text: "/review check the auth flow",
|
|
}},
|
|
"",
|
|
"turn-local-1",
|
|
nil,
|
|
nil,
|
|
func(result ProviderDispatchResult) { dispatches <- result },
|
|
func(receipt ProviderAcceptanceReceipt) error {
|
|
dispatches <- ProviderDispatchResult{
|
|
Disposition: DispatchDispositionApplied,
|
|
Acceptance: &receipt,
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
dispatch := <-dispatches
|
|
if dispatch.Disposition != DispatchDispositionApplied ||
|
|
dispatch.Acceptance == nil ||
|
|
dispatch.Acceptance.ProviderSessionID != "codex-thread-1" ||
|
|
dispatch.Acceptance.ProviderTurnID != "turn-review" {
|
|
t.Fatalf("review dispatch = %#v", dispatch)
|
|
}
|
|
review := appServerRequestParams(t, transport.conn, appServerMethodReviewStart)
|
|
target := payloadObject(review["target"])
|
|
if asString(target["type"]) != "custom" || asString(target["instructions"]) != "check the auth flow" {
|
|
t.Fatalf("review target = %#v", target)
|
|
}
|
|
if asString(review["summary"]) != "auto" {
|
|
t.Fatalf("review/start summary = %q, want auto", review["summary"])
|
|
}
|
|
var assistantText string
|
|
for _, event := range eventsOfType(events, activityshared.EventMessageAppended) {
|
|
if event.Payload.Role == activityshared.MessageRoleAssistant {
|
|
assistantText = event.Payload.Content
|
|
}
|
|
}
|
|
if assistantText != "Found one issue." {
|
|
t.Fatalf("review assistant message = %q", assistantText)
|
|
}
|
|
if completed := eventsOfType(events, activityshared.EventRootProviderTurnCompleted); len(completed) != 1 {
|
|
t.Fatalf("review turn completed events = %d, want 1", len(completed))
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashReviewInlineReasoning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
transport.server.reviewInline = true
|
|
events, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "/review check the auth flow",
|
|
}}, "", "turn-local-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
// Inline reasoning items must surface as finalized thinking so they break
|
|
// the otherwise-unbroken tool-call streak in the GUI.
|
|
thinking := activityMessagesWithRole(events, activityshared.MessageRoleAssistantThinking)
|
|
sawReasoningText := false
|
|
for _, event := range thinking {
|
|
if event.Payload.Metadata["messageKind"] != "review-process" {
|
|
t.Fatalf("thinking messageKind = %#v, want review-process", event.Payload.Metadata["messageKind"])
|
|
}
|
|
if strings.Contains(event.Payload.Content, "Inspecting the auth flow.") {
|
|
sawReasoningText = true
|
|
}
|
|
}
|
|
if !sawReasoningText {
|
|
t.Fatalf("expected reasoning to surface as thinking, got %#v", thinking)
|
|
}
|
|
|
|
// Reasoning streamed and finalized exactly once (no double-append).
|
|
if strings.Count(lastThinkingContent(thinking), "Inspecting the auth flow.") != 1 {
|
|
t.Fatalf("reasoning text duplicated in thinking: %q", lastThinkingContent(thinking))
|
|
}
|
|
|
|
assistantText := ""
|
|
for _, event := range activityMessagesWithRole(events, activityshared.MessageRoleAssistant) {
|
|
assistantText = event.Payload.Content
|
|
}
|
|
if assistantText != "Found one issue." {
|
|
t.Fatalf("review assistant message = %q", assistantText)
|
|
}
|
|
|
|
if completed := eventsOfType(events, activityshared.EventRootProviderTurnCompleted); len(completed) != 1 {
|
|
t.Fatalf("review turn completed events = %d, want 1", len(completed))
|
|
}
|
|
}
|
|
|
|
func TestAppServerReasoningText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
item map[string]any
|
|
want string
|
|
}{
|
|
{
|
|
name: "string summary array",
|
|
item: map[string]any{
|
|
"summary": []any{"Inspecting the auth flow."},
|
|
},
|
|
want: "Inspecting the auth flow.",
|
|
},
|
|
{
|
|
name: "plain string summary",
|
|
item: map[string]any{
|
|
"summary": "Inspecting the auth flow.",
|
|
},
|
|
want: "Inspecting the auth flow.",
|
|
},
|
|
{
|
|
name: "object summary sections",
|
|
item: map[string]any{
|
|
"summary": []any{map[string]any{"text": "Inspecting the auth flow."}},
|
|
},
|
|
want: "Inspecting the auth flow.",
|
|
},
|
|
{
|
|
name: "top-level text fallback",
|
|
item: map[string]any{
|
|
"summary": []any{},
|
|
"content": []any{},
|
|
"text": "Inspecting the auth flow.",
|
|
},
|
|
want: "Inspecting the auth flow.",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := appServerReasoningText(tt.item); got != tt.want {
|
|
t.Fatalf("appServerReasoningText() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppServerReasoningDeltaTextPreservesWhitespace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := appServerReasoningDeltaText(map[string]any{"delta": "Need "}); got != "Need " {
|
|
t.Fatalf("delta text = %q, want trailing space preserved", got)
|
|
}
|
|
if got := appServerReasoningDeltaText(map[string]any{"text": "still "}); got != "still " {
|
|
t.Fatalf("fallback text = %q, want trailing space preserved", got)
|
|
}
|
|
if got := appServerReasoningDeltaText(map[string]any{"text": " more"}); got != " more" {
|
|
t.Fatalf("text fallback = %q, want leading space preserved", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashReviewInlineReasoningSummaryDelta(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
transport.server.reviewInline = true
|
|
transport.server.reviewInlineSummaryDelta = true
|
|
events, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "/review check the auth flow",
|
|
}}, "", "turn-local-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
thinking := activityMessagesWithRole(events, activityshared.MessageRoleAssistantThinking)
|
|
if len(thinking) == 0 {
|
|
t.Fatalf("expected streamed reasoning to surface as thinking, got none")
|
|
}
|
|
finalContent := thinking[len(thinking)-1].Payload.Content
|
|
if finalContent != "Inspecting the auth flow." {
|
|
t.Fatalf("thinking content = %q, want authoritative completed summary text", finalContent)
|
|
}
|
|
if thinking[len(thinking)-1].Payload.Metadata["messageKind"] != "review-process" {
|
|
t.Fatalf("messageKind = %#v, want review-process", thinking[len(thinking)-1].Payload.Metadata["messageKind"])
|
|
}
|
|
}
|
|
|
|
func lastThinkingContent(thinking []activityshared.Event) string {
|
|
content := ""
|
|
for _, event := range thinking {
|
|
content = event.Payload.Content
|
|
}
|
|
return content
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashReviewCancel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
transport.server.reviewHang = true
|
|
entered := make(chan struct{})
|
|
transport.server.reviewStartEntered = entered
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
type execResult struct {
|
|
events []activityshared.Event
|
|
err error
|
|
}
|
|
resultCh := make(chan execResult, 1)
|
|
go func() {
|
|
events, err := adapter.Exec(ctx, session, []PromptContentBlock{{
|
|
Type: "text", Text: "/review",
|
|
}}, "", "turn-local-1", nil, nil)
|
|
resultCh <- execResult{events: events, err: err}
|
|
}()
|
|
|
|
<-entered
|
|
cancel()
|
|
res := <-resultCh
|
|
if res.err != nil {
|
|
t.Fatalf("Exec: %v", res.err)
|
|
}
|
|
|
|
// A canceled review must report as canceled (interrupted), not failed,
|
|
// mirroring a normal turn.
|
|
if failed := eventsOfType(res.events, activityshared.EventTurnFailed); len(failed) != 0 {
|
|
t.Fatalf("canceled review emitted %d turn.failed events, want 0", len(failed))
|
|
}
|
|
sawCanceled := false
|
|
for _, event := range eventsOfType(res.events, activityshared.EventTurnCompleted) {
|
|
if event.Payload.TurnOutcome == string(activityshared.TurnOutcomeInterrupted) {
|
|
sawCanceled = true
|
|
}
|
|
}
|
|
if !sawCanceled {
|
|
t.Fatalf("canceled review missing interrupted turn outcome: %#v", res.events)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashReviewDefaultsToUncommitted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
if _, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "/review",
|
|
}}, "", "turn-local-1", nil, nil); err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
review := appServerRequestParams(t, transport.conn, appServerMethodReviewStart)
|
|
if asString(payloadObject(review["target"])["type"]) != "uncommittedChanges" {
|
|
t.Fatalf("review target = %#v, want uncommittedChanges", review["target"])
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashReviewUncommittedKeyword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
if _, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "/review uncommitted",
|
|
}}, "", "turn-local-1", nil, nil); err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
review := appServerRequestParams(t, transport.conn, appServerMethodReviewStart)
|
|
if asString(payloadObject(review["target"])["type"]) != "uncommittedChanges" {
|
|
t.Fatalf("review target = %#v, want uncommittedChanges", review["target"])
|
|
}
|
|
}
|
|
|
|
func TestAppServerReviewTargetParsing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
args string
|
|
want map[string]any
|
|
}{
|
|
{name: "empty", args: "", want: map[string]any{"type": "uncommittedChanges"}},
|
|
{name: "blank", args: " ", want: map[string]any{"type": "uncommittedChanges"}},
|
|
{name: "uncommitted keyword", args: "uncommitted", want: map[string]any{"type": "uncommittedChanges"}},
|
|
{name: "base branch", args: "base:main", want: map[string]any{"type": "baseBranch", "branch": "main"}},
|
|
{name: "base branch slashes", args: "base:feature/x", want: map[string]any{"type": "baseBranch", "branch": "feature/x"}},
|
|
{name: "commit", args: "commit:abc123", want: map[string]any{"type": "commit", "sha": "abc123"}},
|
|
{name: "custom keyword", args: "custom:check the auth flow", want: map[string]any{"type": "custom", "instructions": "check the auth flow"}},
|
|
{name: "free text stays custom", args: "check the auth flow", want: map[string]any{"type": "custom", "instructions": "check the auth flow"}},
|
|
// Collision guard: free text starting with a keyword but no colon must
|
|
// not be parsed as a structured target.
|
|
{name: "base no colon", args: "base our error handling", want: map[string]any{"type": "custom", "instructions": "base our error handling"}},
|
|
// Unknown keyword before a colon falls back to a full custom prompt.
|
|
{name: "unknown keyword colon", args: "fix the bug: it crashes", want: map[string]any{"type": "custom", "instructions": "fix the bug: it crashes"}},
|
|
// Empty payload after a keyword falls back to custom.
|
|
{name: "base empty", args: "base:", want: map[string]any{"type": "custom", "instructions": "base:"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := appServerReviewTarget(tc.args)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("target = %#v, want %#v", got, tc.want)
|
|
}
|
|
for key, want := range tc.want {
|
|
if asString(got[key]) != want {
|
|
t.Fatalf("target[%q] = %v, want %v", key, got[key], want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterReviewBannersEmitOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := &CodexAppServerAdapter{}
|
|
session := Session{Provider: "codex", AgentSessionID: "agent-review", RoomID: "room-review"}
|
|
|
|
countNotice := func(itemType, wantTitle string) int {
|
|
// Real app-server streams both item/started and item/completed for
|
|
// review/compaction items; the banner must appear exactly once.
|
|
normalizer := newACPTurnNormalizer()
|
|
item := map[string]any{"type": itemType, "id": "item-1"}
|
|
events := adapter.appServerItemEvents(session, "turn-review", item, false, normalizer)
|
|
events = append(events, adapter.appServerItemEvents(session, "turn-review", item, true, normalizer)...)
|
|
count := 0
|
|
for _, event := range events {
|
|
if event.Payload.Content == wantTitle {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
if got := countNotice("enteredReviewMode", "Code review started."); got != 1 {
|
|
t.Fatalf("entered review banners = %d, want exactly 1", got)
|
|
}
|
|
if got := countNotice("exitedReviewMode", "Code review finished."); got != 1 {
|
|
t.Fatalf("exited review banners = %d, want exactly 1", got)
|
|
}
|
|
if got := countNotice("contextCompaction", "Context compacted."); got != 1 {
|
|
t.Fatalf("context compaction banners = %d, want exactly 1", got)
|
|
}
|
|
if got := countNotice("contextCompaction", "Compacting context."); got != 1 {
|
|
t.Fatalf("context compaction progress banners = %d, want exactly 1", got)
|
|
}
|
|
}
|
|
|
|
// The in-progress banner and the completed banner must share one messageId so
|
|
// the completed notice replaces the progress notice in place instead of
|
|
// leaving a stale "Compacting context." row in the transcript.
|
|
func TestCodexAppServerAdapterCompactionBannersShareMessageID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := &CodexAppServerAdapter{}
|
|
session := Session{Provider: "codex", AgentSessionID: "agent-compact", RoomID: "room-compact"}
|
|
normalizer := newACPTurnNormalizer()
|
|
item := map[string]any{"type": "contextCompaction", "id": "item-compact-1"}
|
|
|
|
started := adapter.appServerItemEvents(session, "turn-1", item, false, normalizer)
|
|
completed := adapter.appServerItemEvents(session, "turn-1", item, true, normalizer)
|
|
if len(started) != 1 || len(completed) != 1 {
|
|
t.Fatalf("compaction events = %d started, %d completed, want 1 each", len(started), len(completed))
|
|
}
|
|
if got := started[0].Payload.Content; got != "Compacting context." {
|
|
t.Fatalf("started banner = %q, want %q", got, "Compacting context.")
|
|
}
|
|
if got := completed[0].Payload.Content; got != "Context compacted." {
|
|
t.Fatalf("completed banner = %q, want %q", got, "Context compacted.")
|
|
}
|
|
if got := asString(completed[0].Payload.Metadata["noticeCommandStatus"]); got != "completed" {
|
|
t.Fatalf("completed banner status = %q, want completed", got)
|
|
}
|
|
startedID := asString(started[0].Payload.Metadata["messageId"])
|
|
completedID := asString(completed[0].Payload.Metadata["messageId"])
|
|
if startedID == "" || startedID != completedID {
|
|
t.Fatalf("messageId mismatch: started %q, completed %q", startedID, completedID)
|
|
}
|
|
// The explicit provider terminal won first, so a later synthesized turn
|
|
// terminal must not rewrite the lifecycle to canceled.
|
|
for _, event := range normalizer.FinishInterrupted(session, "turn-1", "interrupted") {
|
|
if asString(event.Payload.Metadata["noticeCommand"]) == "compact" {
|
|
t.Fatalf("completed compaction was overwritten by turn interruption: %#v", event)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A turn that dies mid-compaction must settle the in-progress banner in place;
|
|
// otherwise the transcript keeps a live "Compacting context." row ticking
|
|
// forever after the failure.
|
|
func TestCodexAppServerAdapterCompactionBannerSettlesOnInterrupt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := &CodexAppServerAdapter{}
|
|
session := Session{Provider: "codex", AgentSessionID: "agent-compact", RoomID: "room-compact"}
|
|
normalizer := newACPTurnNormalizer()
|
|
item := map[string]any{"type": "contextCompaction", "id": "item-compact-1"}
|
|
|
|
started := adapter.appServerItemEvents(session, "turn-1", item, false, normalizer)
|
|
if len(started) != 1 {
|
|
t.Fatalf("compaction started events = %d, want 1", len(started))
|
|
}
|
|
terminal := normalizer.FinishInterrupted(session, "turn-1", "interrupted")
|
|
var settled *activityshared.Event
|
|
for index := range terminal {
|
|
if terminal[index].Payload.Content == "Context compaction interrupted." {
|
|
settled = &terminal[index]
|
|
}
|
|
}
|
|
if settled == nil {
|
|
t.Fatalf("expected interrupted compaction banner in terminal events; got %#v", terminal)
|
|
return
|
|
}
|
|
if got, want := asString(settled.Payload.Metadata["messageId"]), asString(started[0].Payload.Metadata["messageId"]); got != want || got == "" {
|
|
t.Fatalf("interrupted banner messageId = %q, want %q", got, want)
|
|
}
|
|
if got := asString(settled.Payload.Metadata["noticeCommand"]); got != "compact" {
|
|
t.Fatalf("interrupted banner command = %q, want compact", got)
|
|
}
|
|
if got := asString(settled.Payload.Metadata["noticeCommandStatus"]); got != "canceled" {
|
|
t.Fatalf("interrupted banner status = %q, want canceled", got)
|
|
}
|
|
// The synthesized canceled terminal won first. A provider completion that
|
|
// was already in flight must be ignored rather than replacing it.
|
|
if late := adapter.appServerItemEvents(session, "turn-1", item, true, normalizer); len(late) != 0 {
|
|
t.Fatalf("late compaction completion emitted after cancellation: %#v", late)
|
|
}
|
|
// Once settled, later terminal calls must not emit the banner again.
|
|
if again := normalizer.FinishFailed(session, "turn-1"); len(again) != 0 {
|
|
for _, event := range again {
|
|
if event.Payload.Content == "Context compaction interrupted." {
|
|
t.Fatalf("compaction banner settled twice: %#v", again)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterCompactionBannerSettlesOnFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := &CodexAppServerAdapter{}
|
|
session := Session{Provider: "codex", AgentSessionID: "agent-compact", RoomID: "room-compact"}
|
|
normalizer := newACPTurnNormalizer()
|
|
item := map[string]any{"type": "contextCompaction", "id": "item-compact-1"}
|
|
|
|
if started := adapter.appServerItemEvents(session, "turn-1", item, false, normalizer); len(started) != 1 {
|
|
t.Fatalf("compaction started events = %d, want 1", len(started))
|
|
}
|
|
terminal := normalizer.FinishFailed(session, "turn-1")
|
|
var settled *activityshared.Event
|
|
for index := range terminal {
|
|
if asString(terminal[index].Payload.Metadata["noticeCommand"]) == "compact" {
|
|
settled = &terminal[index]
|
|
}
|
|
}
|
|
if settled == nil {
|
|
t.Fatalf("expected failed compaction banner in terminal events; got %#v", terminal)
|
|
return
|
|
}
|
|
if got := asString(settled.Payload.Metadata["noticeCommandStatus"]); got != "failed" {
|
|
t.Fatalf("failed banner status = %q, want failed", got)
|
|
}
|
|
if late := adapter.appServerItemEvents(session, "turn-1", item, true, normalizer); len(late) != 0 {
|
|
t.Fatalf("late compaction completion emitted after failure: %#v", late)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashReviewBaseBranch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
if _, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "/review base:main",
|
|
}}, "", "turn-local-1", nil, nil); err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
review := appServerRequestParams(t, transport.conn, appServerMethodReviewStart)
|
|
target := payloadObject(review["target"])
|
|
if asString(target["type"]) != "baseBranch" || asString(target["branch"]) != "main" {
|
|
t.Fatalf("review target = %#v, want baseBranch main", target)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterSlashUndo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
events, err := adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "/undo",
|
|
}}, "", "turn-local-1", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
rollback := appServerRequestParams(t, transport.conn, appServerMethodThreadRollback)
|
|
if numTurns, _ := int64Value(rollback["numTurns"]); numTurns != 1 {
|
|
t.Fatalf("rollback params = %#v", rollback)
|
|
}
|
|
if completed := eventsOfType(events, activityshared.EventTurnCompleted); len(completed) != 1 {
|
|
t.Fatalf("undo turn completed events = %d, want 1", len(completed))
|
|
}
|
|
}
|