fix(core): report a refused Console auto-watch (#63217)

This commit is contained in:
Adam
2026-11-10 18:35:15 -06:00
committed by GitHub
Unverified
parent 05b32ea324
commit 63b42f6f02
2 changed files with 57 additions and 2 deletions
@@ -206,6 +206,19 @@ export const make = Effect.fnUntraced(function* (ctx: Context, http: HttpClient.
// Off the tool call's path: the result never waits on Console, and a failure only skips the watch.
return create(member, event.sessionID, { target }).pipe(
Effect.scoped,
// A refusal, such as a GitHub sign-in the member hasn't made, is told once per reason: the ID repeats with it.
Effect.catchIf(
(cause): cause is ToolFailure => cause instanceof ToolFailure && cause.metadata?.status === 403,
(refusal) =>
ctx.session.synthetic({
sessionID: event.sessionID,
id: SessionMessage.ID.make(
`msg_watch_${Hash.sha256(`${event.sessionID}\n${refusal.message}`).slice(0, 26)}`,
),
text: `OpenCode Console didn't watch a pull request or push from this session, so its updates won't wake the session: ${refusal.message}`,
metadata: { source: "console.watch" },
}),
),
Effect.catch((cause) => Effect.logDebug("skipped watching a shell command's work", { cause })),
Effect.forkIn(lease, { startImmediately: true }),
Effect.asVoid,
@@ -245,7 +258,7 @@ export const make = Effect.fnUntraced(function* (ctx: Context, http: HttpClient.
editor.add({
name: "watch",
description:
"Wake this session when a Slack thread or a GitHub pull request, issue, commit or Actions run gets a reply or changes status, or one of the workspace's MCP servers sends an event `events` lists. Slack and GitHub writes through OpenCode Console, `gh pr create` and `git push` are already watched, a pull request's checks included, so don't watch what you just made. Use it for anything else, such as a deploy's Actions run, `gh workflow run` or a link the user shared. People often answer a Slack post in the channel rather than its thread, so after each top-level post the channel's new top-level messages are watched for 15 minutes too. The session wakes with the update, so don't poll.",
"Wake this session when a Slack thread or a GitHub pull request, issue, commit or Actions run gets a reply or changes status, or one of the workspace's MCP servers sends an event `events` lists. Slack and GitHub writes through OpenCode Console, `gh pr create` and `git push` are already watched, a pull request's checks included, so don't watch what you just made (the session is told if Console refuses). Use it for anything else, such as a deploy's Actions run, `gh workflow run` or a link the user shared. People often answer a Slack post in the channel rather than its thread, so after each top-level post the channel's new top-level messages are watched for 15 minutes too. The session wakes with the update, so don't poll.",
input: Schema.Struct({
target: Schema.optional(
Schema.String.annotate({
@@ -590,7 +603,7 @@ function expectOk(response: HttpClientResponse.HttpClientResponse) {
: response.status === 404
? "Event watches are off for this OpenCode Console workspace."
: (body?.message ?? `OpenCode Console answered ${response.status}`)
return Effect.fail(new ToolFailure({ message }))
return Effect.fail(new ToolFailure({ message, metadata: { status: response.status } }))
}),
)
}
@@ -78,6 +78,8 @@ function fakeConsole(options: { readonly upgrade?: boolean } = {}) {
requests: new Array<{ method: string; path: string; body?: unknown }>(),
/** What `GET /api/event-watches/types` lists. */
types: new Array<Record<string, unknown>>(),
/** The status and message Console answers `POST /api/event-watches` with instead of a watch, when set. */
refuse: undefined as { status: number; message: string } | undefined,
}
const server = Bun.serve({
port: 0,
@@ -112,6 +114,8 @@ function fakeConsole(options: { readonly upgrade?: boolean } = {}) {
}
if (url.pathname === "/api/event-watches/types") return Response.json({ types: state.types })
if (url.pathname === "/api/event-watches" && request.method === "POST") {
if (state.refuse)
return Response.json({ _tag: "WatchRefused", message: state.refuse.message }, { status: state.refuse.status })
const created = body as { sessionID: string; server?: string; event?: string; arguments?: unknown }
// Another server's event type is keyed by a hash of it, as Console keys it.
return Response.json({
@@ -935,6 +939,44 @@ describe("OpencodePlugin event watches", () => {
),
)
it.effect("tells the session once why Console refused a shell command's watch, and skips other failures", () =>
withConsole({}, (console) =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const { host, wakes, replays } = yield* start(console)
const session = yield* host.session.create({})
const created = () =>
console.state.requests.filter((request) => request.method === "POST" && request.path === "/api/event-watches")
const push = (refuse: { status: number; message: string }, posted: number) =>
Effect.gen(function* () {
console.state.refuse = refuse
yield* hooks.trigger("tool", "execute.after", {
tool: "shell",
sessionID: session.id,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make("call_shell"),
input: { command: "gh pr create --fill" },
status: "completed",
result: { output: { exit: 0, output: "https://github.com/Acme/App/pull/42\n", truncated: false } },
})
yield* waitUntil(Effect.sync(() => created().length === posted))
})
const refused = { status: 403, message: "Sign in to GitHub at https://console.test/github first." }
yield* push({ status: 500, message: "Something went wrong" }, 1)
yield* push(refused, 2)
yield* waitUntil(Effect.sync(() => wakes.length === 1))
yield* push(refused, 3)
yield* waitUntil(Effect.sync(() => replays.length === 1))
expect(wakes).toHaveLength(1)
expect(wakes[0]?.sessionID).toBe(session.id)
expect(wakes[0]?.text).toContain(refused.message)
}),
),
)
it.effect("starts and stops with the header on the Console's periodic config check", () =>
withConsole({}, (console) =>
Effect.gen(function* () {