Compare commits

...
12 changed files with 263 additions and 14 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,
+2
View File
@@ -173,6 +173,8 @@ const layer = Layer.effect(
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
// Positive globs override rg's hidden-file filter; exclude before applying the result limit.
...(input.hidden ? [] : ["--glob=!**/.*"]),
"--glob=!**/.git/**",
".",
],
+3 -1
View File
@@ -192,7 +192,9 @@ export const toModelContent = (path: string, offset: number | undefined, output:
}
const start = output.type === "text-page" ? output.offset : 1
const lines = output.content === "" ? [] : output.content.replace(/\n$/, "").split("\n")
// Pages already join selected lines; a trailing newline represents a selected blank line.
const text = output.type === "file" ? output.content.replace(/\n$/, "") : output.content
const lines = output.content === "" ? [] : text.split("\n")
const content = [
lines.length === 0 ? `Read file ${path}, 0 lines` : `Read file ${path}, lines ${start}-${start + lines.length - 1}`,
]
+31 -1
View File
@@ -6,13 +6,43 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Location } from "@opencode/core/location"
import { Ripgrep } from "@opencode/core/ripgrep"
import { RelativePath } from "@opencode/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { tempLocationLayer } from "./fixture/location"
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [Location.node.replace(tempLocationLayer)]))
describe("Ripgrep", () => {
for (const hidden of [undefined, false, true]) {
for (const limit of hidden ? [10] : [1, 10]) {
it.live(`glob honors hidden=${hidden} before limit=${limit}`, () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() =>
Promise.all(
["src/visible.ts", ".hidden.ts", "src/.hidden.ts", ".hidden/nested.ts", ".git/config.ts"].map((file) =>
Bun.write(path.join(tmp.path, file), "needle\n"),
),
),
)
const ripgrep = yield* Ripgrep.Service
const files = yield* ripgrep.glob({
cwd: tmp.path,
pattern: "**/*.ts",
limit,
...(hidden === undefined ? {} : { hidden }),
})
expect(files.map((item) => item.path).sort()).toEqual(
(hidden ? [".hidden.ts", ".hidden/nested.ts", "src/.hidden.ts", "src/visible.ts"] : ["src/visible.ts"]).map(
(file) => RelativePath.make(file),
),
)
}),
)
}
}
it.live("globs files as an array", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -3,6 +3,7 @@ import fs from "fs/promises"
import path from "path"
import { Environment } from "@opencode/core/environment/index"
import { AbsolutePath } from "@opencode/core/schema"
import { ReadTool } from "@opencode/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform"
@@ -20,6 +21,96 @@ const fixture = Effect.gen(function* () {
})
const absolute = (value: string) => AbsolutePath.make(value)
describe("ReadTool text serialization", () => {
const cases = [
{
name: "preserves a selected trailing blank line before continuation",
content: "alpha\n\nomega\n",
page: { offset: 1, limit: 2 },
output: { type: "text-page", content: "alpha\n", offset: 1, truncated: true, next: 3 },
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: \n[Output truncated. Continue reading with offset: 3]",
},
{
name: "preserves multiple selected trailing blank lines at a noninitial offset",
content: "before\nalpha\n\n\nomega\n",
page: { offset: 2, limit: 3 },
output: { type: "text-page", content: "alpha\n\n", offset: 2, truncated: true, next: 5 },
model: "Read file lines.txt, lines 2-4\n2: alpha\n3: \n4: \n[Output truncated. Continue reading with offset: 5]",
},
{
name: "preserves a selected trailing blank line at EOF",
content: "alpha\n\n",
page: { limit: 2 },
output: { type: "text-page", content: "alpha\n", offset: 1, truncated: false },
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: ",
},
{
name: "preserves internal blank lines in a page",
content: "alpha\n\nomega\n",
page: { limit: 3 },
output: { type: "text-page", content: "alpha\n\nomega", offset: 1, truncated: false },
model: "Read file lines.txt, lines 1-3\n1: alpha\n2: \n3: omega",
},
{
name: "preserves continuation for a nonblank page",
content: "alpha\n\nomega\n",
page: { limit: 1 },
output: { type: "text-page", content: "alpha", offset: 1, truncated: true, next: 2 },
model: "Read file lines.txt, lines 1-1\n1: alpha\n[Output truncated. Continue reading with offset: 2]",
},
{
name: "strips only the terminal file newline in a whole-file read",
content: "alpha\n\n",
page: {},
output: { type: "file", content: "alpha\n\n", encoding: "utf8" },
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: ",
},
{
name: "does not add a line for a whole-file terminal newline",
content: "alpha\n",
page: {},
output: { type: "file", content: "alpha\n", encoding: "utf8" },
model: "Read file lines.txt, lines 1-1\n1: alpha",
},
{
name: "preserves a whole-file read without a terminal newline",
content: "alpha",
page: {},
output: { type: "file", content: "alpha", encoding: "utf8" },
model: "Read file lines.txt, lines 1-1\n1: alpha",
},
{
name: "preserves empty whole-file output",
content: "",
page: {},
output: { type: "file", content: "", encoding: "utf8" },
model: "Read file lines.txt, 0 lines",
},
{
name: "preserves empty-file page output",
content: "",
page: { limit: 2 },
output: { type: "text-page", content: "", offset: 1, truncated: false },
model: "Read file lines.txt, 0 lines",
},
]
cases.forEach((input) => {
it.live(input.name, () =>
Effect.gen(function* () {
const current = yield* fixture
const file = absolute(path.join(current.directory, "lines.txt"))
yield* current.files.writeFileString(file, input.content)
const result = yield* ReadToolFileSystem.read(current.environment, file, "lines.txt", input.page)
expect(result).toMatchObject(input.output)
expect(ReadTool.toModelContent("lines.txt", undefined, result)).toBe(input.model)
}),
)
})
})
describe("ReadToolFileSystem", () => {
it.effect("preserves the environment not-found error", () =>
Effect.gen(function* () {
+40 -1
View File
@@ -17,7 +17,7 @@ import { GlobTool } from "@opencode/core/tool/plugin/glob"
import { GrepTool } from "@opencode/core/tool/plugin/grep"
import { Tool } from "@opencode/core/tool"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
@@ -67,6 +67,45 @@ const call = (name: "glob" | "grep", input: unknown) => ({
})
describe("search tools", () => {
for (const hidden of [undefined, false, true]) {
for (const limit of hidden ? [10] : [1, 10]) {
it.live(`glob honors hidden=${hidden} before limit=${limit}`, () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() =>
Promise.all(
["src/visible.ts", ".hidden.ts", "src/.hidden.ts", ".hidden/nested.ts", ".git/config.ts"].map((file) =>
Bun.write(path.join(tmp.path, file), "needle\n"),
),
),
)
yield* withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const result = yield* executeTool(
registry,
call("glob", { pattern: "**/*.ts", limit, ...(hidden === undefined ? {} : { hidden }) }),
)
const expected = hidden
? [".hidden.ts", ".hidden/nested.ts", "src/.hidden.ts", "src/visible.ts"]
: ["src/visible.ts"]
expect(result.status).toBe("completed")
expect(result.output).toHaveLength(expected.length)
expect(result.output).toEqual(
expect.arrayContaining(expected.map((file) => ({ path: path.normalize(file), type: "file" }))),
)
expect(result.metadata).toEqual({ count: expected.length, truncated: false })
expect(result.content).toHaveLength(1)
expect(result.content?.[0]?.type === "text" ? result.content[0].text.split("\n").sort() : []).toEqual(
expected.map((file) => path.join(tmp.path, file)).sort(),
)
}),
)
}),
)
}
}
it.live("bounds omitted glob and grep limits", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),