Skip to content
Merged
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
566 changes: 555 additions & 11 deletions apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ const operationOf = (
query: Record<string, unknown>,
body: unknown,
) => {
const rpc = /^\/api\/rpc\/([^/]+)\/([^/]+)$/.exec(path);
if (method === "POST" && rpc !== null)
return {
type: "rpc.call",
input: { rpcID: rpc[1], method: rpc[2], ...query, ...(body as object) },
};
const session = /^\/api\/session\/([^/]+)(\/.*)?$/.exec(path);
if (method === "GET" && path === "/api/event") return { type: "event.subscribe" };
if (method === "GET" && path === "/api/model") return { type: "model.list", input: query };
Expand Down Expand Up @@ -292,6 +298,7 @@ export const openCode2ReplayRuntime = (
entries: ReadonlyArray<ProviderReplayEntry>,
options?: {
readonly external?: boolean;
readonly version?: string;
readonly onPrompt?: (input: unknown) => void;
readonly borrowers?: { current: number };
readonly replayGate?: ProviderReplayGate;
Expand All @@ -306,7 +313,7 @@ export const openCode2ReplayRuntime = (
{
provider: OPENCODE_PROVIDER,
protocol: OPENCODE2_HTTP_PROTOCOL,
version: "2.0.18",
version: options?.version ?? "2.0.18",
scenario: "opencode2_adapter",
entries,
},
Expand Down
11 changes: 7 additions & 4 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,9 +1168,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
command: OrchestrationV2ServerCommand,
providerInstanceId: ProviderInstanceId,
) {
const { driver } = yield* providerAdapters
.get(providerInstanceId)
.pipe(mapDispatchError(command));
const adapter = yield* providerAdapters.get(providerInstanceId).pipe(mapDispatchError(command));
const { driver } = adapter;
if (driver === "opencode") {
const capabilities = yield* adapter.getCapabilities().pipe(mapDispatchError(command));
return capabilities.turns.supportsNativeGoals === true ? driver : undefined;
}
return driver === "codex" || driver === "claudeAgent" ? driver : undefined;
});

Expand Down Expand Up @@ -6114,7 +6117,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
) {
dispatchMode = { type: "queue_after_active" };
}
// Codex and Claude own /goal; elsewhere it is ordinary text that cannot steer.
// Native goal commands must not steer an existing turn.
const parsedGoalCommand = parseGoalCommand(command);
const goalCommand =
parsedGoalCommand === null ||
Expand Down
28 changes: 26 additions & 2 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ it.each([

function makeLocalCommandHarness(input: {
readonly text: string;
readonly nativeGoals?: boolean;
readonly worktreePath?: string;
readonly worktreesDir?: string;
readonly workspaceRoot?: string;
Expand Down Expand Up @@ -337,7 +338,7 @@ function makeLocalCommandHarness(input: {
};
const providerThread: OrchestrationV2ThreadProjection["providerThreads"][number] = {
id: providerThreadId,
driver: ProviderDriverKind.make("codex"),
driver: ProviderDriverKind.make(input.nativeGoals === undefined ? "codex" : "opencode"),
providerInstanceId: newInstanceId,
providerSessionId,
appThreadId: threadId,
Expand Down Expand Up @@ -778,7 +779,13 @@ function makeLocalCommandHarness(input: {
status: "ready",
cwd: "/tmp/native-account-command",
model: null,
capabilities: CodexProviderCapabilitiesV2,
capabilities: {
...CodexProviderCapabilitiesV2,
turns: {
...CodexProviderCapabilitiesV2.turns,
...(input.nativeGoals === undefined ? {} : { supportsNativeGoals: input.nativeGoals }),
},
},
createdAt: now,
updatedAt: now,
lastError: null,
Expand Down Expand Up @@ -1338,6 +1345,23 @@ effectIt.effect.each([
}),
);

effectIt.effect.each([false, true])(
"OpenCode goal prompts defer switched-provider history only with native support: %s",
(nativeGoals) =>
Effect.gen(function* () {
const harness = makeLocalCommandHarness({
text: "/goal all tests pass",
nativeGoals,
sideChat: { contextRun: "failed", switchedProvider: true },
});
harness.tryHandlePromptCommand.mockReturnValue(Effect.succeed(false));
yield* harness.start;
expect(harness.startTurn.mock.calls[0]?.[0].message.text.includes("SIDE_CHAT_CONTEXT")).toBe(
!nativeGoals,
);
}),
);

effectIt.effect.each([
{ runStatus: "failed", turnStatus: "completed" },
{ runStatus: "completed", turnStatus: "completed" },
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,8 +841,12 @@ export const layer: Layer.Layer<
text: message.text,
records: message.context?.records ?? [],
});
// Codex and Claude parse /goal from the prompt, so it goes verbatim and history waits a turn.
const goalDriver = session.driver === "codex" || session.driver === "claudeAgent";
// Native goal commands go verbatim; handoff history waits a turn.
const goalDriver =
session.driver === "codex" ||
session.driver === "claudeAgent" ||
(session.driver === "opencode" &&
session.providerSession.capabilities.turns.supportsNativeGoals === true);
const goalCommand = goalDriver && parseGoalCommand(message) !== null;
// The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively.
const noteContinuation = isRestartNoteContinuation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,9 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function*
canSteer = true,
interruptTurn: Effect.Effect<void> = Effect.void,
steeringAcceptsAttachments = true,
nativeGoals?: boolean,
) {
const driver = ProviderDriverKind.make(nativeGoals === undefined ? "codex" : "opencode");
const cwd = yield* checkpointWorkspace(name);
const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = [];
Expand All @@ -501,6 +503,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function*
supportsActiveSteering: canSteer,
supportsSteeringByInterruptRestart: canRestart,
activeSteeringAcceptsAttachments: steeringAcceptsAttachments,
...(nativeGoals === undefined ? {} : { supportsNativeGoals: nativeGoals }),
},
};
const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = {
Expand Down Expand Up @@ -2060,8 +2063,15 @@ it.effect("queues a plain send behind the stopped queue and releases its hold",
),
);

const goalHarness = Effect.fn("goalHarness")(function* (name: string) {
const harness = yield* nextTurnSelectionHarness(name);
const goalHarness = Effect.fn("goalHarness")(function* (name: string, nativeGoals?: boolean) {
const harness = yield* nextTurnSelectionHarness(
name,
false,
true,
Effect.void,
true,
nativeGoals,
);
const send = (id: string, text: string, delivery: "queue" | "auto", freshContext = false) =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
Expand Down Expand Up @@ -2101,26 +2111,34 @@ const goalHarness = Effect.fn("goalHarness")(function* (name: string) {
return { ...harness, send, read };
});

it.effect("stops the running turn and starts a goal sent into it next", () =>
Effect.scoped(
Effect.gen(function* () {
const { steered, layer, startFirstTurn, send, read } = yield* goalHarness("goal-steer");
yield* Effect.gen(function* () {
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const threadId = yield* startFirstTurn;
yield* send("queued", "queued", "queue");
yield* send("goal", "/goal all tests pass", "auto");
yield* worker.drain();
it.effect.each([undefined, false, true])(
"goal dispatch stops the active turn only with native support: %s",
(nativeGoals) =>
Effect.scoped(
Effect.gen(function* () {
const { steered, layer, startFirstTurn, send, read } = yield* goalHarness(
"goal-steer",
nativeGoals,
);
yield* Effect.gen(function* () {
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const threadId = yield* startFirstTurn;
yield* send("queued", "queued", "queue");
const sending = send("goal", "/goal all tests pass", "auto");
if (nativeGoals === false) {
yield* Effect.flip(sending);
} else yield* sending;
yield* worker.drain();

assert.deepEqual(steered, []);
assert.deepEqual(yield* read(threadId), {
queue: ["queued"],
started: ["first", "/goal all tests pass"],
stopRequested: true,
});
}).pipe(Effect.provide(layer));
}),
),
assert.deepEqual(steered, []);
assert.deepEqual(yield* read(threadId), {
queue: ["queued"],
started: nativeGoals === false ? ["first"] : ["first", "/goal all tests pass"],
stopRequested: nativeGoals !== false,
});
}).pipe(Effect.provide(layer));
}),
),
);

it.effect("starts a goal that names no model on the thread's model ahead of a queued pick", () =>
Expand Down
6 changes: 3 additions & 3 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,15 @@ Claude instances can also compact idle threads for you. See

## Goals

With Codex and Claude, send `/goal` followed by what "done" means, for example
With Codex, Claude, and OpenCode 2.2.5 or later, send `/goal` followed by what "done" means, for example
`/goal all tests in packages/api pass`. The agent keeps working across turns
until it judges the goal met. The thread shows **Goal** while it works, and a
line at the end of the thread (above the composer on mobile) shows the goal and
its progress.

- `/goal` alone shows the current goal. `/goal clear` removes it.
- Codex also supports `/goal pause` and `/goal resume`. Stopping a Codex goal
pauses it, and so does Codex ending its work without meeting the goal.
- Codex and OpenCode also support `/goal pause` and `/goal resume`. Stopping
their goal pauses it. Codex also pauses when its work ends without meeting the goal.
- Stopping Claude ends the current turn, but the goal stays set. Claude checks it
again at the end of your next message.
- Sending a new goal, or `/goal resume`, while the agent works stops the current
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export const OrchestrationV2ThreadCapabilities = Schema.Struct({
export type OrchestrationV2ThreadCapabilities = typeof OrchestrationV2ThreadCapabilities.Type;

export const OrchestrationV2TurnCapabilities = Schema.Struct({
supportsNativeGoals: Schema.optional(Schema.Boolean),
exposesNativeTurnId: Schema.Boolean,
emitsTurnStarted: Schema.Boolean,
emitsTurnCompleted: Schema.Boolean,
Expand Down
111 changes: 111 additions & 0 deletions packages/provider-opencode/src/server/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,117 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
}).pipe(Effect.scoped),
);

it.effect.each([
"1.18.32",
"opencode v2.0.18",
"opencode v2.2.4",
"opencode v2.2.5",
"opencode v2.2.6",
])("advertises native goal support only for supported OpenCode versions: %s", (version) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-goal-capability-" });
const binaryPath = NodePath.join(
root,
"lib",
"node_modules",
"@opencode",
"cli",
"bin",
"opencode",
);
yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true });
yield* fs.writeFileString(binaryPath, "");
const instance = yield* create({ binaryPath }, noHttp).pipe(
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, {
...openCode2Runtime,
runOpenCodeCommand: () => Effect.succeed({ stdout: version, stderr: "", code: 0 }),
}),
);
yield* instance.snapshot.resolveMaintenance({ fresh: true });
const capabilities = yield* instance.orchestrationAdapter.getCapabilities();
assert.equal(
capabilities.turns.supportsNativeGoals === true,
version.endsWith("2.2.5") || version.endsWith("2.2.6"),
);
}).pipe(Effect.scoped),
);

it.effect("uses the connected OpenCode version for goal support after an upgrade", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-upgraded-goals-" });
const binaryPath = NodePath.join(
root,
"lib",
"node_modules",
"@opencode",
"cli",
"bin",
"opencode",
);
yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true });
yield* fs.writeFileString(binaryPath, "");
const http = HttpClient.make((request) => {
const path = new URL(request.url).pathname;
if (path === "/api/info")
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
...JSON.parse(OPENCODE_2_RESPONSES["/api/info"].body),
version: "2.2.5",
}),
),
);
if (path === "/api/model")
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ location: { directory: "/work" }, data: [] }),
),
);
if (path === "/api/event")
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(new ReadableStream(), {
headers: { "content-type": "text/event-stream" },
}),
),
);
return Effect.die(`Unexpected request: ${path}`);
});
const instance = yield* create({ binaryPath }, http).pipe(
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, {
...openCode2Runtime,
runOpenCodeCommand: () =>
Effect.succeed({ stdout: "opencode v2.2.4", stderr: "", code: 0 }),
startOpenCodeServerProcess: ({ verify }) =>
Effect.gen(function* () {
const url = "http://127.0.0.1:4096";
const version = verify ? yield* verify(url) : "2.2.5";
return { url, version, isRunning: Effect.succeed(true), exitCode: Effect.never };
}),
}),
);
yield* instance.snapshot.resolveMaintenance({ fresh: true });
assert.isFalse(
(yield* instance.orchestrationAdapter.getCapabilities()).turns.supportsNativeGoals,
);
const session = yield* instance.orchestrationAdapter.openSession({
threadId: ThreadId.make("upgraded-goal"),
providerSessionId: ProviderSessionId.make("upgraded-goal"),
modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" },
runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null },
});
assert.isTrue(session.providerSession.capabilities.turns.supportsNativeGoals);
assert.isTrue(
(yield* instance.orchestrationAdapter.getCapabilities()).turns.supportsNativeGoals,
);
}).pipe(Effect.scoped),
);

it.effect("answers capability reads without waiting on an unreachable server", () =>
Effect.gen(function* () {
const hang = HttpClient.make(() => Effect.never);
Expand Down
16 changes: 15 additions & 1 deletion packages/provider-opencode/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { compareSemverVersions } from "@t3tools/shared/semver";
import * as OpenCodeAdapterV2 from "./adapter.ts";
import * as OpenCode2AdapterV2 from "./v2/adapter.ts";
import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
Expand Down Expand Up @@ -120,7 +121,20 @@ function selectOpenCodeRuntimeAdapter(input: {
return {
instanceId: input.v1.instanceId,
driver: DRIVER_KIND,
getCapabilities: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()),
getCapabilities: Effect.fn("OpenCodeDriver.getCapabilities")(function* () {
const probed = Option.getOrUndefined(yield* input.probe.lastSuccess);
const adapter = yield* pick(Effect.succeed(probed));
const capabilities = yield* adapter.getCapabilities();
return {
...capabilities,
turns: {
...capabilities.turns,
supportsNativeGoals:
capabilities.turns.supportsNativeGoals ??
(probed?.generation === "v2" && compareSemverVersions(probed.version, "2.2.5") >= 0),
},
};
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
planSelectionTransition: (transition) =>
Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)),
openSession: (session) =>
Expand Down
Loading
Loading