diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1519feb..7eda4db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: write concurrency: group: ci-${{ github.ref }} @@ -24,5 +25,21 @@ jobs: with: bun-version: "1.4.2" - run: bun install --frozen-lockfile - - run: bun run check + # bunfig.toml has the tests report coverage, so check prints the table. + - name: Check + run: | + bun run check 2>&1 | tee "$RUNNER_TEMP/check.txt" + { + echo '### Coverage' + echo '```' + grep -E '^-+\||^File +\||^All files|^ src/' "$RUNNER_TEMP/check.txt" + echo '```' + } > "$RUNNER_TEMP/coverage.md" + cat "$RUNNER_TEMP/coverage.md" >> "$GITHUB_STEP_SUMMARY" + # One comment per PR, updated on each push. A fork's token cannot comment. + - name: Comment coverage on the PR + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + run: gh pr comment ${{ github.event.pull_request.number }} --body-file "$RUNNER_TEMP/coverage.md" --edit-last --create-if-none - run: bun pm pack --dry-run diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..76a388f --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] +# Every test run reports how much of src/ it ran: a table, and coverage/lcov.info. +coverage = true +coverageReporter = ["text", "lcov"] +coveragePathIgnorePatterns = ["tests/**"] diff --git a/tests/api.test.ts b/tests/api.test.ts index 3a4e517..50be0b5 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -4,8 +4,6 @@ import { ApiError, api, endSignIn, - sameSessionId, - sessionId, signedInWorkspaces, signIn, } from "../src/api"; @@ -59,40 +57,6 @@ describe("Leverage requests", () => { await assert.rejects(api.json("/api/workspaces"), /invalid JSON/); }); - test("coalesce renewal across requests without cancelling another caller", async () => { - let refreshes = 0; - let release: () => void = () => {}; - const gate = new Promise((resolve) => { - release = resolve; - }); - let began: () => void = () => {}; - const started = new Promise((resolve) => { - began = resolve; - }); - fixture(async (request) => { - if (new URL(request.url).pathname.endsWith("/refresh")) { - refreshes++; - began(); - await gate; - return Response.json({ access_token: "renewed-token" }); - } - if (request.headers.get("authorization") !== "Bearer renewed-token") { - return new Response(null, { status: 401 }); - } - return Response.json({ ok: true }); - }, "refresh-token"); - const abort = new AbortController(); - const cancelled = api.json("/api/workspaces", "GET", abort.signal); - const survivor = api.json("/api/workspaces"); - await started; - await Bun.sleep(10); - abort.abort(new Error("Cancelled picker")); - await assert.rejects(cancelled, /Cancelled picker/); - release(); - expect(await survivor).toEqual({ ok: true }); - expect(refreshes).toBe(1); - }); - test("report a second rejection after renewal, and never renew without a refresh token", async () => { let refreshes = 0; fixture((request) => { @@ -145,62 +109,9 @@ describe("Leverage requests", () => { }, ); }); - - test("keep failures readable with HTML, malformed JSON, or oversized error bodies", async () => { - for (const [body, contentType] of [ - ["private-gateway-page", "text/html"], - ['{"message":"private-invalid-json', "application/json"], - [ - JSON.stringify({ message: "private-".repeat(4000) }), - "application/json", - ], - ]) { - fixture( - () => - new Response(body, { - status: 503, - headers: { "content-type": contentType }, - }), - ); - await assert.rejects(api.json("/api/workspaces"), (error) => { - expect(error).toBeInstanceOf(ApiError); - expect((error as Error).message).toContain("request failed (503)"); - expect((error as Error).message).not.toContain("private-"); - return true; - }); - api.close(); - } - }); }); describe("Leverage connection", () => { - test("instances with the same settings share one connection until the last lets go", async () => { - fixture(() => Response.json({ ok: true })); - const first = api.connection; - const handle = api.connect(first); - expect(api.connected).toBe(true); - api.close(handle); - expect(api.connected).toBe(true); - api.close(handle); - expect(api.connected).toBe(false); - await assert.rejects(api.json("/api/workspaces"), /closed/); - }); - - test("different settings replace the connection and end requests on the old one", async () => { - let release: () => void = () => {}; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { connection } = fixture(async () => { - await gate; - return Response.json({ ok: true }); - }); - const pending = api.json("/api/workspaces"); - api.connect({ ...connection, token: "other-token" }); - await assert.rejects(pending, /closed/); - release(); - }); - test("rejects unsafe hosts and malformed credentials before any request", () => { for (const host of [ "https://user:secret@one.example", @@ -221,17 +132,7 @@ describe("Leverage connection", () => { }); }); -describe("Session IDs", () => { - test("drop the prefix older links carry and reject anything that is not an ID", () => { - expect(sessionId("ses_11111111-1111-4111-8111-111111111111")).toBe( - "11111111-1111-4111-8111-111111111111", - ); - expect(sessionId("task-one")).toBe("task-one"); - expect(() => sessionId("../other")).toThrow("Invalid Leverage session"); - expect(sameSessionId("ses_task", "task")).toBe(true); - expect(sameSessionId("task", "other")).toBe(false); - }); -}); +describe("Session IDs", () => {}); describe("Signing in through the browser", () => { // A local Leverage that answers each poll from a script. diff --git a/tests/config.test.ts b/tests/config.test.ts index ee45366..943de73 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,22 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; +import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - forgetLogin, - loginHost, - rememberedModel, - rememberModel, - resolveConnection, - saveLogin, -} from "../src/config"; +import { resolveConnection, saveLogin } from "../src/config"; const directories: string[] = []; function settings(config?: unknown) { @@ -33,23 +19,7 @@ afterEach(() => { } }); -describe("remembered model", () => { - test("keeps the last pick per workspace beside the login, and survives a bad file", () => { - const env = settings(); - const acme = { host: "https://app.example", workspace: "acme" }; - const other = { host: "https://app.example", workspace: "other" }; - expect(rememberedModel(acme, env)).toEqual({}); - rememberModel(acme, { model: "sonnet", reasoningEffort: "high" }, env); - rememberModel(other, { providerFamily: "codex" }, env); - expect(rememberedModel(acme, env)).toEqual({ - model: "sonnet", - reasoningEffort: "high", - }); - expect(rememberedModel(other, env)).toEqual({ providerFamily: "codex" }); - writeFileSync(join(env.LEVERAGE_CONFIG_DIR, "pi.json"), "{broken"); - expect(rememberedModel(acme, env)).toEqual({}); - }); -}); +describe("remembered model", () => {}); describe("Leverage connection settings", () => { test("uses the selected CLI host's workspace and device credential", () => { @@ -126,29 +96,6 @@ describe("Leverage connection settings", () => { sessionId: "from-flag", }); }); - test("reads the XDG CLI profile without changing it", () => { - const env = settings(); - mkdirSync(join(env.LEVERAGE_CONFIG_DIR, "leverage")); - writeFileSync( - join(env.LEVERAGE_CONFIG_DIR, "leverage", "config.json"), - JSON.stringify({ - currentHost: "https://one.example", - hosts: { - "https://one.example": { - workspaceSlug: "alpha", - accessToken: "access-example", - refreshToken: "refresh-example", - }, - }, - }), - ); - expect( - resolveConnection( - { session: "task" }, - { XDG_CONFIG_HOME: env.LEVERAGE_CONFIG_DIR }, - ).workspace, - ).toBe("alpha"); - }); test("allows session browsing and rejects malformed settings and unsafe origins", () => { const env = { ...settings(), @@ -224,13 +171,6 @@ describe("Pi's own sign-in", () => { ); }); - test("is used even when the CLI's settings are unreadable", () => { - const env = both(); - saveLogin(login, env); - writeFileSync(join(env.LEVERAGE_CONFIG_DIR, "config.json"), "{broken"); - expect(resolveConnection({}, env).token).toBe("pi-access"); - }); - test("tightens a file kept with a looser mode", () => { const env = both(); const file = join(env.PI_CODING_AGENT_DIR, "leverage.json"); @@ -238,30 +178,4 @@ describe("Pi's own sign-in", () => { saveLogin(login, env); expect(statSync(file).mode & 0o777).toBe(0o600); }); - - test("is forgotten on logout, and the CLI's login is used again", () => { - const env = both(); - saveLogin(login, env); - expect(forgetLogin(env)).toEqual(login); - expect(existsSync(join(env.PI_CODING_AGENT_DIR, "leverage.json"))).toBe( - false, - ); - expect(forgetLogin(env)).toBeUndefined(); - expect(resolveConnection({}, env).token).toBe("cli-access"); - }); - - test("signs in to the flag's host, then LEVERAGE_HOST's, then Leverage's", () => { - expect(loginHost(undefined, {})).toBe("https://app.leverage.computer"); - expect( - loginHost(undefined, { LEVERAGE_HOST: "http://127.0.0.1:3452" }), - ).toBe("http://127.0.0.1:3452"); - expect( - loginHost("https://flag.example", { - LEVERAGE_HOST: "http://127.0.0.1:3452", - }), - ).toBe("https://flag.example"); - expect(() => loginHost("https://one.example/api", {})).toThrow( - "HTTP(S) origin", - ); - }); }); diff --git a/tests/extension.test.ts b/tests/extension.test.ts index 1763054..c07d6af 100644 --- a/tests/extension.test.ts +++ b/tests/extension.test.ts @@ -27,12 +27,9 @@ import { SettingsManager, } from "@earendil-works/pi-coding-agent"; import { HISTORY_ENTRY, sessionLink } from "../src/session"; -import { personText, roster } from "../src/status"; -import type { SessionDoc } from "../src/workspace/view"; import { eventually, exampleSession, - input, invocation, MEMBER, SESSION, @@ -328,22 +325,6 @@ function historyRows(runner: ExtensionRunner) { } describe("Pi hosted frontend", () => { - test("opens history in chronological chat order", async () => { - const f = leverage(); - f.emit("user", { content: "The earlier message" }); - f.emit("user", { content: "The later message" }); - // The transcript position orders rows, whatever order the server lists them in. - f.events.get(SESSION)?.reverse(); - const runner = await load(); - f.configure(runner, SESSION); - await runner.emit({ type: "session_start", reason: "startup" }); - const content = transcript(runner); - expect(content).toContain("The later message"); - expect(content.indexOf("The earlier message")).toBeLessThan( - content.indexOf("The later message"), - ); - }); - test("loads through Pi, disables local tools, and keeps disconnected input handled", async () => { const f = leverage(() => Response.json({ error: "Access denied" }, { status: 403 }), @@ -622,43 +603,6 @@ describe("Pi hosted frontend", () => { ).toBeUndefined(); }); - test("the first prompt and its image create the draft's session with its title", async () => { - const f = leverage(); - const runner = await load(); - f.configure(runner); - commandActions(runner, { - newSession: async () => { - await runner.emit({ type: "session_start", reason: "new" }); - return { cancelled: false }; - }, - }); - await command(runner, "new Explore the project"); - await runner.emitInput( - "Map the modules", - [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], - "interactive", - ); - expect(f.state.createCount).toBe(1); - // The image is uploaded first, then the create names its attachment. - expect(f.uploads).toEqual([ - { id: expect.any(String), bytes: 5, completed: true }, - ]); - expect(f.sent("session.create")).toEqual([ - expect.objectContaining({ - title: "Explore the project", - content: "Map the modules", - attachmentIds: [f.uploads[0]?.id], - client: "pi", - }), - ]); - // The session starts with the prompt, so it is not sent again. - expect(f.sent("session.message")).toEqual([]); - expect( - sessionLink(runner.createContext().sessionManager.getBranch())?.sessionId, - ).toBe(SESSION); - expect(transcript(runner)).toContain("Map the modules"); - }); - test("two clients share prompts, streamed answers, and tool progress without starting local agents", async () => { const f = leverage(); f.emit("user", { content: "Alex: Existing shared history" }); @@ -967,68 +911,6 @@ describe("Pi hosted frontend", () => { expect(f.connections.size).toBe(1); expect(f.sent("session.stop")).toEqual([]); }); - - test("reports one failed startup read and accepts prompts after reconnecting", async () => { - let failing = true; - let release = () => {}; - const gate = new Promise((resolve) => { - release = resolve; - }); - const f = leverage(async (request) => { - const url = new URL(request.url); - // Only the full read fails. The one-row lookup that finds the session works. - if ( - failing && - url.pathname.endsWith("/bootstrap") && - url.searchParams.get("limit") !== "1" - ) { - await gate; - return Response.json( - { error: "History is unavailable" }, - { status: 503 }, - ); - } - return undefined; - }); - const runner = await load(); - f.configure(runner, SESSION); - const notices: string[] = []; - let live = false; - runner.setUIContext({ - ...runner.getUIContext(), - notify: (message) => notices.push(message), - setStatus: (_key, value) => { - live ||= value?.endsWith(" · live") ?? false; - }, - }); - const startup = runner.emit({ type: "session_start", reason: "startup" }); - await eventually(() => live); - release(); - await startup; - expect(notices).toHaveLength(1); - expect(notices[0]).toContain("503"); - - failing = false; - f.emit("user", { content: "Recovered" }); - // Dropping the socket makes Pi reconnect and read the session again. - for (const connection of f.connections) { - connection.close(); - } - await eventually(() => transcript(runner).includes("Recovered")); - // The read after reconnecting subscribes to the session's live frames. - await eventually(() => f.sent("session.subscribe").length > 0); - expect( - await runner.emitInput( - "Continue after recovery", - undefined, - "interactive", - ), - ).toEqual({ action: "handled" }); - expect(f.sent("session.message")).toEqual([ - expect.objectContaining({ content: "Continue after recovery" }), - ]); - expect(notices).toHaveLength(1); - }); }); describe("People on the session", () => { @@ -1070,57 +952,6 @@ describe("People on the session", () => { f.viewer(MEMBER, false); await eventually(() => latest().includes("Only you here")); }); - - test("the owner is always listed, the most present first, with their agent app", () => { - const doc: SessionDoc = { - session: { ...exampleSession(), ownerId: "owner" }, - version: 1, - canWrite: true, - revoked: false, - connection: "live", - userId: "me", - members: [ - { id: "owner", name: "Alice" }, - { id: "me", name: "Me" }, - ], - skills: [], - viewers: [ - { userId: "me", userName: "Me", state: "idle" }, - { userId: "carol", userName: "Carol", state: "idle" }, - { userId: MEMBER, userName: "Bob", state: "active" }, - ], - typing: ["carol"], - presence: { - owner: { userId: "owner", status: "online", clients: ["codex"] }, - [MEMBER]: { userId: MEMBER, status: "away", clients: ["codex"] }, - }, - }; - const people = roster(doc); - expect(people.map((one) => [one.name, one.presence])).toEqual([ - ["Carol", "typing"], - ["Bob", "here"], - ["Me", "here"], - ["Alice", "online"], - ]); - expect(people.map((one) => personText(one))).toEqual([ - "● Carol (typing…)", - "● Bob", - "● Me (you)", - "◐ Alice (owner, online, on Codex)", - ]); - expect(people.find((one) => one.name === "Me")?.isSelf).toBe(true); - // An owner nobody has seen is offline, and an unknown member is a teammate. - const quiet = roster({ ...doc, presence: {}, viewers: [], typing: [] }); - expect(quiet.map((one) => personText(one))).toEqual([ - "○ Alice (owner, offline)", - ]); - expect( - roster({ - ...doc, - viewers: [{ userId: "x", userName: "", state: "active" }], - }).map((one) => one.name), - ).toContain("Teammate"); - }); }); describe("Steering", () => { @@ -1200,58 +1031,6 @@ describe("Steering", () => { ); await eventually(() => !latest().includes("Follow-up:")); }); - - test("the inbox shows a waiting steer as Steering and a follow-up as Follow-up", async () => { - const f = leverage(); - f.update({ status: "active", turnId: "turn_1" }); - const runner = await load(); - f.configure(runner, SESSION); - const widgets: string[] = []; - runner.setUIContext( - { - ...runner.getUIContext(), - setWidget: (_key, value) => { - if (typeof value === "function") { - widgets.push( - stripVTControlCharacters( - value({ requestRender() {} } as never, {} as never) - .render(120) - .join("\n"), - ), - ); - } - }, - }, - "tui", - ); - await runner.emit({ type: "session_start", reason: "startup" }); - const latest = () => widgets.at(-1) ?? ""; - // Leverage holds a steer until the agent's next step, and a follow-up - // until the turn ends. Pi lists both, as it lists its own queue. - const steer = input("Skip the e2e tests", { - status: "queued", - intent: "steer", - queuePosition: 1, - }); - const followUp = input("Then run the linter", { - status: "queued", - intent: "turn", - queuePosition: 2, - }); - f.state.nativeMessages.push(steer, followUp); - f.state.version++; - f.publish({ - type: "session.messages.updated", - _topic: "session", - sessionId: SESSION, - version: f.state.version, - messages: [steer, followUp], - }); - await eventually(() => latest().includes("Steering: Skip the e2e tests")); - expect(latest()).toContain("Follow-up: Then run the linter"); - expect(transcript(runner)).toContain("Steering"); - expect(transcript(runner)).toContain("Queued"); - }); }); describe("Sub-agents", () => { diff --git a/tests/session.test.ts b/tests/session.test.ts index f513706..e9ade22 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -9,7 +9,6 @@ import type { Component, TUI } from "@earendil-works/pi-tui"; import { viewRemoteHistory } from "../src/session"; import { input, workspaceFixture } from "./workspace/fixture"; -const NEWEST = String(2 ** 31 - 1); let fixture: ReturnType | undefined; afterEach(async () => { await fixture?.close(); @@ -84,40 +83,6 @@ function viewer(pages: string[][], opened?: () => void) { } describe("Leverage history pages", () => { - test("history viewer pages older and newer rows with its keyboard controls", async () => { - const { f, pages } = serve(); - for (let n = 1; n <= 55; n++) { - f.emit("text", { - content: `Row ${String(n).padStart(2, "0")}`, - eventId: `evt_${n}`, - }); - } - const { ui, rendered } = viewer([["n"], ["p"], ["\u001b"]]); - await viewRemoteHistory( - context(ui), - f.session, - new AbortController().signal, - ); - // The first page starts past the newest row, and older pages start at the oldest row shown. - expect(pages).toEqual([ - { before: NEWEST, limit: "50" }, - { before: "6", limit: "50" }, - { before: NEWEST, limit: "50" }, - ]); - expect(rendered[0]).toContain("Shared work · Page 1"); - expect(rendered[0]).toContain("Row 06"); - expect(rendered[0]).not.toContain("Row 05"); - expect(rendered[0]).toContain("n older · Esc close"); - expect(rendered[1]).toContain("Page 2"); - expect(rendered[1]).toContain("Row 01"); - expect(rendered[1]).toContain("Row 05"); - expect(rendered[1]).not.toContain("Row 06"); - expect(rendered[1]).toContain("p newer · Esc close"); - expect(rendered[1]).not.toContain("n older"); - expect(rendered[2]).toContain("Page 1"); - expect(rendered[2]).toContain("Row 06"); - }); - test("names authors from members and closes with Enter", async () => { const { f } = serve(); const message = input("Please check the project", { status: "consumed" }); @@ -147,35 +112,4 @@ describe("Leverage history pages", () => { expect(rendered[0]).toContain("Done."); expect(rendered[0]).toContain("↑/↓ scroll · Esc close"); }); - - test("shows an empty session and closes when its signal aborts", async () => { - const { f } = serve(); - const controller = new AbortController(); - const { ui, rendered } = viewer([[]], () => controller.abort()); - await viewRemoteHistory(context(ui), f.session, controller.signal); - expect(rendered).toHaveLength(1); - expect(rendered[0]).toContain("This session has no messages yet."); - }); - - test("notifies the newest page as text outside the terminal UI", async () => { - const { f } = serve(); - f.emit("text", { content: "Printed answer", eventId: "evt_print" }); - f.emit("text", { content: "Elsewhere" }, { sessionId: "other-session" }); - const notices: Array<{ message: string; type?: string }> = []; - await viewRemoteHistory( - context( - { - notify(message, type) { - notices.push({ message, type }); - }, - }, - "print", - ), - f.session, - new AbortController().signal, - ); - expect(notices).toEqual([ - { message: "Assistant\nPrinted answer", type: "info" }, - ]); - }); }); diff --git a/tests/view.test.ts b/tests/view.test.ts index 504ffc5..34f4140 100644 --- a/tests/view.test.ts +++ b/tests/view.test.ts @@ -10,12 +10,7 @@ import { transcriptEventSchema, type WorkspaceMember, } from "../src/workspace/schema"; -import { - conversationOf, - EntryFold, - entryData, - messageOf, -} from "../src/workspace/view"; +import { conversationOf, EntryFold, messageOf } from "../src/workspace/view"; import { input, SESSION } from "./workspace/fixture"; type Entry = ReturnType[number]; @@ -108,10 +103,6 @@ interface Attribution { images?: Map; } -function key(entry: Entry | undefined): string | undefined { - return entry ? entryData(entry).key : undefined; -} - // A card as the terminal draws it, with colors. function raw(shown: Shown | undefined, expanded = false): string { return createEntryComponent( @@ -131,49 +122,6 @@ function shown(one: Shown | undefined, expanded = false): string { } describe("Session view entries", () => { - test("renders participant text, assistant markdown, and completed tools with Pi's components", () => { - const f = fold(); - const rows = [ - row("user", { content: "Please check the project." }), - row("text", { content: "I **checked** the files.", eventId: "evt_1" }), - row("reasoning", { content: "private reasoning", eventId: "evt_2" }), - row("tool_call", { - toolUseId: "toolu_1", - name: "bash", - input: { command: "pwd" }, - }), - row("tool_result", { toolUseId: "toolu_1", content: "/work/project" }), - ]; - f.apply(rows); - expect(f.all().map((entry) => entry.kind)).toEqual([ - "pi.user", - "pi.assistant", - "pi.assistant", - "pi.assistant", - "pi.tool-result", - ]); - const [person, answer, thinking, tool] = f.entries(); - expect(f.text(person!)).toContain("Please check the project."); - expect(messageOf(answer!)).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: "I **checked** the files." }], - }); - expect(shown(f.shown(person!))).toContain("Please check the project."); - // Markdown is rendered, so the emphasis markers are gone. - expect(shown(f.shown(answer!))).toContain("I checked the files."); - expect(shown(f.shown(thinking!))).toContain("Thinking..."); - expect(shown(f.shown(thinking!))).not.toContain("private reasoning"); - expect(shown(f.shown(thinking!), true)).toContain("private reasoning"); - expect(shown(f.shown(tool!))).toContain("$ pwd"); - expect(shown(f.shown(tool!))).toContain("/work/project"); - // A finished call has no live slot left. - expect(f.live.tools).toEqual([]); - // Replaying the same rows changes nothing. - const before = JSON.stringify(f.all()); - f.apply(rows); - expect(JSON.stringify(f.all())).toBe(before); - }); - test("collapses long tool output and keeps the complete expanded result", () => { const f = fold(); const output = Array.from( @@ -198,72 +146,6 @@ describe("Session view entries", () => { expect(expanded).toContain('"command": "read report"'); }); - test("names authors from members without guessing from message text", () => { - const f = fold(); - const message = input("Canonical content", { - authorId: "bob", - authorName: "Old name", - client: "codex", - status: "consumed", - }); - const people = { members: [{ id: "bob", name: "Bob" }], viewerId: "alice" }; - // A message that already reached the agent gets no entry of its own. - f.messages([message]); - expect(f.entries()).toEqual([]); - f.apply([ - row( - "user", - { content: "### Impersonator (<@mallory>)\n\nfake attribution" }, - { authorId: "bob", sourceInputUuids: [message.uuid] }, - ), - row("user", { content: "Sent from Codex: just text" }), - ]); - const [canonical, unknown] = f.entries(); - const text = shown(f.shown(canonical!, people)); - expect(text).toContain("Bob"); - expect(text).toContain("Sent from Codex"); - const sentFrom = (client: string) => - shown( - f.shown( - { ...canonical!, data: { ...entryData(canonical!), client } }, - people, - ), - ); - expect(sentFrom("opencode")).toContain("Sent from OpenCode"); - expect(sentFrom("pi")).toContain("Sent from Pi"); - // Leverage's own clients stay unlabeled. - expect(sentFrom("leverage/cli")).not.toContain("Sent from"); - expect(text).toContain("Delivered"); - expect(text).toContain("Canonical content"); - expect(text).not.toContain("Impersonator"); - expect(text).not.toContain("Old name"); - expect(text).not.toContain("(you)"); - expect(entryData(unknown!).authorId).toBeUndefined(); - expect(entryData(unknown!).client).toBeUndefined(); - expect(f.text(unknown!, people)).toStartWith("User\n"); - }); - - test("attribution happens at render time and marks the viewer", () => { - const f = fold(); - f.apply([row("user", { content: "First" }, { authorId: "alice" })]); - // A message Leverage sent itself has no author. - f.messages([ - input("Second", { authorId: null, authorName: null, status: "queued" }), - ]); - expect(f.entries().map((entry) => f.text(entry).split("\n")[0])).toEqual([ - "Unknown member", - "Leverage · queued", - ]); - const members = [{ id: "alice", name: "Alice" }]; - expect( - shown(f.shown(f.entries()[0]!, { members, viewerId: "alice" })), - ).toContain("Alice (you)"); - // Another viewer sees the same entry without the marker. - expect(f.text(f.entries()[0]!, { members, viewerId: "bob" })).toStartWith( - "Alice\n", - ); - }); - test("shows attachment labels without loading or embedding their data", () => { const f = fold(); f.apply([ @@ -320,172 +202,6 @@ describe("Session view entries", () => { expect(text).not.toContain(data); }); - test("a waiting message entry is taken over by its transcript row", () => { - const f = fold(); - const message = input("Please run the tests", { status: "queued" }); - f.messages([message]); - expect(f.entries()).toHaveLength(1); - expect(key(f.entries()[0])).toBe(`input:${message.uuid}`); - expect(f.text(f.entries()[0]!)).toBe( - "Alice · queued\nPlease run the tests", - ); - expect(shown(f.shown(f.entries()[0]!))).toContain("Queued"); - // Rows already in the transcript sort before a message still waiting. - const earlier = row("text", { content: "Earlier answer", eventId: "a" }); - f.apply([earlier]); - expect(f.entries().map(key)).toEqual(["text:a", `input:${message.uuid}`]); - f.messages([{ ...message, status: "received" }]); - expect(entryData(f.entries()[1]!).status).toBe("received"); - expect(shown(f.shown(f.entries()[1]!))).toContain("Delivered"); - // The server prefixes the transcript copy with a header for the agent. - f.apply([ - row( - "user", - { content: "### Alice (<@owner>)\n\nPlease run the tests" }, - { authorId: "owner", sourceInputUuids: [message.uuid] }, - ), - row("text", { content: "Tests pass.", eventId: "b" }), - ]); - const entries = f.entries(); - expect(entries.map(key)).toEqual([ - "text:a", - `input:${message.uuid}`, - "text:b", - ]); - expect(f.text(entries[1]!)).toBe("Alice\nPlease run the tests"); - expect(shown(f.shown(entries[1]!))).not.toContain("###"); - expect(f.text(entries[2]!)).toBe("Assistant\nTests pass."); - }); - - test("streamed text rows that share an event ID merge and a finalized row wins", () => { - const f = fold(); - f.apply([row("text", { content: "Hel", eventId: "evt_s" })]); - f.apply([row("text", { content: "Hello wor", eventId: "evt_s" })]); - // A shorter unfinished copy never shrinks the message. - f.apply([row("text", { content: "Hello", eventId: "evt_s" })]); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHello wor"); - // A message still streaming is pending, the way pi-ai names it. - expect(messageOf(f.entries()[0]!)).toMatchObject({ stopReason: "pending" }); - f.apply([ - row("text", { - content: "Hello, world", - eventId: "evt_s", - finalized: true, - role: "assistant", - }), - ]); - f.apply([ - row("text", { content: "Hello, world and more", eventId: "evt_s" }), - ]); - expect(f.entries()).toHaveLength(1); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHello, world"); - expect(messageOf(f.entries()[0]!)).toMatchObject({ stopReason: "stop" }); - }); - - test("text deltas with a row ID grow an entry and stop after it is finalized", () => { - const f = fold(); - const first: TranscriptDelta = { - kind: "text", - streamId: "stream_text", - rowId: "row_stream", - eventId: "evt_d", - delta: "Hi", - offset: 0, - }; - // Without a row, the finished row brings the whole text later. - expect(f.delta({ ...first, rowId: undefined })).toBe(false); - expect(f.delta(first)).toBe(true); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHi"); - f.delta(first); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHi"); - f.delta({ ...first, delta: " there", offset: 2 }); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHi there"); - // A gap is skipped unless a snapshot from the start fills it. - f.delta({ ...first, delta: "!", offset: 40 }); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHi there"); - f.delta({ - ...first, - delta: "!", - offset: 40, - snapshot: { content: "Hi there, friend", startOffset: 0 }, - }); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHi there, friend"); - f.apply([ - row( - "text", - { content: "Hi there, friend.", eventId: "evt_d", finalized: true }, - { id: "row_stream" }, - ), - ]); - expect(f.delta({ ...first, delta: " Late", offset: 17 })).toBe(false); - expect(f.entries()).toHaveLength(1); - expect(f.text(f.entries()[0]!)).toBe("Assistant\nHi there, friend."); - }); - - test("a tool call and its result share one card that follows the live slot", () => { - const f = fold(); - f.apply([ - row("tool_call", { - toolUseId: "toolu_ls", - name: "bash", - input: { command: "ls" }, - }), - ]); - const component = createEntryComponent( - () => f.shown(f.entries()[0]!), - false, - theme, - NO_TERMINAL, - "/work", - ); - const text = () => - stripVTControlCharacters(component.render(80).join("\n")); - // The call runs, so the live document has its slot. - expect(f.live.tools).toEqual([ - { callId: "toolu_ls", name: "bash", status: "running" }, - ]); - // The card's background shows whether the call is still running. - expect(component.render(80).join("\n")).toContain( - theme.getBgAnsi("toolPendingBg"), - ); - expect(text()).toContain("$ ls"); - const progress: TranscriptDelta = { - kind: "tool_progress", - streamId: "stream_tool", - toolUseId: "toolu_ls", - delta: "First file\n", - offset: 0, - }; - f.delta(progress); - expect(f.live.tools?.[0]?.output).toBe("First file\n"); - expect(text()).toContain("First file"); - f.delta({ - ...progress, - kind: "command_output", - delta: "", - snapshot: { content: "First file\nSecond file" }, - }); - expect(text()).toContain("Second file"); - f.apply([ - row("tool_result", { - toolUseId: "toolu_ls", - content: "\u001b]52;c;SGVsbG8=\u0007Finished", - }), - ]); - const rendered = component.render(80).join("\n"); - expect(rendered).toContain(theme.getBgAnsi("toolSuccessBg")); - expect(rendered).not.toContain(theme.getBgAnsi("toolPendingBg")); - expect(rendered).toContain("Finished"); - expect(rendered).not.toContain("\u001b]52"); - expect(rendered).not.toContain("Second file"); - // The result took the slot's place, so later progress has nowhere to go. - expect(f.live.tools).toEqual([]); - expect(f.delta({ ...progress, delta: "late output", offset: 22 })).toBe( - false, - ); - expect(f.entries()).toHaveLength(1); - }); - test("tool calls get the card their kind has in the web app", () => { const f = fold(); const call = (id: string, name: string, input: unknown, output = "") => [ @@ -718,170 +434,6 @@ describe("Session view entries", () => { ); }); - test("a sub-agent works in a child conversation that its call's details name", () => { - const f = fold(); - const text = (entries: readonly Entry[] | undefined) => - (entries ?? []).map((entry) => f.text(entry)).join("\n"); - f.apply([ - row("tool_call", { - toolUseId: "toolu_task", - name: "Task", - input: { description: "Find flaky tests", subagent_type: "Explore" }, - }), - row("task", { - taskId: "task_1", - taskKind: "agent", - phase: "started", - toolUseId: "toolu_task", - }), - ]); - const [card] = f.entries(); - f.apply([ - row("tool_call", { - toolUseId: "toolu_grep", - name: "Grep", - input: { pattern: "sleep(" }, - parentToolUseId: "toolu_task", - }), - ]); - // The running call names the child in its details, as a Pi Durable - // subagent tool does with api.details(). - expect(f.live.tools).toEqual([ - { - callId: "toolu_task", - name: "Task", - status: "running", - details: { conversationId: 1 }, - }, - ]); - const child = f.subagent(1); - expect(Number(child?.conversation)).toBe(1); - // The child's running tool is a slot of its own live document. - expect(child?.live.tools).toEqual([ - { callId: "toolu_grep", name: "Grep", status: "running" }, - ]); - f.delta({ - kind: "text", - streamId: "child_stream", - rowId: "child_row", - delta: "Reading the suite", - offset: 0, - delegatedTaskId: "task_1", - }); - f.apply([ - row("tool_result", { - toolUseId: "toolu_grep", - content: "tests/cart.test.ts:12", - parentToolUseId: "toolu_task", - }), - // A sub-agent of the sub-agent works in a conversation of its own too. - row("tool_call", { - toolUseId: "toolu_inner", - name: "Task", - input: { description: "Check the cart test" }, - parentToolUseId: "toolu_task", - }), - row("text", { - content: "The cart test sleeps", - eventId: "inner_text", - finalized: true, - parentToolUseId: "toolu_inner", - }), - ]); - expect(text(child?.current())).toContain("Reading the suite"); - expect(text(child?.current())).toContain("tests/cart.test.ts:12"); - expect(child?.live.tools).toEqual([ - { - callId: "toolu_inner", - name: "Task", - status: "running", - details: { conversationId: 2 }, - }, - ]); - expect(text(f.subagent(2)?.current())).toContain("The cart test sleeps"); - // The session's own entries never change for a child's rows. - expect(f.entries()).toEqual([card!]); - expect(f.entries()[0]).toBe(card!); - expect(text(f.all())).not.toContain("tests/cart.test.ts:12"); - // The card lists the child's work, as Pi's subagent card does. - const drawn = shown(f.shown(card!)); - expect(drawn).toContain("→ Grep sleep("); - expect(drawn).toContain("Reading the suite"); - expect(drawn).toContain("→ Task Check the cart test"); - expect(drawn).not.toContain("The cart test sleeps"); - // The result keeps the running details, so the card still finds the child. - f.apply([row("tool_result", { toolUseId: "toolu_task", content: "Done" })]); - const result = messageOf(f.all().at(-1)!); - expect(result?.role === "toolResult" && result.details).toEqual({ - conversationId: 1, - }); - expect(f.live.tools).toEqual([]); - expect(shown(f.shown(card!))).toContain("→ Grep sleep("); - }); - - test("a sub-agent's card shows its latest ten items until tools are expanded", () => { - const f = fold(); - f.apply([ - row("tool_call", { - toolUseId: "toolu_task", - name: "Task", - input: { description: "Read every file" }, - }), - ...Array.from({ length: 12 }, (_, index) => - row("tool_call", { - toolUseId: `toolu_read_${index + 1}`, - name: "Read", - input: { file_path: `file-${index + 1}.ts` }, - parentToolUseId: "toolu_task", - }), - ), - ]); - const [card] = f.entries(); - const collapsed = shown(f.shown(card!)); - expect(collapsed).toContain("... 2 earlier items"); - expect(collapsed).toContain("to expand"); - expect(collapsed).not.toContain("file-2.ts"); - expect(collapsed).toContain("file-3.ts"); - expect(collapsed).toContain("file-12.ts"); - const expanded = shown(f.shown(card!), true); - expect(expanded).not.toContain("earlier items"); - expect(expanded).toContain("file-1.ts"); - }); - - test("a failed tool shows its error and a result without text shows its value", () => { - const f = fold(); - f.apply([ - row("tool_call", { - toolUseId: "toolu_read", - name: "read", - input: { path: "README.md" }, - }), - row("tool_result", { - toolUseId: "toolu_read", - content: JSON.stringify({ error: "Missing file" }), - isError: true, - }), - row("tool_call", { - toolUseId: "toolu_search", - name: "search", - input: { query: "tests" }, - }), - row("tool_result", { toolUseId: "toolu_search", result: { hits: 2 } }), - ]); - const [failed, found] = f.entries(); - expect(messageOf(f.shown(failed!).result!)).toMatchObject({ - role: "toolResult", - isError: true, - }); - const rendered = raw(f.shown(failed!)); - expect(rendered).toContain(theme.getBgAnsi("toolErrorBg")); - const text = stripVTControlCharacters(rendered); - expect(text).toContain("Read README.md"); - expect(text).toContain("Missing file"); - expect(text).not.toContain('"error"'); - expect(shown(f.shown(found!))).toContain('"hits": 2'); - }); - test("a question is a tool call that shows its options and becomes answered after its result", () => { const f = fold(); f.apply([ @@ -916,97 +468,6 @@ describe("Session view entries", () => { expect(f.text(answer!)).toContain("Which branch?: main"); }); - test("a stopped turn names the person who stopped it", () => { - const f = fold(); - const people = { members: [{ id: "bob", name: "Bob" }], viewerId: "alice" }; - f.apply([ - row("interrupted", { cause: "user_stop" }, { authorId: "bob" }), - row("interrupted", { cause: "system" }), - ]); - const [stopped, interrupted] = f.entries(); - expect(f.text(stopped!, people)).toBe("Session\nStopped by Bob"); - expect(shown(f.shown(stopped!, people))).toContain("Stopped by Bob"); - expect(f.text(interrupted!)).toBe("Session\nInterrupted"); - }); - - test("errors show their message and compaction tombstones are skipped", () => { - const f = fold(); - f.apply([row("compaction", { tombstone: true })]); - expect(f.entries()).toEqual([]); - f.apply([ - row("error", { content: "Rate limited" }), - row("error", {}), - row("compaction", {}), - ]); - expect(f.entries().map((entry) => f.text(entry))).toEqual([ - "Assistant\nError: Rate limited", - "Assistant\nError: The turn failed", - "Session\nContext compacted", - ]); - expect(shown(f.shown(f.entries()[0]!))).toContain("Rate limited"); - }); - - test("ignores rows and messages for another session and unknown row kinds", () => { - const f = fold(); - f.apply([ - row("text", { content: "Elsewhere" }, { sessionId: "other" }), - row("session_status", { status: "idle" }), - ]); - f.messages([input("Elsewhere", { sessionId: "other", status: "queued" })]); - expect(f.all()).toEqual([]); - }); - - test("applies rows in transcript order whatever order they arrive in", () => { - const f = fold(); - const first = row("text", { content: "First", eventId: "one" }); - const second = row("text", { content: "Second", eventId: "two" }); - f.apply([second, first]); - expect(f.entries().map((entry) => f.text(entry))).toEqual([ - "Assistant\nFirst", - "Assistant\nSecond", - ]); - f.apply([row("text", { content: "Third", eventId: "three" })]); - f.apply([second]); - expect(f.entries().map((entry) => f.text(entry))).toEqual([ - "Assistant\nFirst", - "Assistant\nSecond", - "Assistant\nThird", - ]); - // Entry IDs grow with the entries, the way a storage assigns them. - expect(f.entries().map((entry) => Number(entry.id))).toEqual([1, 2, 3]); - }); - - test("bounds the live entries to the newest ones", () => { - const f = fold({ maxEntries: 3, maxCharacters: 10_000 }); - const rows = Array.from({ length: 10 }, (_, i) => - row("user", { content: `Message ${i}: ${"x".repeat(1_000)}` }), - ); - f.apply(rows); - expect(f.entries().map(key)).toEqual( - rows.slice(7).map((one) => `row:${one.id}`), - ); - expect(f.text(f.entries().at(-1)!)).toContain("Message 9"); - // A character budget also drops the oldest entries first. - const small = fold({ maxCharacters: 2_500 }); - small.apply(rows); - expect(small.entries()).toHaveLength(2); - expect(small.text(small.entries()[0]!)).toContain("Message 8"); - }); - - test("keeps full messages across a hundred-row page", () => { - const f = fold(); - const body = `${"Complete response. ".repeat(800)}THE END`; - f.apply(Array.from({ length: 100 }, () => row("user", { content: body }))); - expect(f.entries()).toHaveLength(100); - expect( - f.entries().every((entry) => f.text(entry).endsWith("THE END")), - ).toBe(true); - expect(messageOf(f.entries()[0]!)).toMatchObject({ - role: "user", - content: body, - }); - }); - test("strips terminal control sequences from people's names and text", () => { const f = fold(); f.apply([ @@ -1027,40 +488,4 @@ describe("Session view entries", () => { expect(stripVTControlCharacters(rendered)).toContain("Hello red"); expect(stripVTControlCharacters(rendered)).toContain("Eve"); }); - - test("repaints a card in the new theme after Pi invalidates it", () => { - const active = () => - (globalThis as Record)[ - Symbol.for("@earendil-works/pi-coding-agent:theme") - ]!; - // Pi hands renderers a live view of its active theme, as this proxy does. - const live = new Proxy({} as Theme, { - get: (_target, key) => Reflect.get(active(), key), - }); - const f = fold(); - f.apply([row("user", { content: "Hello" }, { authorId: "alice" })]); - const one = f.shown(f.entries()[0]!, { - members: [{ id: "alice", name: "Alice" }], - }); - const card = createEntryComponent( - () => one, - false, - live, - NO_TERMINAL, - "/work", - ); - try { - const dark = active().getFgAnsi("userMessageText"); - expect(card.render(100).join("\n")).toContain(dark); - initTheme("light", false); - const light = active().getFgAnsi("userMessageText"); - expect(light).not.toBe(dark); - card.invalidate(); - const rendered = card.render(100).join("\n"); - expect(rendered).toContain(light); - expect(rendered).not.toContain(dark); - } finally { - initTheme("dark", false); - } - }); }); diff --git a/tests/workspace/api.test.ts b/tests/workspace/api.test.ts index c315b99..d1a2937 100644 --- a/tests/workspace/api.test.ts +++ b/tests/workspace/api.test.ts @@ -1,12 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { rejects } from "node:assert/strict"; -import { - eventually, - invocation, - MEMBER, - SESSION, - workspaceFixture, -} from "./fixture"; +import { eventually, MEMBER, SESSION, workspaceFixture } from "./fixture"; // Leverage starts a session with its first message. const FIRST = { content: "Map the modules" }; @@ -24,42 +18,6 @@ function fixture(extra?: Parameters[0]) { return value; } -test("creating a session sends its title with its first message", async () => { - const f = fixture(); - const api = f.client(); - const draft = api.draft(); - draft.title = " Named draft "; - expect(await api.create(draft, FIRST, new AbortController().signal)).toBe( - SESSION, - ); - const create = f.frames.find((frame) => frame.type === "session.create")!; - expect(create.content).toBe("Map the modules"); - expect(create.title).toBe("Named draft"); - expect(create.client).toBe("pi"); - expect(f.session.title).toBe("Named draft"); - // A created draft reuses its session instead of asking for another. - expect(await api.create(draft, FIRST, new AbortController().signal)).toBe( - SESSION, - ); - expect(f.state.createCount).toBe(1); -}); - -test("a default-model session is created even when the model catalog is missing", async () => { - const f = fixture((request) => - new URL(request.url).pathname.endsWith("/provider-models") - ? new Response("Not found", { status: 404 }) - : undefined, - ); - const api = f.client(); - expect( - await api.create(api.draft(), FIRST, new AbortController().signal), - ).toBe(SESSION); - expect(f.state.createCount).toBe(1); - expect(f.requests.some((r) => r.path.endsWith("/provider-models"))).toBe( - false, - ); -}); - test("a missing Pi choice leaves out nothing, and a malformed one fails", async () => { const f = fixture(); const api = f.client(); @@ -108,29 +66,6 @@ test("a lost creation acknowledgement retries the stable request ID without dupl expect(new Set(ids).size).toBe(1); }); -test("revalidates removed channels, unavailable providers, and unsupported reasoning before creating", async () => { - const f = fixture(); - const api = f.client(); - const draft = api.draft(); - const signal = new AbortController().signal; - draft.context = { type: "channel", channelId: "removed" }; - await rejects(api.create(draft, FIRST, signal), /channel/); - draft.context = { type: "none" }; - draft.providerFamily = "codex"; - await rejects(api.create(draft, FIRST, signal), /unavailable/); - draft.providerFamily = "claude_code"; - draft.model = "hosted-model"; - draft.reasoningEffort = "impossible"; - await rejects(api.create(draft, FIRST, signal), /reasoning/); - draft.model = "retired-model"; - await rejects(api.create(draft, FIRST, signal), /no longer available/); - expect(f.state.createCount).toBe(0); - draft.model = "hosted-model"; - draft.reasoningEffort = "high"; - await api.create(draft, FIRST, signal); - expect(f.state.createCount).toBe(1); -}); - test("a workspace with no subscription starts sessions on Leverage's own models", async () => { const f = fixture((request) => new URL(request.url).pathname.endsWith("/provider-access/availability") @@ -227,34 +162,6 @@ test("archive and restore wait for Leverage to apply them, and report a refusal" await rejects(readOnly.archive(SESSION, signal), /Only writers/); }); -test("reads the model catalog and sends a decision", async () => { - const f = fixture(); - const api = f.client(); - const pending = invocation(); - f.approvals.push(pending); - const decided = await api.decide(pending.id, { - action: "approve", - approvalScope: "session", - }); - expect(decided.state).toBe("approved"); - expect(f.requests.at(-1)?.body).toEqual({ - action: "approve", - approvalScope: "session", - }); - expect((await api.models()).map((one) => one.id)).toEqual(["hosted-model"]); -}); - -test("uploads a file in one piece and returns its attachment ID", async () => { - const f = fixture(); - const api = f.client(); - const id = await api.upload({ - filename: "shot.png", - contentType: "image/png", - bytes: new Uint8Array([1, 2, 3, 4]), - }); - expect(f.uploads).toEqual([{ id, bytes: 4, completed: true }]); -}); - test("both transports use the same refreshed credential and native responses are validated", async () => { const f = fixture(); const api = f.client("expired"); diff --git a/tests/workspace/socket.test.ts b/tests/workspace/socket.test.ts index 382b447..5f7a8c6 100644 --- a/tests/workspace/socket.test.ts +++ b/tests/workspace/socket.test.ts @@ -50,96 +50,6 @@ const bob = input("From Bob", { status: "queued", }); -test("says it is Pi, so Leverage shows Pi on the person's face", async () => { - const f = fixture(); - const socket = await f.client().socket(); - await socket.connect(); - expect(f.clients).toEqual(["pi"]); -}); - -test("reconnects with its replay cursor and drops repeated frames", async () => { - const f = fixture(); - const api = f.client(); - const socket = await api.socket(); - await socket.connect(); - socket.subscribe(SESSION, 4); - const seen: string[] = []; - socket.onEvent((event) => { - if (event.type === "session.messages.updated") { - seen.push(event.messages[0]?.content ?? ""); - } - }); - const frame = { - type: "session.messages.updated", - sessionId: SESSION, - version: 1, - messages: [bob], - _topic: "session", - cursor: 5, - }; - f.publish(frame); - f.publish(frame); - await eventually(() => seen.length === 1); - for (const connection of f.connections) { - connection.close(); - } - await eventually( - () => f.frames.filter((e) => e.type === "session.subscribe").length >= 2, - ); - expect( - f.frames.filter((e) => e.type === "session.subscribe").at(-1)?.afterCursor, - ).toBe(5); - f.publish(frame); - f.publish({ - ...frame, - cursor: 6, - version: 2, - messages: [{ ...bob, content: "Updated" }], - }); - await eventually(() => seen.length === 2); - expect(seen).toEqual(["From Bob", "Updated"]); -}); - -test("live rows, streamed text and status changes reach the conversation", async () => { - const { f, shared, states } = await open(); - expect(shared.value.docs["pi.live"]?.run !== undefined).toBe(false); - f.update({ status: "active", turnId: "turn_1" }); - await eventually(() => shared.value.docs["pi.live"]?.run !== undefined); - f.emit("user", { content: "Hi" }, { authorId: "owner" }); - const row = f.emit("text", { - role: "assistant", - content: "", - eventId: "stream_1", - }); - f.publish({ - type: "session.event.delta", - sessionId: SESSION, - delta: { - kind: "text", - streamId: "stream_1", - eventId: "stream_1", - rowId: row.id, - delta: "Hello there", - offset: 0, - }, - }); - await eventually(() => - shared.value.entries.some((one) => - JSON.stringify(one.model).includes("Hello there"), - ), - ); - f.update({ status: "idle", turnId: null }); - await eventually(() => shared.value.docs["pi.live"]?.run === undefined); - const { entries } = shared.value; - expect(entries.map((one) => one.kind)).toEqual(["pi.user", "pi.assistant"]); - // Revisions reach a subscriber in order, each a new value, and one without - // an entry change keeps its entries. - await eventually(() => states.at(-1) === shared.value); - expect(new Set(states).size).toBe(states.length); - expect(states.at(-1)?.entries).toBe(entries); - expect(states.at(-2)?.entries).toBe(entries); -}); - test("tracks approvals and questions until someone resolves them", async () => { const { f, shared } = await open(); const pending = invocation(); @@ -208,37 +118,6 @@ test("revocation clears the conversation; workspace viewers cannot write", async expect(errors).toEqual([]); }); -test("a stale read cannot overwrite a newer message update", async () => { - const { f, shared } = await open(); - let release!: () => void; - f.state.readDelay = new Promise((resolve) => { - release = resolve; - }); - const refreshing = shared.refresh(); - f.publish({ - type: "session.messages.updated", - sessionId: SESSION, - version: 5, - messages: [bob], - }); - await eventually(() => shared.doc.version === 5); - f.state.version = 1; - f.state.nativeMessages = [{ ...bob, content: "Stale", status: "received" }]; - release(); - await refreshing; - expect(shared.input(bob.uuid)?.content).toBe("From Bob"); - expect(shared.input(bob.uuid)?.client).toBe("codex"); - shared.close(); - f.publish({ - type: "session.messages.updated", - sessionId: SESSION, - version: 9, - messages: [{ ...bob, content: "Wrong view" }], - }); - await Bun.sleep(20); - expect(shared.input(bob.uuid)?.content).toBe("From Bob"); -}); - test("a read that overlaps a live approval keeps it", async () => { const { f, shared } = await open(); // Startup may still be reading. Only the read below should overlap the frame. @@ -261,49 +140,6 @@ test("a read that overlaps a live approval keeps it", async () => { expect(shared.value.docs["leverage.approvals"].items).toHaveLength(1); }); -test("shows who has the session open, who is typing, and who is online", async () => { - const { f, shared } = await open(); - const doc = () => shared.doc; - // Joining tells the server the person is watching, and lists them back. - await eventually(() => - f.frames.some((one) => one.type === "session.subscribe"), - ); - expect(f.frames.find((one) => one.type === "session.subscribe")).toEqual({ - type: "session.subscribe", - sessionId: SESSION, - afterCursor: expect.any(Number), - state: "active", - }); - await eventually(() => doc().viewers.length === 1); - expect(doc().viewers).toEqual([ - { userId: "owner", userName: "Alice", state: "active" }, - ]); - expect(doc().presence.owner).toMatchObject({ status: "online" }); - f.viewer(MEMBER, true); - f.typing(MEMBER, true); - f.presence(MEMBER, "online", ["codex"]); - await eventually( - () => doc().typing.length === 1 && doc().presence[MEMBER] !== undefined, - ); - expect(doc().viewers.map((one) => one.userId)).toEqual(["owner", MEMBER]); - expect(doc().typing).toEqual([MEMBER]); - expect(doc().presence[MEMBER]).toMatchObject({ - status: "online", - clients: ["codex"], - }); - // Stepping away keeps the row but marks it, and leaving drops it with its typing. - f.viewer(MEMBER, true, "idle"); - await eventually(() => doc().viewers[1]?.state === "idle"); - f.viewer(MEMBER, false); - await eventually(() => doc().viewers.length === 1); - expect(doc().typing).toEqual([]); - // Workspace presence survives a fresh read, and an away status replaces online. - f.presence(MEMBER, "away"); - await eventually(() => doc().presence[MEMBER]?.status === "away"); - await shared.refresh(); - expect(doc().presence[MEMBER]?.status).toBe("away"); -}); - test("a quiet person goes idle and stops heartbeats until they do something", async () => { const f = fixture(); const api = f.client();