259 lines
9.3 KiB
TypeScript
259 lines
9.3 KiB
TypeScript
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: {},
|
|
})
|
|
})
|
|
|
|
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
|
|
}
|