diff --git a/packages/acp-client/index.ts b/packages/acp-client/index.ts index 631bfd06..4ca530fa 100644 --- a/packages/acp-client/index.ts +++ b/packages/acp-client/index.ts @@ -1,4 +1,10 @@ -export { attachmentUri, FileNotTaken, type PromptFile, promptBlocks } from "./prompt-blocks"; +export { + attachmentUri, + FileNotTaken, + type PromptFile, + type PromptLink, + promptBlocks, +} from "./prompt-blocks"; export { type ConnectOptions, connectSession, diff --git a/packages/acp-client/prompt-blocks.ts b/packages/acp-client/prompt-blocks.ts index 6f149a91..86247659 100644 --- a/packages/acp-client/prompt-blocks.ts +++ b/packages/acp-client/prompt-blocks.ts @@ -9,6 +9,15 @@ import type * as acp from "@agentclientprotocol/sdk"; +/** + * A file the prompt names by its address rather than sends: a `resource_link`, which the agent + * reads itself. ACP requires every agent to take one, so no capability is asked for. + */ +export interface PromptLink { + readonly uri: string; + readonly name: string; +} + /** What a prompt needs of a file: a browser's `File` has all of it. */ export type PromptFile = Pick; @@ -71,16 +80,21 @@ async function blockFor( } /** - * The blocks for `text` and `files`, in that order. A prompt of files alone has no text block. - * Rejects with `FileNotTaken` for a file the agent takes in no form, so no file is left out. + * The blocks for `text`, `links` and `files`, in that order. A prompt of links or files alone has + * no text block. Rejects with `FileNotTaken` for a file the agent takes in no form, so no file is + * left out. */ export async function promptBlocks( text: string, files: readonly PromptFile[], takes: acp.PromptCapabilities, + links: readonly PromptLink[] = [], ): Promise { const attached = await Promise.all(files.map((file) => blockFor(file, takes))); + const linked = links.map( + ({ uri, name }): acp.ContentBlock => ({ type: "resource_link", uri, name }), + ); const said: acp.ContentBlock[] = - text === "" && attached.length > 0 ? [] : [{ type: "text", text }]; - return [...said, ...attached]; + text === "" && attached.length + linked.length > 0 ? [] : [{ type: "text", text }]; + return [...said, ...linked, ...attached]; } diff --git a/packages/acp-client/session.ts b/packages/acp-client/session.ts index c2ac74d9..51fe024e 100644 --- a/packages/acp-client/session.ts +++ b/packages/acp-client/session.ts @@ -15,7 +15,7 @@ import * as Client from "effective-acp/client"; import * as Http from "effective-acp/http"; import * as Protocol from "effective-acp/protocol"; import * as V1 from "effective-acp/schema/v1"; -import { type PromptFile, promptBlocks } from "./prompt-blocks"; +import { type PromptFile, type PromptLink, promptBlocks } from "./prompt-blocks"; export interface ConnectOptions { /** The agent's ACP endpoint. */ @@ -35,10 +35,11 @@ export interface SessionClient { /** What the agent takes in a prompt besides text, as its `initialize` answer advertises it. */ readonly promptCapabilities: acp.PromptCapabilities; /** - * Sends a prompt: `text`, and `files` as `promptBlocks` turns them into blocks. Resolves when the - * turn has ended; what happened arrives through `onEvent`, as does a file the agent does not take. + * Sends a prompt: `text`, `links` and `files` as `promptBlocks` turns them into blocks. Resolves + * when the turn has ended; what happened arrives through `onEvent`, as does a file the agent does + * not take. */ - prompt(text: string, files?: readonly PromptFile[]): Promise; + prompt(text: string, files?: readonly PromptFile[], links?: readonly PromptLink[]): Promise; /** * Asks the agent to stop the turn (`session/cancel`), then answers every permission request still * open as `cancelled`, as ACP requires of a client that cancels a turn. The prompt then resolves @@ -313,10 +314,10 @@ export async function connectSession(options: ConnectOptions): Promise { expect(decodeURIComponent(uri.split("/").at(-1) ?? "")).toBe("Δ loss.csv"); }); }); + +describe("files a prompt names by address", () => { + test("each is a resource_link, after the text and before attached files, whatever the agent takes", async () => { + const blocks = await promptBlocks( + "look at @src/a.ts", + [new File(["a"], "a.txt", { type: "text/plain" })], + BOTH, + [{ uri: "file:///work/src/a.ts", name: "src/a.ts" }], + ); + expect(blocks.map((b) => b.type)).toEqual(["text", "resource_link", "resource"]); + expect(blocks[1]).toEqual({ + type: "resource_link", + uri: "file:///work/src/a.ts", + name: "src/a.ts", + }); + const plain = await promptBlocks("x", [], {}, [{ uri: "file:///w/b", name: "b" }]); + expect(plain.map((b) => b.type)).toEqual(["text", "resource_link"]); + }); + + test("a prompt of links alone has no text block", async () => { + const blocks = await promptBlocks("", [], {}, [{ uri: "file:///w/b", name: "b" }]); + expect(blocks).toEqual([{ type: "resource_link", uri: "file:///w/b", name: "b" }]); + }); +}); diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index bd66f43a..bc7cdf2a 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -56,6 +56,7 @@ collections, `file` (type `File`) and `commit` (type `Commit`). | `/workspace/{slug}/commit/{id}/diff/{path}` | One changed file's diff: its status and unified diff, cut at 20,000 characters (`patch_truncated`). | | `/workspace/{slug}/file/{path}` | The tree at HEAD: a directory as a collection of its entries, a file as a resource with its text, cut at 100,000 characters (`content_truncated`). A listed file carries `media_type` only when the extension table names its extension; the file's own resource always carries it. | | `/workspace/{slug}/commit/{id}/file/{path}` | The same, at that commit. | +| `/workspace/{slug}/paths` | Every file's path in the tree at HEAD, in name order, as a `paths` array: what the chat offers to mention. Directories and submodules are not listed. At most 5,000 paths; `total` counts them all and `truncated` says when some are left out. The top directory at HEAD links it as `paths`. | | `/workspace/{slug}/raw/{path}` | A file's committed bytes at HEAD, as its media type. A file over 20 MiB gets a 413 that names its size. | | `/workspace/{slug}/commit/{id}/raw/{path}` | The same, at that commit. | | `/workspace/{slug}/commit/{id}/changes` | The files the commit changed, with their status, paged. | @@ -132,7 +133,7 @@ Text that is not UTF-8, or a binary file of a text type, gets the note. |--------|-------| | Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest and a filter box that narrows the listed items. | | Centre | The graph, a card that folds: the resources opened in the workspace since its last reset and what they relate to, on a 3D canvas (`GraphView`). Clicking a node opens its resource. Below it, the open resource. Tabs: Overview (properties, then a card for each related resource, outbound then inbound), Debug, drawn as a wrench (the response; the events of the record, or of the acts that ran against the commit, each a line with chips for the records it names; and the commits those acts ran against). `?tab=` names the tab. The tabs and Reload sit at the right of the line with the resource's name. | -| Right | In development, in a workspace: the agent's chat. At full width it slides open and shut beside a rail that stays; the rail's button expands or minimises it. In a window 860 px wide or narrower it is a drawer over the page from the right, opened by a button in the top bar. Its sessions open in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/`). A workspace's session connects the first time the chat is shown in that workspace, and stays connected while the chat is hidden; a workspace the chat was never shown in has no session. Each workspace keeps its session for the life of the page: leaving it and coming back reopens that session. `?session=` names the session of the workspace in scope, so a reload reopens it. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place, and `/sessions` lists the sessions the agent keeps for the workspace's folder (ACP `session/list` by `cwd`), newest first, in a palette; picking one reopens it. The border at the chat's left edge moves by drag, by Left and Right (16 px), and by Home and End (to 320 px, and to 60% of the window); a double-click returns it to 400 px, and the browser keeps the width. | +| Right | In development, in a workspace: the agent's chat. At full width it slides open and shut beside a rail that stays; the rail's button expands or minimises it. In a window 860 px wide or narrower it is a drawer over the page from the right, opened by a button in the top bar. Its sessions open in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/`). A workspace's session connects the first time the chat is shown in that workspace, and stays connected while the chat is hidden; a workspace the chat was never shown in has no session. Each workspace keeps its session for the life of the page: leaving it and coming back reopens that session. `?session=` names the session of the workspace in scope, so a reload reopens it. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place, and `/sessions` lists the sessions the agent keeps for the workspace's folder (ACP `session/list` by `cwd`), newest first, in a palette; picking one reopens it. `@` offers the workspace's files at HEAD (`/paths`); each file a message mentions is sent with it as a `resource_link` to the file in the workspace's folder (`file:///`), which the agent reads itself. The border at the chat's left edge moves by drag, by Left and Right (16 px), and by Home and End (to 320 px, and to 60% of the window); a double-click returns it to 400 px, and the browser keeps the width. | The top bar holds the LabKit mark and name, which are one button that opens the list of workspaces, and the theme button, which has no border or background. The browser's own Back and Forward buttons diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index c40511cf..c4acd795 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -1574,4 +1574,35 @@ test.describe("the agent's chat in the Trace Console", () => { await expect(page).toHaveURL(new RegExp(`[?&]session=${first}(&|$)`)); await expect(chat.locator(".agent-status")).toContainText("ready"); }); + + test("@ offers the workspace's files; a mentioned file is sent as a link to it in the agent's folder", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the agent exists only in development"); + const prompts: string[] = []; + page.on("request", (req) => { + const body = req.postData(); + if (req.url().endsWith("/acp") && body?.includes('"session/prompt"')) prompts.push(body); + }); + await page.goto("/app/workspace/alpha/Q_1"); + const folder = await page.evaluate(async () => { + const res = await fetch("/agent-workspace/alpha"); + return ((await res.json()) as { cwd: string }).cwd; + }); + const chat = page.getByRole("complementary", { name: "Agent" }); + await chatToggle(page).click(); + await expect(chat.locator(".agent-status")).toContainText("ready"); + const message = chat.getByRole("textbox", { name: "Message" }); + await message.fill("look at @README.m"); + await expect(chat.getByRole("option", { name: /README\.md/ })).toBeVisible(); + await message.press("Enter"); + await expect(message).toHaveValue(/^look at @README\.md\s*$/); + await message.press("Enter"); + await expect.poll(() => prompts.length).toBe(1); + const prompt = JSON.parse(prompts[0] ?? "{}").params.prompt; + expect(prompt).toEqual([ + { type: "text", text: "look at @README.md" }, + { type: "resource_link", uri: `file://${folder}/README.md`, name: "README.md" }, + ]); + }); }); diff --git a/packages/app-web/src/server/git-handler.ts b/packages/app-web/src/server/git-handler.ts index 0bab1abf..82f682ca 100644 --- a/packages/app-web/src/server/git-handler.ts +++ b/packages/app-web/src/server/git-handler.ts @@ -59,12 +59,16 @@ const RAW = /^(?:\/commit\/([0-9a-f]{40}|[0-9a-f]{64}))?\/raw\/(.+)$/; // The commits that changed a file, from HEAD, `/history/{path}`, or from a commit, // `/commit/{id}/history/{path}`. const HISTORY = /^(?:\/commit\/([0-9a-f]{40}|[0-9a-f]{64}))?\/history\/(.+)$/; +// Every file's path in the tree at HEAD, `/paths`. +const PATHS = /^\/paths\/?$/; +/** The most paths `/paths` lists; it says how many there are in all. */ +export const MAX_PATHS = 5000; // The tree at HEAD, `/file/{path}`, or at a commit, `/commit/{id}/file/{path}`. const IN_TREE = /^(?:\/commit\/([0-9a-f]{40}|[0-9a-f]{64}))?\/file(?:\/(.*))?$/; /** Whether `rest`, the path after `/workspace/{slug}`, names the workspace's files or history. */ export function isGitPath(rest: string): boolean { - return /^\/(commit|file|history|raw|branch|timeline)(\/|$)/.test(rest); + return /^\/(commit|file|history|raw|branch|timeline|paths)(\/|$)/.test(rest); } /** @@ -601,7 +605,14 @@ function typeOf(entry: TreeEntry): string { * BY_NAME order. `at` is the tree's address, `/file` or `/commit/{id}/file`; `up` is the parent * directory's, or the workspace's for the top. */ -function directory(req: Request, tree: Tree, at: string, path: string, up: string): Response { +function directory( + req: Request, + tree: Tree, + at: string, + path: string, + up: string, + more: Record = {}, +): Response { const url = new URL(req.url); const limit = pageParam(url.searchParams.get("limit"), DEFAULT_LIMIT, 1, MAX_LIMIT); const offset = pageParam(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER); @@ -617,6 +628,7 @@ function directory(req: Request, tree: Tree, at: string, path: string, up: strin _links: { self: { href: `${self}?limit=${limit}&offset=${offset}` }, ...Object.fromEntries(links.map((l) => [l.rel, { href: l.href }])), + ...more, }, path, offset, @@ -759,7 +771,16 @@ function inTree( const at = id === undefined ? `${base}/${FILE_SLUG}` : `${base}/${COMMIT_SLUG}/${id}/${FILE_SLUG}`; const path = decodedPath(encoded); - if (path === "") return directory(req, tree, at, "", base); + // The top of the tree at HEAD links every file's path, which the chat's mentions are made from. + if (path === "") + return directory( + req, + tree, + at, + "", + base, + id === undefined ? { paths: { href: `${base}/paths` } } : {}, + ); const entry = tree.getPath(path); if (entry === null) return problem(404, "Not Found", `${path} is not in ${slug} at ${commitId}`); const type = typeOf(entry); @@ -839,6 +860,42 @@ function rawFile( }); } +/** + * Every file's path in the tree at HEAD, in BY_NAME order of the whole path: what can be mentioned + * in a message to the agent. Directories and submodules are not listed. At most MAX_PATHS are + * listed; `total` counts them all, and `truncated` says when some are left out. + */ +function filePaths(req: Request, repo: Repository, base: string, slug: string): Response { + let tree: Tree; + try { + tree = repo.getCommit(repo.revparseSingle("HEAD")).tree(); + } catch (err) { + if (isNotFound(err)) return problem(404, "Not Found", `${slug}'s repository has no commits`); + throw err; + } + const paths: string[] = []; + const walk = (at: Tree, prefix: string) => { + for (const entry of at.iter()) { + const kind = entry.type(); + if (kind === "Tree") walk(repo.getTree(entry.id()), `${prefix}${entry.name()}/`); + else if (kind === "Blob") paths.push(`${prefix}${entry.name()}`); + } + }; + walk(tree, ""); + paths.sort((a, b) => BY_NAME.compare(a, b)); + const listed = paths.slice(0, MAX_PATHS); + return halJson(req, { + _links: { + self: { href: `${base}/paths` }, + index: { href: `${base}/${FILE_SLUG}` }, + }, + total: paths.length, + count: listed.length, + truncated: listed.length < paths.length, + paths: listed, + }); +} + /** * The workspace's files, git history and branches, read from `repo`, the repository mapped to it, * at the paths `isGitPath` names, where `rest` is the path after `/workspace/{slug}`. Without a @@ -876,6 +933,7 @@ export async function gitHandler( const fileHistoryOf = HISTORY.exec(rest); if (fileHistoryOf !== null) return fileHistory(req, repo, base, scope.slug, fileHistoryOf[1], fileHistoryOf[2] ?? ""); + if (PATHS.test(rest)) return filePaths(req, repo, base, scope.slug); const tree = IN_TREE.exec(rest); if (tree !== null) return inTree(req, repo, base, scope.slug, tree[1], tree[2] ?? ""); return problem(404, "Not Found", `${rest} names no commit or file in ${scope.slug}`); diff --git a/packages/app-web/src/ui/agent/AgentChat.tsx b/packages/app-web/src/ui/agent/AgentChat.tsx index 57989efe..024cc12f 100644 --- a/packages/app-web/src/ui/agent/AgentChat.tsx +++ b/packages/app-web/src/ui/agent/AgentChat.tsx @@ -1,11 +1,12 @@ import { SCENARIOS } from "@labkit/acp-scenarios"; -import { Conversation } from "@labkit/ui"; +import { Conversation, type PickItem } from "@labkit/ui"; import "@labkit/ui/ui.css"; import { AGENT_CWD, AGENT_URL } from "../agent-endpoint"; import { RECORD_TYPES } from "../record-types"; import { useSavedTheme } from "../saved-theme"; import { useState } from "react"; import { CONNECTING, NEW, resolveLink, SESSIONS, useAgentSession } from "./agent-session"; +import { mentionedLinks } from "./mentions"; import { SessionPicker } from "./SessionPicker"; /** @@ -19,6 +20,7 @@ export function AgentChat({ sessionId, onStarted, onPick, + mentions, shown = true, themeButton = false, }: { @@ -30,6 +32,8 @@ export function AgentChat({ onStarted: (sessionId: string) => void; /** Opens another session in this folder: `/sessions` lists them. Without it there is no `/sessions`. */ onPick?: (sessionId: string) => void; + /** The files a message can mention with `@`; each one mentioned is sent with it as a link. */ + mentions?: readonly PickItem[]; shown?: boolean; /** Whether the conversation draws its own theme button, for a page with no top bar. */ themeButton?: boolean; @@ -62,11 +66,12 @@ export function AgentChat({ theme={theme} {...(themeButton ? { onThemeChange: setTheme } : {})} hostCommands={onPick === undefined ? [NEW] : [NEW, SESSIONS]} + {...(mentions === undefined ? {} : { mentions })} {...(attach === undefined ? {} : { attach })} composerUnavailable={status === "connecting" ? CONNECTING : undefined} onSend={(text, files) => { if (onPick !== undefined && text.trim() === `/${SESSIONS.name}`) setPicking(true); - else send(text, files); + else send(text, files, mentions === undefined ? [] : mentionedLinks(text, mentions)); }} onCancel={cancel} onAnswer={answer} diff --git a/packages/app-web/src/ui/agent/agent-session.ts b/packages/app-web/src/ui/agent/agent-session.ts index 5103493f..bb5e6500 100644 --- a/packages/app-web/src/ui/agent/agent-session.ts +++ b/packages/app-web/src/ui/agent/agent-session.ts @@ -3,7 +3,7 @@ import type { PromptCapabilities, RequestPermissionOutcome, } from "@agentclientprotocol/sdk"; -import { connectSession, type SessionClient } from "@labkit/acp-client"; +import { connectSession, type PromptLink, type SessionClient } from "@labkit/acp-client"; import type { AttachLimits, ResolveLink } from "@labkit/ui"; import { initialState, reduce } from "@labkit/view-model"; import { useCallback, useEffect, useReducer, useRef, useState } from "react"; @@ -157,7 +157,7 @@ export function useAgentSession({ }, []); const send = useCallback( - (text: string, files: readonly File[]) => { + (text: string, files: readonly File[], links: readonly PromptLink[] = []) => { if (text.trim() === `/${NEW.name}`) { setLeft(sessionId); setStarted(undefined); @@ -171,7 +171,7 @@ export function useAgentSession({ }); return; } - void client.current.prompt(text, files); + void client.current.prompt(text, files, links); }, [sessionId], ); diff --git a/packages/app-web/src/ui/agent/mentions.ts b/packages/app-web/src/ui/agent/mentions.ts new file mode 100644 index 00000000..dea188a3 --- /dev/null +++ b/packages/app-web/src/ui/agent/mentions.ts @@ -0,0 +1,48 @@ +import type { PromptLink } from "@labkit/acp-client"; +import type { PickItem } from "@labkit/ui"; + +/** + * The `file:` URI of `path`, a file in `folder`, each segment escaped. This is an address for the + * agent, which reads the file from its working folder; it is not an address of the API. + */ +export function fileUri(folder: string, path: string): string { + const root = folder.replace(/\/+$/, ""); + const escaped = path.split("/").map(encodeURIComponent).join("/"); + return `file://${root.split("/").map(encodeURIComponent).join("/")}/${escaped}`; +} + +/** What can be mentioned in a message: each file at `paths` in `folder`, named by its path. */ +export function mentionItems(folder: string, paths: readonly string[]): PickItem[] { + return paths.map((path) => ({ id: fileUri(folder, path), label: path })); +} + +const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * The files `text` mentions, as links to send with it: each `@