| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- import { describe, expect, test } from "bun:test"
- import type { SessionConfigOption } from "@agentclientprotocol/sdk"
- import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
- describe("acp service lifecycle", () => {
- test("loads and forks with paginated replay while resume does not replay", async () => {
- await using fixture = makeACPFixture({
- fetch(request) {
- if (request.method === "GET" && request.path === "/api/session/ses_loaded") {
- return Response.json({
- data: makeSession("ses_loaded", {
- cwd: "/workspace",
- agent: "plan",
- model: { providerID: "test", id: secondModel.id, variant: "medium" },
- }),
- })
- }
- if (request.method === "GET" && request.path === "/api/session/ses_resume") {
- return Response.json({
- data: makeSession("ses_resume", {
- cwd: "/workspace",
- agent: "plan",
- model: { providerID: "test", id: secondModel.id, variant: "low" },
- }),
- })
- }
- if (request.method === "POST" && request.path === "/api/session/ses_loaded/fork") {
- return Response.json({
- data: makeSession("ses_fork", {
- cwd: "/workspace",
- agent: "plan",
- model: { providerID: "test", id: secondModel.id, variant: "medium" },
- }),
- })
- }
- if (request.method === "GET" && request.path === "/api/session/ses_loaded/message") {
- if (request.query.cursor === "messages-2") {
- return Response.json({
- data: [
- {
- id: "msg_assistant",
- type: "assistant",
- content: [{ type: "text", text: "hi there" }],
- },
- ],
- cursor: {},
- })
- }
- return Response.json({
- data: [{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } }],
- cursor: { next: "messages-2" },
- })
- }
- if (request.method === "GET" && request.path === "/api/session/ses_fork/message") {
- return Response.json({
- data: [{ id: "msg_fork", type: "user", text: "forked", time: { created: 2 } }],
- cursor: {},
- })
- }
- return undefined
- },
- })
- const loaded = await fixture.service.loadSession({
- cwd: "/ignored",
- sessionId: "ses_loaded",
- mcpServers: [],
- })
- const resumed = await fixture.service.resumeSession({
- cwd: "/ignored",
- sessionId: "ses_resume",
- mcpServers: [],
- })
- const forked = await fixture.service.forkSession({
- cwd: "/ignored",
- sessionId: "ses_loaded",
- mcpServers: [],
- })
- expect(currentValue(loaded, "model")).toBe("test/second-model")
- expect(currentValue(loaded, "effort")).toBe("medium")
- expect(currentValue(loaded, "mode")).toBe("plan")
- expect(currentValue(resumed, "effort")).toBe("low")
- expect(forked.sessionId).toBe("ses_fork")
- expect(currentValue(forked, "effort")).toBe("medium")
- expect(
- fixture.updates.filter(
- (item) =>
- item.update.sessionUpdate === "user_message_chunk" || item.update.sessionUpdate === "agent_message_chunk",
- ),
- ).toEqual([
- {
- sessionId: "ses_loaded",
- update: {
- sessionUpdate: "user_message_chunk",
- messageId: "msg_user",
- content: { type: "text", text: "hello" },
- },
- },
- {
- sessionId: "ses_loaded",
- update: {
- sessionUpdate: "agent_message_chunk",
- messageId: "msg_assistant",
- content: { type: "text", text: "hi there" },
- },
- },
- {
- sessionId: "ses_fork",
- update: {
- sessionUpdate: "user_message_chunk",
- messageId: "msg_fork",
- content: { type: "text", text: "forked" },
- },
- },
- ])
- expect(
- fixture.requests
- .filter((request) => request.path.endsWith("/message"))
- .map((request) => ({ path: request.path, query: request.query })),
- ).toEqual([
- {
- path: "/api/session/ses_loaded/message",
- query: { limit: "200", order: "asc" },
- },
- {
- path: "/api/session/ses_loaded/message",
- query: { limit: "200", cursor: "messages-2" },
- },
- {
- path: "/api/session/ses_fork/message",
- query: { limit: "200", order: "asc" },
- },
- ])
- expect(fixture.requests).toContainEqual({
- method: "POST",
- path: "/api/session/ses_loaded/fork",
- query: {},
- body: { boundary: { type: "through" } },
- })
- })
- test("lists server-backed pages and forwards cwd and cursor", async () => {
- const firstPage = Array.from({ length: 100 }, (_, index) =>
- makeSession(`ses_${100 - index}`, {
- cwd: "/workspace",
- time: { created: index, updated: 100_000 - index },
- title: `Session ${100 - index}`,
- }),
- )
- await using fixture = makeACPFixture({
- fetch(request) {
- if (request.method !== "GET" || request.path !== "/api/session") return undefined
- if (request.query.cursor === "page-2") {
- return Response.json({
- data: [makeSession("ses_0", { cwd: "/workspace", time: { created: 0, updated: 1 } })],
- cursor: {},
- })
- }
- return Response.json({ data: firstPage, cursor: { next: "page-2" } })
- },
- })
- const first = await fixture.service.listSessions({ cwd: "/workspace" })
- const second = await fixture.service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })
- expect(first.sessions).toHaveLength(100)
- expect(first.sessions[0]).toEqual({
- sessionId: "ses_100",
- cwd: "/workspace",
- title: "Session 100",
- updatedAt: new Date(100_000).toISOString(),
- })
- expect(first.nextCursor).toBe("page-2")
- expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
- expect(second.nextCursor).toBeUndefined()
- expect(
- fixture.requests.filter((request) => request.path === "/api/session").map((request) => request.query),
- ).toEqual([
- { limit: "100", order: "desc", directory: "/workspace" },
- { limit: "100", order: "desc", directory: "/workspace", cursor: "page-2" },
- ])
- })
- test("cancel preserves the attachment while close removes it and interrupts best-effort", async () => {
- await using fixture = makeACPFixture({
- fetch(request) {
- if (request.method === "POST" && request.path === "/api/session") {
- return Response.json({ data: makeSession("ses_lifecycle") })
- }
- if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
- return new Response(null, { status: 204 })
- }
- if (request.method === "POST" && request.path.endsWith("/interrupt")) {
- return new Response(null, { status: 500 })
- }
- return undefined
- },
- })
- const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
- await fixture.service.cancel({ sessionId: created.sessionId })
- const updated = await fixture.service.setSessionConfigOption({
- sessionId: created.sessionId,
- configId: "effort",
- value: "high",
- })
- expect(currentValue(updated, "effort")).toBe("high")
- expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
- const missing = await fixture.service
- .setSessionConfigOption({
- sessionId: created.sessionId,
- configId: "effort",
- value: "default",
- })
- .catch((error: unknown) => error)
- expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: created.sessionId })
- expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
- expect(
- fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
- ).toEqual([
- "/api/session/ses_lifecycle/interrupt",
- "/api/session/ses_lifecycle/interrupt",
- "/api/session/missing/interrupt",
- ])
- })
- test("deletes sessions from backing and local storage", async () => {
- await using fixture = makeACPFixture({
- fetch(request) {
- if (request.method === "POST" && request.path === "/api/session") {
- return Response.json({ data: makeSession("ses_delete") })
- }
- if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
- return new Response(null, { status: 204 })
- }
- return undefined
- },
- })
- const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
- expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
- expect(fixture.requests).toContainEqual({
- method: "DELETE",
- path: "/api/session/ses_delete",
- query: {},
- body: undefined,
- })
- const missing = await fixture.service
- .setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
- .catch((error: unknown) => error)
- expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
- })
- })
- function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
- return result.configOptions?.find((option) => option.id === id)?.currentValue
- }
|