Skip to content

Commit 29af2e3

Browse files
authored
feat(app): migrate session interactions (anomalyco#38461)
1 parent 090a26a commit 29af2e3

14 files changed

Lines changed: 189 additions & 104 deletions

packages/app/e2e/regression/remote-session-settings.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
9898
.toEqual([
9999
{
100100
origin: serverA,
101-
directory: directoryA,
101+
directory: undefined,
102102
sessionID: sessionA.id,
103103
permissionID: "permission-background-a",
104104
body: { response: "once" },
@@ -126,14 +126,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
126126
.toEqual([
127127
{
128128
origin: serverA,
129-
directory: directoryA,
129+
directory: undefined,
130130
sessionID: sessionA.id,
131131
permissionID: "permission-background-a",
132132
body: { response: "once" },
133133
},
134134
{
135135
origin: serverA,
136-
directory: directoryA,
136+
directory: undefined,
137137
sessionID: childSessionA.id,
138138
permissionID: "permission-background-a-child",
139139
body: { response: "once" },

packages/app/e2e/regression/session-request-docks.spec.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
4242
const rejectRequests: string[] = []
4343
page.on("request", (request) => {
4444
if (request.method() !== "POST") return
45-
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
45+
if (new URL(request.url()).pathname === "/question/question-request/reject")
46+
rejectRequests.push(request.url())
4647
})
4748

4849
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
6465

6566
await question.getByRole("radio", { name: /Minimal/ }).click()
6667
const reply = page.waitForRequest(
67-
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
68+
(request) =>
69+
request.method() === "POST" &&
70+
new URL(request.url()).pathname === "/question/question-request/reply",
6871
)
6972
await question.getByRole("button", { name: "Submit" }).click()
7073
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })

packages/app/e2e/regression/subagent-child-navigation.spec.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { base64Encode } from "@opencode-ai/core/util/encode"
22
import { expect, test, type Page } from "@playwright/test"
3-
import { mockOpenCodeServer } from "../utils/mock-server"
3+
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
44
import { expectSessionTitle } from "../utils/waits"
55

66
const directory = "C:/OpenCode/SubagentNavigation"
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
7272
events,
7373
eventRetry: events ? 16 : undefined,
7474
})
75-
// The child session resolves via /session/:id but is absent from the /session list,
75+
// The child session resolves by ID but is absent from the session list,
7676
// matching a subagent session that has not been loaded into the list cache yet.
7777
await page.route(
78-
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
78+
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
7979
(route) =>
8080
route.fulfill({
8181
status: 200,
8282
contentType: "application/json",
8383
headers: { "access-control-allow-origin": "*" },
84-
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
84+
body: JSON.stringify({
85+
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
86+
cursor: {},
87+
}),
8588
}),
8689
)
8790
await configurePage(page)

packages/app/e2e/utils/mock-server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
205205
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
206206
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
207207
}
208+
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
209+
return json(route, true)
210+
}
211+
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
212+
return json(route, true)
213+
}
208214
if (
209215
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
210216
route.request().method() === "POST"

packages/app/src/components/dialog-fork.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
6969
const dir = base64Encode(sdk().directory)
7070

7171
sdk()
72-
.client.session.fork({ sessionID, messageID: item.id })
72+
.api.session.fork({ sessionID, messageID: item.id })
7373
.then((forked) => {
74-
if (!forked.data) {
75-
showToast({ title: language.t("common.requestFailed") })
76-
return
77-
}
7874
dialog.close()
79-
prompt.set(restored, undefined, { dir, id: forked.data.id })
80-
navigate(`/${dir}/session/${forked.data.id}`)
75+
prompt.set(restored, undefined, { dir, id: forked.id })
76+
navigate(`/${dir}/session/${forked.id}`)
8177
})
8278
.catch((err: unknown) => {
8379
const message = err instanceof Error ? err.message : String(err)

packages/app/src/components/prompt-input/submit.test.ts

Lines changed: 121 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
77

88
const createdClients: string[] = []
99
const createdSessions: string[] = []
10+
const sessionCreateInputs: Array<{
11+
agent?: string
12+
model?: { id: string; providerID: string; variant?: string }
13+
location?: { directory: string }
14+
}> = []
1015
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
1116
const optimistic: Array<{
1217
directory?: string
@@ -19,11 +24,15 @@ const optimistic: Array<{
1924
}> = []
2025
const optimisticSeeded: boolean[] = []
2126
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
22-
const sessionDirectories: Record<string, string> = {}
2327
const promoted: Array<{ directory: string; sessionID: string }> = []
24-
const sentShell: string[] = []
28+
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
2529
const syncedDirectories: string[] = []
2630
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
31+
const sentPrompts: string[] = []
32+
const promptInputs: unknown[] = []
33+
const sentCommands: unknown[] = []
34+
const commands: Array<{ name: string }> = []
35+
let serverSessionSyncs = 0
2736

2837
let params: { id?: string } = {}
2938
let search: { draftId?: string } = {}
@@ -32,7 +41,7 @@ let variant: string | undefined
3241
let permissionServer = "server-a"
3342
let createSessionGate: Promise<void> | undefined
3443

35-
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
44+
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
3645
const [promptStore, setPromptStore] = createStore<PromptStore>({
3746
prompt: promptValue,
3847
cursor: 0,
@@ -64,23 +73,39 @@ const prompt = {
6473
const clientFor = (directory: string) => {
6574
createdClients.push(directory)
6675
return {
67-
session: {
68-
create: async () => {
69-
await createSessionGate
70-
createdSessions.push(directory)
71-
return {
72-
data: {
76+
api: {
77+
session: {
78+
create: async (input: (typeof sessionCreateInputs)[number]) => {
79+
await createSessionGate
80+
const location = input.location?.directory ?? directory
81+
createdSessions.push(location)
82+
sessionCreateInputs.push(input)
83+
return {
7384
id: `session-${createdSessions.length}`,
85+
projectID: "project",
86+
agent: input.agent,
87+
model: input.model,
88+
cost: 0,
89+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
90+
time: { created: 1, updated: 1 },
7491
title: `New session ${createdSessions.length}`,
75-
},
76-
}
77-
},
78-
shell: async () => {
79-
sentShell.push(directory)
80-
return { data: undefined }
92+
location: { directory: location },
93+
}
94+
},
95+
prompt: async (input: unknown) => {
96+
sentPrompts.push(directory)
97+
promptInputs.push(input)
98+
return { data: undefined }
99+
},
100+
command: async (input: unknown) => {
101+
sentCommands.push(input)
102+
},
103+
shell: async (input: { sessionID: string; id?: string; command: string }) => {
104+
sentShell.push(input)
105+
},
81106
},
82-
prompt: async () => ({ data: undefined }),
83-
promptAsync: async () => ({ data: undefined }),
107+
},
108+
session: {
84109
command: async () => ({ data: undefined }),
85110
abort: async () => ({ data: undefined }),
86111
},
@@ -90,27 +115,6 @@ const clientFor = (directory: string) => {
90115
}
91116
}
92117

93-
const api = {
94-
session: {
95-
async create(input: { location: { directory: string } }) {
96-
await createSessionGate
97-
createdSessions.push(input.location.directory)
98-
const session = {
99-
id: `session-${createdSessions.length}`,
100-
title: `New session ${createdSessions.length}`,
101-
}
102-
sessionDirectories[session.id] = input.location.directory
103-
return session
104-
},
105-
async shell(input: { sessionID: string }) {
106-
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
107-
},
108-
async prompt() {},
109-
async command() {},
110-
async interrupt() {},
111-
},
112-
}
113-
114118
beforeAll(async () => {
115119
const rootClient = clientFor("/repo/main")
116120

@@ -193,8 +197,8 @@ beforeAll(async () => {
193197
const sdk = {
194198
scope: "local",
195199
directory: "/repo/main",
196-
api,
197200
client: rootClient,
201+
api: rootClient.api,
198202
url: "http://localhost:4096",
199203
createClient(opts: any) {
200204
return clientFor(opts.directory)
@@ -206,7 +210,7 @@ beforeAll(async () => {
206210

207211
mock.module("@/context/sync", () => ({
208212
useSync: () => () => ({
209-
data: { command: [] },
213+
data: { command: commands },
210214
session: {
211215
optimistic: {
212216
add: (value: {
@@ -233,6 +237,9 @@ beforeAll(async () => {
233237
session: {
234238
remember: () => undefined,
235239
set: () => undefined,
240+
sync: async () => {
241+
serverSessionSyncs++
242+
},
236243
},
237244
child: (directory: string) => {
238245
syncedDirectories.push(directory)
@@ -274,11 +281,17 @@ beforeAll(async () => {
274281
beforeEach(() => {
275282
createdClients.length = 0
276283
createdSessions.length = 0
284+
sessionCreateInputs.length = 0
277285
enabledAutoAccept.length = 0
278286
optimistic.length = 0
279287
optimisticSeeded.length = 0
280288
promoted.length = 0
281289
promotedDrafts.length = 0
290+
sentPrompts.length = 0
291+
promptInputs.length = 0
292+
sentCommands.length = 0
293+
commands.length = 0
294+
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
282295
params = {}
283296
search = {}
284297
sentShell.length = 0
@@ -287,8 +300,8 @@ beforeEach(() => {
287300
variant = undefined
288301
permissionServer = "server-a"
289302
createSessionGate = undefined
303+
serverSessionSyncs = 0
290304
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
291-
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
292305
})
293306

294307
describe("prompt submit worktree selection", () => {
@@ -321,8 +334,24 @@ describe("prompt submit worktree selection", () => {
321334

322335
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
323336
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
324-
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
337+
expect(sessionCreateInputs).toEqual([
338+
{
339+
agent: "agent",
340+
model: { id: "model", providerID: "provider", variant: undefined },
341+
location: { directory: "/repo/worktree-a" },
342+
},
343+
{
344+
agent: "agent",
345+
model: { id: "model", providerID: "provider", variant: undefined },
346+
location: { directory: "/repo/worktree-b" },
347+
},
348+
])
349+
expect(sentShell).toEqual([
350+
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
351+
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
352+
])
325353
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
354+
expect(serverSessionSyncs).toBe(0)
326355
expect(promoted).toEqual([
327356
{ directory: "/repo/worktree-a", sessionID: "session-1" },
328357
{ directory: "/repo/worktree-b", sessionID: "session-2" },
@@ -443,6 +472,7 @@ describe("prompt submit worktree selection", () => {
443472
const event = { preventDefault: () => undefined } as unknown as Event
444473

445474
await submit.handleSubmit(event)
475+
await Bun.sleep(0)
446476

447477
expect(optimistic).toHaveLength(1)
448478
expect(optimistic[0]).toMatchObject({
@@ -451,6 +481,53 @@ describe("prompt submit worktree selection", () => {
451481
model: { providerID: "provider", modelID: "model", variant: "high" },
452482
},
453483
})
484+
expect(sentPrompts).toEqual(["/repo/main"])
485+
expect(promptInputs[0]).toMatchObject({
486+
sessionID: "session-1",
487+
text: "ls",
488+
files: [],
489+
agents: [],
490+
})
491+
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
492+
})
493+
494+
test("submits slash commands through the current session API", async () => {
495+
params = { id: "session-1" }
496+
variant = "high"
497+
commands.push({ name: "review" })
498+
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
499+
500+
const submit = createPromptSubmit({
501+
prompt,
502+
info: () => ({ id: "session-1" }),
503+
imageAttachments: () => [],
504+
commentCount: () => 0,
505+
autoAccept: () => false,
506+
mode: () => "normal",
507+
working: () => false,
508+
editor: () => undefined,
509+
queueScroll: () => undefined,
510+
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
511+
addToHistory: () => undefined,
512+
resetHistoryNavigation: () => undefined,
513+
setMode: () => undefined,
514+
setPopover: () => undefined,
515+
})
516+
517+
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
518+
519+
expect(sentCommands).toEqual([
520+
{
521+
sessionID: "session-1",
522+
id: expect.stringMatching(/^msg_/),
523+
command: "review",
524+
arguments: "staged changes",
525+
agent: "agent",
526+
model: { id: "model", providerID: "provider", variant: "high" },
527+
files: [],
528+
},
529+
])
530+
expect(serverSessionSyncs).toBe(0)
454531
})
455532

456533
test("uses an injected model selection", async () => {
@@ -511,7 +588,8 @@ describe("prompt submit worktree selection", () => {
511588

512589
await submit.handleSubmit(event)
513590

514-
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
591+
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
592+
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
515593
expect(optimisticSeeded).toEqual([true])
516594
})
517595
})

0 commit comments

Comments
 (0)