Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/acp-client/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
22 changes: 18 additions & 4 deletions packages/acp-client/prompt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<File, "name" | "type" | "arrayBuffer">;

Expand Down Expand Up @@ -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<acp.ContentBlock[]> {
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];
}
13 changes: 7 additions & 6 deletions packages/acp-client/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<void>;
prompt(text: string, files?: readonly PromptFile[], links?: readonly PromptLink[]): Promise<void>;
/**
* 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
Expand Down Expand Up @@ -313,10 +314,10 @@ export async function connectSession(options: ConnectOptions): Promise<SessionCl
return {
sessionId: id,
promptCapabilities,
async prompt(text, files = []) {
async prompt(text, files = [], links = []) {
let content: acp.ContentBlock[];
try {
content = await promptBlocks(text, files, promptCapabilities);
content = await promptBlocks(text, files, promptCapabilities, links);
} catch (err) {
onEvent({ type: "failed", message: describeError(err) });
return;
Expand Down
24 changes: 24 additions & 0 deletions packages/acp-client/tests/prompt-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,27 @@ describe("a prompt's files", () => {
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" }]);
});
});
3 changes: 2 additions & 1 deletion packages/app-web/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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/<slug>`). 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/<slug>`). 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://<folder>/<path>`), 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
Expand Down
31 changes: 31 additions & 0 deletions packages/app-web/e2e/app.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});
});
64 changes: 61 additions & 3 deletions packages/app-web/src/server/git-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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<string, { href: string }> = {},
): 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);
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
Expand Down
9 changes: 7 additions & 2 deletions packages/app-web/src/ui/agent/AgentChat.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -19,6 +20,7 @@ export function AgentChat({
sessionId,
onStarted,
onPick,
mentions,
shown = true,
themeButton = false,
}: {
Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 3 additions & 3 deletions packages/app-web/src/ui/agent/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -171,7 +171,7 @@ export function useAgentSession({
});
return;
}
void client.current.prompt(text, files);
void client.current.prompt(text, files, links);
},
[sessionId],
);
Expand Down
Loading
Loading