mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 18:36:22 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e648913e3 | ||
|
|
e28fcf5edb | ||
|
|
4ea368e09e | ||
|
|
c6977a836f |
@@ -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)
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/**",
|
||||
".",
|
||||
],
|
||||
|
||||
@@ -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}`,
|
||||
]
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user