Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ thor/
- **Runtime**: Node.js 22+
- **Formatting**: Default TypeScript/ESLint conventions. No custom config until needed.
- **OpenCode version alignment**: When bumping `@opencode-ai/sdk`, also bump the OpenCode server/package version in the Dockerfile in the same change so the client and server stay aligned.
- **OpenCode model limit alignment**: When adding or changing models in `docker/opencode/config/`, update `MODEL_CONTEXT_LIMITS` in `packages/runner/src/index.ts` in the same change so context progress remains available.
- **OpenCode event schema/viewer drift**: Before changing OpenCode event persistence, projection, parser schemas, unknown-event fallback rendering, or `unrecognized_opencode_event` handling, read `docs/plan/2026051601_opencode-event-view-schema.md`.
- **No frameworks unless justified** — Express for HTTP, raw TypeScript for everything else. Every added dependency should have a reason in the plan.

Expand Down
246 changes: 246 additions & 0 deletions docs/plan/2026052702_simplify-opencode-prompt-send.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion packages/common/src/service-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export function loadRunnerEnv(env: EnvSource = process.env) {
port: envInt(env, "PORT", 3000),
opencodeUrl: envBaseUrl(env, "OPENCODE_URL", "http://127.0.0.1:4096"),
opencodeConnectTimeout: envInt(env, "OPENCODE_CONNECT_TIMEOUT", 15000),
abortTimeout: envInt(env, "ABORT_TIMEOUT", 10000),
sessionErrorGraceMs: envInt(env, "SESSION_ERROR_GRACE_MS", 10000),
slackBotToken: envOptionalString(env, "SLACK_BOT_TOKEN") ?? "",
slackApiBaseUrl: envBaseUrl(env, "SLACK_API_BASE_URL", "https://slack.com/api"),
Expand Down
16 changes: 6 additions & 10 deletions packages/gateway/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,10 +536,7 @@ describe("gateway", () => {
);
}
if (url === "http://runner.test/trigger" && init?.method === "POST") {
return new Response(JSON.stringify({ busy: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
return new Response("runner unavailable", { status: 500 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
Expand Down Expand Up @@ -3095,7 +3092,7 @@ describe("gateway", () => {
it("uses a fresh public-channel cache hit to accept app mentions without pending privacy", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify({ busy: false }), { status: 200 }));
.mockResolvedValue(new Response(null, { status: 200 }));

await withServer(fetchImpl, async (baseUrl, queue, queueDir, slack) => {
slack.conversationsInfo.mockResolvedValueOnce({ ok: true, channel: { is_private: false } });
Expand Down Expand Up @@ -3975,7 +3972,7 @@ describe("gateway", () => {
expect(runnerBody.correlationKey).toBe("git:branch:test-repo:feature/from-slack");
});

it("retries queued approval outcome re-entry when runner is busy", async () => {
it("acks queued approval outcome re-entry on any successful runner response", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
Expand All @@ -3993,9 +3990,9 @@ describe("gateway", () => {
),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ busy: true }), {
new Response('{"type":"start","sessionId":"s1","resumed":true}\n', {
status: 200,
headers: { "content-type": "application/json" },
headers: { "content-type": "application/x-ndjson" },
}),
)
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
Expand Down Expand Up @@ -4036,7 +4033,6 @@ describe("gateway", () => {
await new Promise((resolve) => setTimeout(resolve, 50));

await queue.flush();
await queue.flush();
},
{
remoteCliHost: "remote-cli.internal",
Expand All @@ -4048,7 +4044,7 @@ describe("gateway", () => {
const runnerCalls = fetchImpl.mock.calls.filter(
([url]) => typeof url === "string" && url === "http://runner.test/trigger",
);
expect(runnerCalls).toHaveLength(2);
expect(runnerCalls).toHaveLength(1);

const firstBody = JSON.parse(String(runnerCalls[0]?.[1]?.body));
expect(firstBody.interrupt).toBe(false);
Expand Down
6 changes: 2 additions & 4 deletions packages/gateway/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,7 @@ export function createGatewayApp(config: GatewayAppConfig): GatewayApp {
const hasInterrupt = events.some((event) => event.interrupt);
const logTrigger = (
prefix: BatchLogPrefix,
outcome: "busy" | "dropped" | "fired",
outcome: "dropped" | "fired",
reason?: string,
) => {
logInfo(
Expand Down Expand Up @@ -1163,9 +1163,7 @@ export function createGatewayApp(config: GatewayAppConfig): GatewayApp {
}

const result = await executeBatchDispatchPlan(plan);
if (result.busy) {
logTrigger(plan.logPrefix, "busy");
} else if (result.rejected) {
if (result.rejected) {
logTrigger(plan.logPrefix, "dropped", result.reason);
} else {
logTrigger(plan.logPrefix, "fired");
Expand Down
2 changes: 1 addition & 1 deletion packages/gateway/src/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ describe("EventQueue", () => {

it("files stay on disk when handler does not call ack", async () => {
const handler = vi.fn<EventHandler>().mockImplementation(async () => {
// Don't call ack — simulates busy/deferred
// Don't call ack — simulates deferred retry
});
queue = new EventQueue({ dir: queueDir, handler, disableInterval: true });

Expand Down
4 changes: 2 additions & 2 deletions packages/gateway/src/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ const QueuedEventSchema = z.object({
/**
* Handler callback. Call `ack()` to confirm processing and delete the files.
* Call `reject(reason)` to move files to the dead-letter directory.
* If the handler returns without settling (e.g. runner busy), files stay on
* disk and will be retried on the next scan cycle.
* If the handler returns without calling ack or reject (e.g. retryable runner failure),
* files stay on disk and will be retried on the next scan cycle.
* If the handler throws, files are deleted to prevent infinite retry loops.
*/
export type EventHandler = (
Expand Down
51 changes: 37 additions & 14 deletions packages/gateway/src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ function execResponse(stdout: unknown, stderr = "", exitCode = 0): Response {
});
}

function ndjsonResponseWithCancelSpy(cancelSpy: ReturnType<typeof vi.fn>): Response {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"type":"start"}\n'));
},
cancel(reason) {
cancelSpy(reason);
},
}),
{
status: 200,
headers: { "content-type": "application/x-ndjson" },
},
);
}

function noopSlackDeps(): SlackDeps {
return { client: {} } as unknown as SlackDeps;
}
Expand Down Expand Up @@ -133,8 +150,7 @@ describe("triggerRunnerSlack edge cases", () => {
onRejected,
);

expect(result.busy).toBe(false);
expect(result.rejected).toBe(true);
expect(result).toMatchObject({ rejected: true });
expect(onRejected).toHaveBeenCalledWith(expect.stringContaining("400"));
});

Expand Down Expand Up @@ -174,7 +190,7 @@ describe("triggerRunnerCron", () => {
deps,
);

expect(result.busy).toBe(false);
expect(result).toEqual({ rejected: false });
const triggerBody = JSON.parse(String(mockFetch.mock.calls[0][1]?.body));
expect(triggerBody.prompt).toBe("Cron events:\n\ndo something\n\ndo the follow-up");
});
Expand Down Expand Up @@ -213,7 +229,7 @@ describe("triggerRunnerGitHub", () => {
vi.fn(),
);

expect(result.busy).toBe(false);
expect(result).toEqual({ rejected: false });
expect(mockFetch.mock.calls[0][0]).toBe("http://remote-cli:3004/internal/exec");
expect(mockFetch.mock.calls[0][1]).toMatchObject({
method: "POST",
Expand Down Expand Up @@ -264,7 +280,7 @@ describe("triggerRunnerGitHub", () => {
vi.fn(),
);

expect(result.busy).toBe(false);
expect(result).toEqual({ rejected: false });
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][0]).toBe("http://runner:3000/trigger");
const triggerBody = JSON.parse(String(mockFetch.mock.calls[0][1]?.body));
Expand Down Expand Up @@ -297,7 +313,7 @@ describe("triggerRunnerGitHub", () => {
onRejected,
);

expect(result.busy).toBe(false);
expect(result).toMatchObject({ rejected: true });
expect(onRejected).toHaveBeenCalledWith("installation_gone");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -326,13 +342,14 @@ describe("triggerRunnerGitHub", () => {
onRejected,
);

expect(result).toEqual({ busy: false });
expect(result).toEqual({ rejected: false });
expect(onRejected).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it("returns busy without ack for non-mention events", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ busy: true }));
it("acks any successful runner response for non-mention events", async () => {
const cancelSpy = vi.fn();
mockFetch.mockResolvedValueOnce(ndjsonResponseWithCancelSpy(cancelSpy));
const onAccepted = vi.fn();

const { triggerRunnerGitHub } = await import("./service.ts");
Expand All @@ -346,16 +363,22 @@ describe("triggerRunnerGitHub", () => {
onAccepted,
);

expect(result.busy).toBe(true);
expect(onAccepted).not.toHaveBeenCalled();
expect(result).toEqual({ rejected: false });
expect(onAccepted).toHaveBeenCalled();
expect(cancelSpy).toHaveBeenCalledTimes(1);
const triggerBody = JSON.parse(String(mockFetch.mock.calls[0][1]?.body));
expect(triggerBody.interrupt).toBe(false);
});
});

describe("approval outcome prompts", () => {
it("includes approval guidance when slack events and approval outcomes share a batch", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ busy: true }));
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
new Response('{"type":"start","sessionId":"s1","resumed":false}\n', {
status: 200,
headers: { "content-type": "application/x-ndjson" },
}),
);
const { triggerRunnerSlack } = await import("./service.ts");

const result = await triggerRunnerSlack(
Expand Down Expand Up @@ -389,7 +412,7 @@ describe("approval outcome prompts", () => {
],
);

expect(result.busy).toBe(true);
expect(result).toEqual({ rejected: false });
const req = fetchImpl.mock.calls[0]?.[1] as { body: string };
const body = JSON.parse(req.body);
expect(body.prompt).toContain("Slack event:");
Expand Down Expand Up @@ -669,7 +692,7 @@ describe("triggerRunnerApprovalOutcomes", () => {
),
]);

expect(outcome).toEqual({ kind: "resolved", result: { busy: false } });
expect(outcome).toEqual({ kind: "resolved", result: { rejected: false } });
expect(onAccepted).toHaveBeenCalledTimes(1);

await resultPromise;
Expand Down
34 changes: 17 additions & 17 deletions packages/gateway/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,9 @@ function getFetch(fetchImpl?: typeof fetch): typeof fetch {
return fetchImpl ?? fetch;
}

export interface TriggerResult {
/** True when the runner reported session busy and interrupt was false. */
busy: boolean;
/** True when the batch was terminally rejected (dead-lettered). */
rejected?: boolean;
/** Human-readable rejection reason; set when `rejected` is true. */
reason?: string;
}
export type TriggerResult =
| { rejected: true; reason: string }
| { rejected: false };

export interface GitHubPrHeadResult {
ref: string;
Expand Down Expand Up @@ -664,17 +659,22 @@ async function triggerRunnerPrompt(options: RunnerTriggerOptions): Promise<Trigg
if (response.status >= 400 && response.status < 500) {
const reason = `Runner returned ${response.status}: ${text}`;
options.onRejected?.(reason);
return { busy: false, rejected: true, reason };
return { rejected: true, reason };
}
throw new Error(`Runner returned ${response.status}: ${text}`);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1017aa2: the gateway now best-effort cancels successful runner response bodies before returning, and I added a service test covering the cancel path.


AI-generated — verify before acting. View Thor context

const json = (await response.json()) as Record<string, unknown>;
if (json.busy === true) {
return { busy: true };
if (response.body) {
await response.body.cancel().catch((error) => {
logWarn(log, "runner_response_cancel_failed", {
correlationKey: options.correlationKey,
error: error instanceof Error ? error.message : String(error),
});
});
}

options.onAccepted?.();
return { busy: false };
return { rejected: false };
}

export async function planBatchDispatch(input: BatchDispatchInput): Promise<BatchDispatchPlan> {
Expand Down Expand Up @@ -882,7 +882,7 @@ async function dispatchBatch(input: BatchDispatchInput): Promise<TriggerResult>
const plan = await planBatchDispatch(currentInput);
if (plan.kind === "drop") {
currentInput.onRejected?.(plan.reason);
return { busy: false, rejected: true, reason: plan.reason };
return { rejected: true, reason: plan.reason };
}
if (plan.kind === "reroute") {
currentInput = {
Expand All @@ -909,7 +909,7 @@ export async function triggerRunnerSlack(
approvalOutcomes?: ApprovalOutcomeEventPayload[],
): Promise<TriggerResult> {
if (events.length === 0 && (!approvalOutcomes || approvalOutcomes.length === 0)) {
return { busy: false };
return { rejected: false };
}

const handleRejected = (reason: string) => {
Expand Down Expand Up @@ -976,7 +976,7 @@ export async function triggerRunnerGitHub(
onAccepted?: () => void,
onRejected?: (reason: string) => void,
): Promise<TriggerResult> {
if (events.length === 0) return { busy: false };
if (events.length === 0) return { rejected: false };

return dispatchBatch({
slackEvents: [],
Expand Down Expand Up @@ -1009,7 +1009,7 @@ export async function triggerRunnerApprovalOutcomes(
slackDirectoryForChannel?: (channel: string) => SlackRoutingInfo,
onRejected?: (reason: string) => void,
): Promise<TriggerResult> {
if (events.length === 0) return { busy: false };
if (events.length === 0) return { rejected: false };

const handleRejected = (reason: string) => {
const last = events[events.length - 1];
Expand Down
41 changes: 1 addition & 40 deletions packages/runner/src/event-bus.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { Event, GlobalEvent, TextPart } from "@opencode-ai/sdk";
import { EventBusRegistry, SessionSubscription, waitForSessionSettled } from "./event-bus.ts";
import { EventBusRegistry, SessionSubscription } from "./event-bus.ts";

vi.mock("@opencode-ai/sdk", () => {
return {
Expand Down Expand Up @@ -49,13 +49,6 @@ function makeIdleEvent(sessionID: string): Event {
};
}

function makeErrorEvent(sessionID: string): Event {
return {
type: "session.error",
properties: { sessionID, error: { name: "UnknownError", data: { message: "test" } } },
};
}

function makeGlobalEvent(directory: string, event: Event): GlobalEvent {
return {
directory,
Expand Down Expand Up @@ -115,10 +108,6 @@ async function collectUntilIdle(sub: AsyncIterable<Event>): Promise<Event[]> {
return items;
}

async function* eventIterable(events: Event[]) {
for (const event of events) yield event;
}

describe("SessionSubscription", () => {
let emitter: EventEmitter;

Expand Down Expand Up @@ -358,31 +347,3 @@ describe("EventBusRegistry", () => {
sub.close();
});
});

describe("waitForSessionSettled", () => {
it("treats idle and error events as settled", async () => {
await expect(waitForSessionSettled(eventIterable([makeIdleEvent("s1")]), 1_000)).resolves.toBe(
true,
);
await expect(waitForSessionSettled(eventIterable([makeErrorEvent("s1")]), 1_000)).resolves.toBe(
true,
);
});

it("returns false when the event stream ends before the session settles", async () => {
await expect(waitForSessionSettled(eventIterable([makePartEvent("s1")]), 1_000)).resolves.toBe(
false,
);
});

it("resolves false when the timeout fires before any settle event arrives", async () => {
const emitter = new EventEmitter();
const sub = new SessionSubscription(emitter, ["s1"]);
try {
// Stream stays open with no settle event — timeout has to win.
await expect(waitForSessionSettled(sub, 30)).resolves.toBe(false);
} finally {
sub.close();
}
});
});
Loading