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
19 changes: 15 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
SessionView,
sameSession,
sessionLink,
uploadFiles,
viewRemoteHistory,
} from "./session";
import {
Expand Down Expand Up @@ -696,7 +697,7 @@ export default function leverage(pi: ExtensionAPI): void {
await createAndSend(prompt, ctx);
}
};
// The first prompt creates the empty session, opens it, then sends the prompt.
// The first prompt creates the session as its first message, then opens it.
const createAndSend = async (prompt: Prompt, ctx: ExtensionContext) => {
if (creating) {
throw new Error("Session setup is already in progress.");
Expand All @@ -723,7 +724,19 @@ export default function leverage(pi: ExtensionAPI): void {
pendingCreation = prompt;
status(ctx);
try {
const id = await workspace.create(draft, lifetime.signal);
// A created draft needs no upload: its session already has the message.
// Another attempt reuses the files the first one uploaded.
const attachmentIds = draft.sessionId
? []
: (prompt.attachmentIds ??= await uploadFiles(prompt, lifetime.signal));
const id = await workspace.create(
draft,
{
content: prompt.text,
...(attachmentIds.length ? { attachmentIds } : {}),
},
lifetime.signal,
);
rememberModel(api.connection, {
providerFamily: draft.providerFamily,
model: draft.model,
Expand All @@ -736,12 +749,10 @@ export default function leverage(pi: ExtensionAPI): void {
if (!view?.ready || view.session.id !== session.id) {
await attach(session, ctx);
}
// The session exists, so the open view sends the prompt from here on.
if (opening === generation) {
draft = undefined;
pendingCreation = undefined;
}
await requireSession(true).send(prompt);
} catch (error) {
if (opening === generation && pendingCreation === prompt) {
if (error instanceof UnconfirmedError) {
Expand Down
2 changes: 1 addition & 1 deletion src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ function person(theme: Theme, shown: Shown): Component {
DELIVERY[
data.steer && data.status === "queued" ? "steering" : (data.status ?? "")
] ?? [];
const app = SENT_FROM.get(data.harness ?? "");
const app = SENT_FROM.get(data.client ?? "");
card.addChild(
new Text(
[
Expand Down
32 changes: 20 additions & 12 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ export type Prompt = {
attachmentIds?: string[];
};

/** Uploads a prompt's files and returns their attachment IDs. */
export function uploadFiles(
prompt: Prompt,
signal: AbortSignal,
): Promise<string[]> {
return Promise.all(
(prompt.files ?? []).map((file) =>
workspace.upload(
{
filename: file.filename,
contentType: file.contentType,
bytes: Buffer.from(file.data, "base64"),
},
signal,
),
),
);
}

export interface ViewHooks {
// The status lines and footer need a redraw.
changed(): void;
Expand Down Expand Up @@ -553,18 +572,7 @@ export class SessionView {

// One attempt. Its attachments upload once, so another attempt reuses them.
private async deliver(prompt: Prompt): Promise<void> {
prompt.attachmentIds ??= await Promise.all(
(prompt.files ?? []).map((file) =>
workspace.upload(
{
filename: file.filename,
contentType: file.contentType,
bytes: Buffer.from(file.data, "base64"),
},
this.signal,
),
),
);
prompt.attachmentIds ??= await uploadFiles(prompt, this.signal);
const { attachmentIds } = prompt;
const next = this.nextModel;
const input = await workspace.send(
Expand Down
18 changes: 11 additions & 7 deletions src/workspace/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,11 +446,15 @@ export class WorkspaceClient {
}

/**
* Creates the empty session for a draft. The draft keeps the created ID, so
* a retry after a lost acknowledgement uses the same session instead of
* creating a second one.
* Creates a draft's session with its first message. The draft keeps the
* created ID, so a retry after a lost acknowledgement uses the same session
* instead of creating a second one.
*/
create(draft: SessionDraft, signal: AbortSignal): Promise<string> {
create(
draft: SessionDraft,
first: Pick<OutgoingMessage, "content" | "attachmentIds">,
signal: AbortSignal,
): Promise<string> {
const program = Effect.gen({ self: this }, function* () {
if (draft.sessionId) {
return draft.sessionId;
Expand All @@ -466,14 +470,14 @@ export class WorkspaceClient {
const request = sessionCreateSchema.parse({
type: "session.create",
clientRequestId: draft.requestId,
prompt: "",
...first,
...(title?.success ? { title: title.data } : {}),
context: draft.context,
providerFamily: draft.providerFamily,
model: draft.model,
reasoningEffort: draft.reasoningEffort,
checkoutStrategy: "clone",
harness: "pi",
client: "pi",
});
// The socket owns the "not confirmed" outcome, so the signal goes to it.
const reply = yield* Effect.tryPromise({
Expand Down Expand Up @@ -510,7 +514,7 @@ export class WorkspaceClient {
type: "session.message",
sessionId,
...message,
harness: "pi",
client: "pi",
});
const reply = await socket.request(
frame,
Expand Down
10 changes: 6 additions & 4 deletions src/workspace/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const inputSchema = z.object({
sessionId: z.string(),
authorId: z.string().nullable(),
authorName: z.string().nullable(),
harness: z.string().nullable(),
client: z.string().nullable(),
content: z.string(),
status: z.string(),
createdAt: z.string(),
Expand Down Expand Up @@ -469,14 +469,16 @@ export type WorkspaceEvent = z.infer<typeof eventSchema>;
export const sessionCreateSchema = z.object({
type: z.literal("session.create"),
clientRequestId: z.string().uuid(),
prompt: z.string(),
// The first message. Leverage needs its text or an attachment.
content: z.string(),
attachmentIds: z.array(z.string()).optional(),
title: z.string().optional(),
context: sessionContextSchema,
providerFamily: familySchema.optional(),
model: z.string().optional(),
reasoningEffort: z.string().optional(),
checkoutStrategy: z.literal("clone"),
harness: z.literal("pi"),
client: z.literal("pi"),
});

export const sessionMessageSchema = z.object({
Expand All @@ -488,7 +490,7 @@ export const sessionMessageSchema = z.object({
attachmentIds: z.array(z.string()).optional(),
model: z.string().optional(),
reasoningEffort: z.string().optional(),
harness: z.literal("pi"),
client: z.literal("pi"),
});

export const clientFrameSchema = z.discriminatedUnion("type", [
Expand Down
2 changes: 1 addition & 1 deletion src/workspace/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function editDraft(
await textDrawer(ctx, {
title: "Session setup is waiting",
read: () =>
"The empty session already exists. Its first message goes again until Leverage answers. Start a new session to use different settings.",
"The session already exists. Retry the original prompt to finish its setup. Start a new session to use different settings.",
signal,
});
return;
Expand Down
4 changes: 2 additions & 2 deletions src/workspace/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export interface EntryData {
status?: string;
// A waiting message that joins the running turn instead of waiting for it.
steer?: boolean;
harness?: string | null;
client?: string | null;
attachments?: Attachment[];
// A background command or agent keeps running after its call returns.
background?: boolean;
Expand Down Expand Up @@ -503,7 +503,7 @@ export class EntryFold {
...(input?.authorName ? { authorName: input.authorName } : {}),
...(input?.status ? { status: input.status } : {}),
...(input?.intent === "steer" ? { steer: true } : {}),
...(input?.harness !== undefined ? { harness: input.harness } : {}),
...(input?.client !== undefined ? { client: input.client } : {}),
...(attachments.length ? { attachments } : {}),
},
[user],
Expand Down
28 changes: 15 additions & 13 deletions tests/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ describe("Pi hosted frontend", () => {
).toBeUndefined();
});

test("the first prompt creates the draft's session with its title, then sends the prompt", async () => {
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);
Expand All @@ -633,24 +633,26 @@ describe("Pi hosted frontend", () => {
},
});
await command(runner, "new Explore the project");
await runner.emitInput("Map the modules", undefined, "interactive");
await runner.emitInput(
"Map the modules",
[{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
"interactive",
);
expect(f.state.createCount).toBe(1);
expect(f.sent("session.create")).toEqual([
expect.objectContaining({ title: "Explore the project", prompt: "" }),
// 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.message")).toEqual([
expect(f.sent("session.create")).toEqual([
expect.objectContaining({
sessionId: SESSION,
title: "Explore the project",
content: "Map the modules",
delivery: "queue",
attachmentIds: [f.uploads[0]?.id],
client: "pi",
}),
]);
// The create frame goes out before the message that needs its session.
expect(
f.frames.findIndex((frame) => frame.type === "session.create"),
).toBeLessThan(
f.frames.findIndex((frame) => frame.type === "session.message"),
);
// 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);
Expand Down
8 changes: 4 additions & 4 deletions tests/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ describe("Session view entries", () => {
const message = input("Canonical content", {
authorId: "bob",
authorName: "Old name",
harness: "codex",
client: "codex",
status: "consumed",
});
const people = { members: [{ id: "bob", name: "Bob" }], viewerId: "alice" };
Expand All @@ -222,10 +222,10 @@ describe("Session view entries", () => {
const text = shown(f.shown(canonical!, people));
expect(text).toContain("Bob");
expect(text).toContain("Sent from Codex");
const sentFrom = (harness: string) =>
const sentFrom = (client: string) =>
shown(
f.shown(
{ ...canonical!, data: { ...entryData(canonical!), harness } },
{ ...canonical!, data: { ...entryData(canonical!), client } },
people,
),
);
Expand All @@ -239,7 +239,7 @@ describe("Session view entries", () => {
expect(text).not.toContain("Old name");
expect(text).not.toContain("(you)");
expect(entryData(unknown!).authorId).toBeUndefined();
expect(entryData(unknown!).harness).toBeUndefined();
expect(entryData(unknown!).client).toBeUndefined();
expect(f.text(unknown!, people)).toStartWith("User\n");
});

Expand Down
Loading
Loading