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
33 changes: 33 additions & 0 deletions packages/app/e2e/regression/session-queue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ async function openQueue(
revert?: string,
status: "idle" | "running" = "running",
model = { id: "queue-model", name: "Queue Model" },
config: Pick<MockServerConfig, "beforeAgentsResponse"> = {},
) {
const options: Parameters<typeof openSession>[1] = {
name: "SessionQueueRegression",
Expand All @@ -191,6 +192,7 @@ async function openQueue(
onBackground: mock.onBackground,
shellCommands: () => mock.shells,
events: mock.events,
...config,
}

if (followUpBehavior) options.seed = { settings: { general: { followUpBehavior } } }
Expand Down Expand Up @@ -339,6 +341,37 @@ test("a steer shows as pending in the timeline as the composer clears, before th
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
})

test("a prompt sent while the session's agents and models load shows as pending at once and sends once they load", async ({
page,
}) => {
const mock = createQueueMock(
[],
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
)

// The Location's agents load only once released, so the composer has no agent or model to send with yet.
const agents = Promise.withResolvers<void>()

const view = await openQueue(page, mock, "steer", undefined, "idle", undefined, {
beforeAgentsResponse: () => agents.promise,
})

const prompt = page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
hasText: "read the deploy logs",
})

await expect(userRow(page, "msg_queue_delivered")).toContainText("First prompt")
await view.input.fill("read the deploy logs")
await view.input.press("Enter")
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
await expect(view.input).toHaveText("")
expect(mock.prompts).toEqual([])

agents.resolve()
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["read the deploy logs"])
await expect(prompt).toHaveCount(1)
})

for (const row of [
{ case: "a missed admission echo", reconnect: false },
{ case: "an event stream reconnect", reconnect: true },
Expand Down
30 changes: 17 additions & 13 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export interface MockServerConfig {
// `directory` is the requested `location[directory]`, such as an untracked folder the review opened.
vcsDiff?: unknown[] | ((input: { mode?: string; directory: string }) => unknown[])
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
beforeAgentsResponse?: () => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
onMessage?: (input: { sessionID: string; messageID: string }) => void
Expand Down Expand Up @@ -777,20 +778,23 @@ function mockHandlers(
}),
config: () => Effect.succeed(configEntries),
reference: () => Effect.succeed({ location: location(config), data: [] }),
// `beforeAgentsResponse` holds the Location's agent list, as a cold Location does while its plugins activate.
agent: (ctx) =>
Effect.succeed({
location: location(config, requestDirectory(config, ctx.request)),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
Effect.promise(async () => config.beforeAgentsResponse?.()).pipe(
Effect.as({
location: location(config, requestDirectory(config, ctx.request)),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
),
provider: () =>
Effect.succeed({ location: location(config), data: currentProviders(resolve(config.provider)) }),
model: () => Effect.succeed({ location: location(config), data: currentModels(resolve(config.provider)) }),
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/composer/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ export type ComposerQueue = {
export type ComposerSession = {
id: string
directory: string
// The timeline's optimistic prompt: shown on `set`, held after `settle` until its admission echo, removed on `clear`.
// The timeline's optimistic prompt: shown on `set` (or `hold` while the send waits for the Location's agents and models),
// held after `settle` until its admission echo, removed on `clear`.
handoff: {
set: (handoff: SessionMessageHandoff) => void
hold: (handoff: SessionMessageHandoff) => void
settle: (messageID: string) => void
clear: (messageID: string) => void
}
Expand Down
107 changes: 103 additions & 4 deletions packages/app/src/composer/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const image: ImageAttachmentPart = {
const session: ComposerSession = {
id: "session-1",
directory: "C:/repo",
handoff: { set() {}, settle() {}, clear() {} },
handoff: { set() {}, hold() {}, settle() {}, clear() {} },
api: {
command: async () => undefined,
shell: async () => undefined,
Expand Down Expand Up @@ -81,6 +81,7 @@ function snapshot(
commands: [],
clientCommand: undefined,
comments: [],
session: undefined,
...input,
}
}
Expand Down Expand Up @@ -121,6 +122,7 @@ const started = (attempt: number): ComposerSubmitEvent => ({

const states = {
held: replay([{ type: "submit", snapshot: snapshot({ selection: undefined, loading: true }), done }]).state,
shown: replay([{ type: "submit", snapshot: snapshot({ selection: undefined, loading: true, session }), done }]).state,
running: replay([{ type: "submit", snapshot: snapshot({ clientCommand: () => undefined }), done }]).state,
preparing: replay([{ type: "submit", snapshot: snapshot(), done }]).state,
"handing-off": replay([{ type: "submit", snapshot: snapshot(), done }, started(1)]).state,
Expand Down Expand Up @@ -202,7 +204,7 @@ describe("composer submit machine", () => {
test("holds a send while the Location loads, until stopped, edited, unmounted or loaded", () => {
const held = states.held

expect(held).toMatchObject({ type: "held", attempt: 1, draft: JSON.stringify(snapshot().draft.prompt) })
expect(held).toMatchObject({ type: "held", attempt: 1, shown: undefined })
expect(composerSubmitView(held)).toEqual({ held: true, busy: true, canSubmit: false })

const cancelled: ComposerSubmitTransition = {
Expand All @@ -213,7 +215,7 @@ describe("composer submit machine", () => {
expect(transitionComposerSubmit(held, { type: "stop" })).toEqual(cancelled)
expect(transitionComposerSubmit(held, { type: "unmounted" })).toEqual(cancelled)
expect(transitionComposerSubmit(held, { type: "hold.changed", attempt: 1 })).toEqual(cancelled)
expect(transitionComposerSubmit(held, { type: "hold.loaded", attempt: 1 })).toEqual({
expect(transitionComposerSubmit(held, { type: "hold.loaded", attempt: 1, selection, commands: [] })).toEqual({
state: { type: "idle", attempt: 1 },
effects: [{ type: "submit.retry", alternate: false, done }],
})
Expand All @@ -229,12 +231,109 @@ describe("composer submit machine", () => {
})
})

test("a prompt held while a Session's Location loads shows in its timeline at once and sends once it loads", () => {
const held = snapshot({ selection: undefined, loading: true, session })
const cleared: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]

// Enter shows the prompt and clears the composer before the agents and models load, with no request in flight.
const shown = replay([
{ type: "submit", snapshot: held, done },
{ type: "hold.cleared", attempt: 1, cleared },
// A new draft typed meanwhile is the user's, so it no longer cancels the send.
{ type: "hold.changed", attempt: 1 },
])

expect(shown.effects).toEqual([{ type: "hold.show", attempt: 1, session, snapshot: held }])
expect(shown.state).toMatchObject({ type: "held", attempt: 1, shown: { session, cleared } })
expect(composerSubmitView(shown.state)).toEqual({ held: true, busy: true, canSubmit: false })

const sent = replay(
[
{ type: "hold.loaded", attempt: 1, selection, commands: [{ name: "review" }] },
started(1),
{ type: "handoff.ready", attempt: 1, prompt: undefined },
],
shown.state,
)

// The send keeps the shown message's ID, records no second history entry, and leaves the new draft alone.
expect(sent.effects.map((effect) => effect.type)).toEqual(["session.start", "handoff", "composer.clear", "done"])
expect(sent.effects[0]).toMatchObject({ value: { id: held.id, selection, text: "ship it" } })
expect(sent.effects[2]).toMatchObject({ type: "composer.clear", cleared, value: { id: held.id } })
expect(sent.effects[3]).toEqual({ type: "done", done, sent: true })
})

test.each([
{ name: "Stop", event: { type: "stop" } },
{ name: "unmounting", event: { type: "unmounted" } },
] satisfies { name: string; event: ComposerSubmitEvent }[])(
"$name cancels a shown hold and returns its prompt to the composer",
(row) => {
const held = snapshot({ selection: undefined, loading: true, session })
const cleared: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]

const result = replay([
{ type: "submit", snapshot: held, done },
{ type: "hold.cleared", attempt: 1, cleared },
row.event,
{ type: "hold.loaded", attempt: 1, selection, commands: [] },
])

expect(result.state).toEqual({ type: "idle", attempt: 1 })
expect(result.effects.slice(1)).toEqual([
{ type: "hold.cancel", session, snapshot: held, cleared },
{ type: "done", done, sent: false },
])
},
)

test("a shown hold whose Location loads without a model returns its prompt and asks for one", () => {
const held = snapshot({ selection: undefined, loading: true, session })

const result = replay([
{ type: "submit", snapshot: held, done },
{ type: "hold.loaded", attempt: 1, selection: undefined, commands: [] },
])

expect(result.state).toEqual({ type: "idle", attempt: 1 })
expect(result.effects.slice(1)).toEqual([
{ type: "notify.missing-selection" },
{ type: "hold.cancel", session, snapshot: held, cleared: undefined },
{ type: "done", done, sent: false },
])
})

test.each([
{ name: "a slash command", input: { text: "/review" } },
{ name: "a shell command", input: { mode: "shell" } },
{ name: "a queued prompt", input: { delivery: "queue" } },
{ name: "a new-session draft", input: { session: undefined } },
] satisfies { name: string; input: Partial<SubmitSnapshot> }[])(
"$name held while the Location loads waits in the composer",
(row) => {
const result = replay([
{ type: "submit", snapshot: snapshot({ selection: undefined, loading: true, session, ...row.input }), done },
])

expect(result).toMatchObject({ state: { type: "held", shown: undefined }, effects: [] })
},
)

test.each([
{
name: "a late load after Stop",
events: [{ type: "stop" }, { type: "hold.loaded", attempt: 1 }],
events: [{ type: "stop" }, { type: "hold.loaded", attempt: 1, selection, commands: [] }],
from: "held",
},
{
name: "a late clear or load after a shown hold is stopped",
events: [
{ type: "stop" },
{ type: "hold.cleared", attempt: 1, cleared: [] },
{ type: "hold.loaded", attempt: 1, selection, commands: [] },
],
from: "shown",
},
{
name: "a start after an aborted one",
events: [{ type: "submit.aborted", attempt: 1 }, started(1)],
Expand Down
Loading
Loading