From 63b42f6f02e4b74fae343eddbe567db2c53fcf21 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:35:15 -0600 Subject: [PATCH] fix(core): report a refused Console auto-watch (#63217) --- .../src/plugin/provider/opencode-watches.ts | 17 +++++++- .../plugin/provider-opencode-watches.test.ts | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/core/src/plugin/provider/opencode-watches.ts b/packages/core/src/plugin/provider/opencode-watches.ts index 4ab4e1eeb06..b0a6b6ac83c 100644 --- a/packages/core/src/plugin/provider/opencode-watches.ts +++ b/packages/core/src/plugin/provider/opencode-watches.ts @@ -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 } })) }), ) } diff --git a/packages/core/test/plugin/provider-opencode-watches.test.ts b/packages/core/test/plugin/provider-opencode-watches.test.ts index bdfd437ec4e..a5329a69e99 100644 --- a/packages/core/test/plugin/provider-opencode-watches.test.ts +++ b/packages/core/test/plugin/provider-opencode-watches.test.ts @@ -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>(), + /** 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* () {