| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestDeleteRemoteProjectSessionSendsCanonicalIdentity(t *testing.T) { |
| 10 | fs := newFakeServe(t, "s3cret", nil) |
| 11 | kernel := &fakeRemoteKernel{ |
| 12 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 13 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 14 | ensureToken: "s3cret", |
| 15 | } |
| 16 | seedBridgeTestHost(t, "box") |
| 17 | a := &App{remoteRuntime: kernel} |
| 18 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 19 | defer cleanupRemoteTabPumps(t, a) |
| 20 | if err := a.DeleteRemoteProjectSession("box", "~/app", "canonical-target"); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | want := `POST /delete-session {"name":"canonical-target","sessionId":"canonical-target"}` |
| 24 | if !slices.Contains(fs.recorded(), want) { |
| 25 | t.Fatalf("delete request missing canonical identity: %v", fs.recorded()) |
| 26 | } |
| 27 | if strings.TrimSpace(meta.ID) == "" { |
| 28 | t.Fatal("remote tab id is empty") |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // TestRemoteTabCommandsForwardedToServe pins that every command binding |
| 33 | // reaches the right serve endpoint with the mapped body. |
| 34 | func TestRemoteTabCommandsForwardedToServe(t *testing.T) { |
| 35 | fs := newFakeServe(t, "s3cret", nil) |
| 36 | const currentPath = "/sessions/current.jsonl" |
| 37 | fs.newSessionPath = currentPath |
| 38 | kernel := &fakeRemoteKernel{ |
| 39 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 40 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 41 | ensureToken: "s3cret", |
| 42 | } |
| 43 | seedBridgeTestHost(t, "box") |
| 44 | a := &App{remoteRuntime: kernel} |
| 45 | cleanupRemoteTabPumps(t, a) |
| 46 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 47 | a.remoteTabMu.Lock() |
| 48 | a.remoteTabs[meta.ID].capabilities[serveCapabilityGoalLifecycleV2] = true |
| 49 | a.remoteTabMu.Unlock() |
| 50 | var profileDrained []string |
| 51 | beforeRetiredFloorCall := len(fs.recorded()) |
| 52 | if err := a.SetRemoteTabQualityFloor(meta.ID, "delivery"); err != nil { |
| 53 | t.Fatalf("retired quality-floor compatibility call: %v", err) |
| 54 | } |
| 55 | if got := len(fs.recorded()); got != beforeRetiredFloorCall { |
| 56 | t.Fatalf("retired quality-floor call reached the remote server: before=%d after=%d", beforeRetiredFloorCall, got) |
| 57 | } |
| 58 | |
| 59 | steps := []struct { |
| 60 | name string |
| 61 | call func() error |
| 62 | want string |
| 63 | }{ |
| 64 | {"submit", func() error { return a.SubmitRemoteTab(meta.ID, "hello") }, `POST /submit {"input":"hello"}`}, |
| 65 | {"cancel", func() error { return a.CancelRemoteTab(meta.ID) }, "POST /cancel {}"}, |
| 66 | {"approve", func() error { return a.ApproveRemoteTab(meta.ID, "call-1", "allow") }, `POST /approve {"allow":true,"id":"call-1","persist":false,"session":false}`}, |
| 67 | {"approve-session", func() error { return a.ApproveRemoteTab(meta.ID, "call-2", "session") }, `POST /approve {"allow":true,"id":"call-2","persist":false,"session":true}`}, |
| 68 | {"approve-deny", func() error { return a.ApproveRemoteTab(meta.ID, "call-4", "deny") }, `POST /approve {"allow":false,"id":"call-4","persist":false,"session":false}`}, |
| 69 | {"plan-start", func() error { return a.ResolveRemoteTabPlanDecision(meta.ID, "plan-1", "start_execution", "") }, `POST /plan-decision {"action":"start_execution","feedback":"","id":"plan-1"}`}, |
| 70 | {"plan-revise", func() error { |
| 71 | return a.ResolveRemoteTabPlanDecision(meta.ID, "plan-2", "revise_plan", "check the fallback") |
| 72 | }, `POST /plan-decision {"action":"revise_plan","feedback":"check the fallback","id":"plan-2"}`}, |
| 73 | {"answer", func() error { |
| 74 | return a.AnswerRemoteTab(meta.ID, "ask-1", []RemoteAskAnswer{{QuestionID: "question-1", Selected: []string{"yes"}}}) |
| 75 | }, `POST /answer {"answers":[{"QuestionID":"question-1","Selected":["yes"]}],"id":"ask-1"}`}, |
| 76 | {"rewind", func() error { return a.RewindRemoteTab(meta.ID, "3", "code") }, `POST /rewind {"scope":"code","turn":3}`}, |
| 77 | {"permission-preset", func() error { return a.SetRemoteTabToolApprovalMode(meta.ID, "workspace-write") }, `POST /permission/preset {"expectedRevision":7,"preset":"workspace-write"}`}, |
| 78 | {"composer-profile", func() (err error) { |
| 79 | profileDrained, err = a.SetRemoteTabComposerProfile(meta.ID, "plan", "workspace-write", "") |
| 80 | return |
| 81 | }, `POST /composer-profile {"collaborationMode":"plan","expectedPermissionRevision":7,"goal":"","toolApprovalMode":"workspace-write"}`}, |
| 82 | {"goal", func() error { return a.SetRemoteTabGoal(meta.ID, "ship it") }, `POST /goal {"goal":"ship it"}`}, |
| 83 | {"edit-goal", func() error { |
| 84 | limit := uint64(12) |
| 85 | return a.EditRemoteTabGoal(meta.ID, "ship it safely", &limit) |
| 86 | }, `POST /goal/edit {"maxGoalRounds":12,"objective":"ship it safely"}`}, |
| 87 | {"effort", func() error { return a.SetRemoteTabEffort(meta.ID, "high") }, `POST /effort {"level":"high"}`}, |
| 88 | {"pause-goal", func() error { return a.PauseRemoteTabGoal(meta.ID) }, "POST /goal/pause {}"}, |
| 89 | {"resume-goal", func() error { return a.ResumeRemoteTabGoal(meta.ID) }, "POST /goal/resume {}"}, |
| 90 | {"cancel-jobs", func() error { return a.CancelRemoteTabJobs(meta.ID, []string{"job-1"}) }, `POST /jobs/cancel {"ids":["job-1"]}`}, |
| 91 | {"steer", func() error { return a.SteerRemoteTab(meta.ID, "keep it narrow") }, `POST /inbox/items {"input":"keep it narrow","intent":"steer"}`}, |
| 92 | {"plan-on", func() error { return a.SetRemoteTabPlanMode(meta.ID, true) }, `POST /plan {"on":true}`}, |
| 93 | {"compact", func() error { return a.CompactRemoteTab(meta.ID, "preserve tests") }, `POST /compact {"instructions":"preserve tests"}`}, |
| 94 | {"fork", func() error { return a.ForkRemoteTab(meta.ID, 2, "try-auth") }, `POST /fork {"name":"try-auth","turn":2}`}, |
| 95 | {"summarize", func() error { return a.SummarizeRemoteTab(meta.ID, 4, "upto") }, `POST /summarize {"mode":"upto","turn":4}`}, |
| 96 | {"forget", func() error { return a.ForgetRemoteTab(meta.ID, "api-key") }, `POST /forget {"name":"api-key"}`}, |
| 97 | {"clear", func() error { return a.ClearRemoteTabSession(meta.ID) }, "POST /clear {}"}, |
| 98 | } |
| 99 | for _, step := range steps { |
| 100 | if err := step.call(); err != nil { |
| 101 | t.Fatalf("%s: %v", step.name, err) |
| 102 | } |
| 103 | } |
| 104 | for i, got := range fs.recordedExpectedPaths() { |
| 105 | if got != currentPath { |
| 106 | t.Fatalf("command %d expected-session header = %q, want %q", i, got, currentPath) |
| 107 | } |
| 108 | } |
| 109 | if !slices.Equal(profileDrained, []string{"approval-1"}) { |
| 110 | t.Fatalf("composer profile drained ids = %v, want [approval-1]", profileDrained) |
| 111 | } |
| 112 | calls := fs.recorded() |
| 113 | if slices.ContainsFunc(calls, func(call string) bool { |
| 114 | return call == `POST /inbox/items {"idempotencyKey":"plan-revision:plan-2","input":"check the fallback","intent":"followup"}` |
| 115 | }) { |
| 116 | t.Fatalf("plan revision was split into a second request: %v", calls) |
| 117 | } |
| 118 | for _, step := range steps { |
| 119 | if !slices.Contains(calls, step.want) { |
| 120 | t.Fatalf("%s: serve saw %v, want %q", step.name, calls, step.want) |
| 121 | } |
| 122 | } |
| 123 | if _, err := a.RemoteTabBranches(meta.ID); err != nil { |
| 124 | t.Fatalf("branches: %v", err) |
| 125 | } |
| 126 | if _, err := a.RemoteTabSkills(meta.ID); err != nil { |
| 127 | t.Fatalf("skills: %v", err) |
| 128 | } |
| 129 | if _, err := a.ReplayRemoteTabPrompts(meta.ID); err != nil { |
| 130 | t.Fatalf("pending prompts: %v", err) |
| 131 | } |
| 132 | foundBranches, foundSkills, foundPrompts := false, false, false |
| 133 | for _, c := range fs.recorded() { |
| 134 | if c == "GET /branches " { |
| 135 | foundBranches = true |
| 136 | } |
| 137 | if c == "GET /skills " { |
| 138 | foundSkills = true |
| 139 | } |
| 140 | if c == "GET /pending-prompts " { |
| 141 | foundPrompts = true |
| 142 | } |
| 143 | } |
| 144 | if !foundBranches || !foundSkills || !foundPrompts { |
| 145 | t.Fatalf("branches/skills/prompt reads missing: %v", fs.recorded()) |
| 146 | } |
| 147 | } |
| 148 |