Compare commits

...
7 changed files with 96 additions and 11 deletions
+8
View File
@@ -0,0 +1,8 @@
# Slash-command follow-up behavior
Both screenshots submit `/review current changes` with the follow-up preference set to **Queue** while a session is running.
- `before.png`: production build of `v2` at `1dcc6551d9`; the command is sent as Steer.
- `after.png`: production build with this fix; the command appears in the queue above the composer.
Captured with the fixture-backed `slash commands respect queue preference and alternate submit` Playwright scenario in `packages/app/e2e/regression/session-queue.spec.ts`. The production UI is rendered against an isolated command/session fixture.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -139,9 +139,15 @@ async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>,
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
commands: [{ name: "review", description: "Review current changes" }],
onInboxChange: mock.onInboxChange,
events: mock.events,
})
await page.route(`**/api/session/${sessionID}/command`, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>
mock.onPrompt({ sessionID, body: { ...body, text: `Review ${body.text}` } })
await route.fulfill({ status: 204 })
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
@@ -167,6 +173,31 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
await expect(view.input).toHaveText("")
})
for (const behavior of ["queue", "steer"] as const) {
test(`slash commands respect ${behavior} preference and alternate submit`, async ({ page }, testInfo) => {
const mock = createQueueMock([])
const view = await openSession(page, mock, behavior)
await expect(view.input).toBeEditable()
await view.input.fill("/review current changes")
await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText(
behavior === "queue" ? "Steer" : "Queue",
)
await view.input.press("Enter")
await expect(page.getByText("Review current changes", { exact: true })).toBeVisible()
await expect(view.input).toHaveText("")
await page.screenshot({ path: testInfo.outputPath("slash-command-delivery.png") })
expect(mock.prompts.map((prompt) => prompt.delivery)).toEqual([behavior])
await expect(view.rows).toHaveCount(behavior === "queue" ? 1 : 0)
await view.input.fill("/review the retry path")
await view.input.press("ControlOrMeta+Enter")
await expect
.poll(() => mock.prompts.map((prompt) => prompt.delivery))
.toEqual([behavior, behavior === "queue" ? "steer" : "queue"])
await expect(view.rows).toHaveCount(1)
})
}
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
+2 -1
View File
@@ -38,6 +38,7 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
commands?: { name: string; description?: string }[]
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
}
@@ -254,7 +255,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: [] }),
command: () => Effect.succeed({ location: location(config), data: config.commands ?? [] }),
skill: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
+50
View File
@@ -65,12 +65,14 @@ function submitInput(
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
skills: () => readonly Skill.Info[] | undefined = () => [],
delivery?: Parameters<typeof createComposerSubmit>[0]["delivery"],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
skills,
delivery,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -128,6 +130,54 @@ function session(input: {
}
describe("Composer submission", () => {
test.each([
{ delivery: "queue" as const, alternate: false },
{ delivery: "steer" as const, alternate: false },
{ delivery: "queue" as const, alternate: true },
{ delivery: "steer" as const, alternate: true },
])("submits slash commands with $delivery delivery and alternate=$alternate", async ({ delivery, alternate }) => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["api"]["command"]>[0]>()
const target = session({
calls,
current: () => ({ agent: "plan", model: { id: "old", providerID: "old" } }),
prompt: async () => {
throw new Error("command must not call prompt")
},
command: async (request) => {
calls.push("command")
admitted.resolve(request)
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => true,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [{ name: "review" }],
undefined,
(value) => {
expect(value).toBe(alternate)
return delivery
},
).submit(new Event("submit"), { alternate })
expect(await admitted.promise).toMatchObject({ command: "review", text: "changes", delivery })
expect(calls).toEqual(delivery === "queue" ? ["command"] : ["switch-agent", "switch-model", "command"])
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
+5 -10
View File
@@ -122,15 +122,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -322,7 +316,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,