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
250 changes: 250 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5021,4 +5021,254 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
NodeAssert.deepEqual(closeCallsDuringRun, []);
}),
);

it.effect("maps OpenCode todo updates onto a turn plan", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-todo-plan");
const todoEvent = promiseWithResolvers<unknown>();
runtimeMock.state.autoPromptEcho = false;
runtimeMock.state.subscribedEvents = [todoEvent.promise];

const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId && event.type === "turn.plan.updated"),
Stream.take(1),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "full-access",
});
const turn = yield* adapter.sendTurn({
threadId,
input: "Plan the work",
modelSelection: createModelSelection(
ProviderInstanceId.make("opencode"),
"opencode/kimi-k3",
),
});

todoEvent.resolve({
id: "evt-todo-plan",
type: "todo.updated",
properties: {
sessionID: "http://127.0.0.1:9999/session",
todos: [
{ id: "t1", content: "Read the adapter", status: "completed", priority: "high" },
{ id: "t2", content: "Emit the plan", status: "in_progress", priority: "medium" },
{ id: "t3", content: "Write tests", status: "pending", priority: "low" },
],
},
});

const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
const planEvent = events[0];
NodeAssert.equal(planEvent?.type, "turn.plan.updated");
NodeAssert.equal(planEvent?.turnId, turn.turnId);
if (planEvent?.type === "turn.plan.updated") {
NodeAssert.deepEqual(planEvent.payload.plan, [
{ step: "Read the adapter", status: "completed" },
{ step: "Emit the plan", status: "inProgress" },
{ step: "Write tests", status: "pending" },
]);
}

yield* adapter.stopSession(threadId);
}),
);

it.effect("drops cancelled todos and names an empty step", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-todo-cancelled");
const todoEvent = promiseWithResolvers<unknown>();
runtimeMock.state.autoPromptEcho = false;
runtimeMock.state.subscribedEvents = [todoEvent.promise];

const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId && event.type === "turn.plan.updated"),
Stream.take(1),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId,
input: "Plan with a cancelled item",
modelSelection: createModelSelection(
ProviderInstanceId.make("opencode"),
"opencode/kimi-k3",
),
});

todoEvent.resolve({
id: "evt-todo-cancelled",
type: "todo.updated",
properties: {
sessionID: "http://127.0.0.1:9999/session",
todos: [
{ id: "t1", content: " ", status: "pending", priority: "high" },
{ id: "t2", content: "Abandoned branch", status: "cancelled", priority: "low" },
{ id: "t3", content: "Still going", status: "unrecognized-status", priority: "low" },
],
},
});

const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
const planEvent = events[0];
if (planEvent?.type === "turn.plan.updated") {
// Cancelled work is dropped rather than reported completed, an empty
// description gets a placeholder, and an unknown status stays pending.
NodeAssert.deepEqual(planEvent.payload.plan, [
{ step: "Task", status: "pending" },
{ step: "Still going", status: "pending" },
]);
}

yield* adapter.stopSession(threadId);
}),
);

it.effect("emits one plan update when OpenCode repeats a todo payload", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-todo-dedupe");
const firstTodo = promiseWithResolvers<unknown>();
const repeatTodo = promiseWithResolvers<unknown>();
const changedTodo = promiseWithResolvers<unknown>();
runtimeMock.state.autoPromptEcho = false;
runtimeMock.state.subscribedEvents = [
firstTodo.promise,
repeatTodo.promise,
changedTodo.promise,
];

const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId && event.type === "turn.plan.updated"),
Stream.take(2),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId,
input: "Repeat the plan",
modelSelection: createModelSelection(
ProviderInstanceId.make("opencode"),
"opencode/kimi-k3",
),
});

const todos = [{ id: "t1", content: "Only step", status: "pending", priority: "high" }];
firstTodo.resolve({
id: "evt-todo-first",
type: "todo.updated",
properties: { sessionID: "http://127.0.0.1:9999/session", todos },
});
yield* Effect.yieldNow;
repeatTodo.resolve({
id: "evt-todo-repeat",
type: "todo.updated",
properties: { sessionID: "http://127.0.0.1:9999/session", todos },
});
yield* Effect.yieldNow;
changedTodo.resolve({
id: "evt-todo-changed",
type: "todo.updated",
properties: {
sessionID: "http://127.0.0.1:9999/session",
todos: [{ id: "t1", content: "Only step", status: "completed", priority: "high" }],
},
});

// Take(2) only completes if the identical repeat was suppressed and the
// genuine status change was not.
const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
NodeAssert.equal(events.length, 2);
const second = events[1];
if (second?.type === "turn.plan.updated") {
NodeAssert.deepEqual(second.payload.plan, [{ step: "Only step", status: "completed" }]);
}

yield* adapter.stopSession(threadId);
}),
);

it.effect("classifies todowrite as a tool call rather than a file change", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-todo-classification");
const assistantMessage = promiseWithResolvers<unknown>();
const toolPart = promiseWithResolvers<unknown>();
runtimeMock.state.autoPromptEcho = false;
runtimeMock.state.subscribedEvents = [assistantMessage.promise, toolPart.promise];

const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId && event.type === "item.completed"),
Stream.take(1),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId,
input: "Track the work",
modelSelection: createModelSelection(
ProviderInstanceId.make("opencode"),
"opencode/kimi-k3",
),
});

assistantMessage.resolve({
id: "evt-assistant-todo-tool",
type: "message.updated",
properties: {
sessionID: "http://127.0.0.1:9999/session",
info: { id: "msg-todo-tool", role: "assistant" },
},
});
yield* Effect.yieldNow;
toolPart.resolve({
id: "evt-todo-tool-part",
type: "message.part.updated",
properties: {
sessionID: "http://127.0.0.1:9999/session",
part: {
id: "part-todo-tool",
sessionID: "http://127.0.0.1:9999/session",
messageID: "msg-todo-tool",
callID: "call-todo-tool",
type: "tool",
tool: "todowrite",
state: { status: "completed", title: "todowrite", time: { start: 1, end: 2 } },
},
time: 1,
},
});

const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
const item = events[0];
if (item?.type === "item.completed") {
// "todowrite" contains "write" but edits nothing; filing it as
// file_change inflates the work log's edit count.
NodeAssert.equal(item.payload.itemType, "dynamic_tool_call");
}

yield* adapter.stopSession(threadId);
}),
);
});
71 changes: 71 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@ interface OpenCodeSessionContext {
activeTurnId: TurnId | undefined;
activeAgent: string | undefined;
activeVariant: string | undefined;
/**
* Last emitted plan fingerprint. OpenCode re-emits `todo.updated` on every
* mutation, so without this each step transition writes a duplicate plan
* activity.
*/
lastPlanFingerprint: string | undefined;
cancellation: OpenCodeCancellation | undefined;
interruptedTurnId: TurnId | undefined;
reconcileIdleStatus: boolean;
Expand Down Expand Up @@ -411,11 +417,44 @@ type EventBaseInput = {
readonly raw?: unknown;
};

type OpenCodePlanStep = {
readonly step: string;
readonly status: "pending" | "inProgress" | "completed";
};

/**
* Maps an OpenCode `todo.updated` payload onto Pylon plan steps. `Todo.status`
* is typed as a bare string by the SDK, so anything unrecognized settles to
* `pending` rather than inventing progress. Cancelled entries are dropped
* outright: reporting them as `completed` would claim work that never happened,
* and Pylon's plan contract has no cancelled state to carry them into.
*/
function extractOpenCodePlanSteps(todos: ReadonlyArray<unknown>): OpenCodePlanStep[] {
return todos
.filter((todo): todo is Record<string, unknown> => todo !== null && typeof todo === "object")
.filter((todo) => todo.status !== "cancelled")
.map((todo) => ({
step: trimText(typeof todo.content === "string" ? todo.content : undefined) ?? "Task",
status:
todo.status === "completed"
? ("completed" as const)
: todo.status === "in_progress"
? ("inProgress" as const)
: ("pending" as const),
}));
}

function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {
const normalized = toolName.toLowerCase();
if (normalized.includes("bash") || normalized.includes("command")) {
return "command_execution";
}
// Ahead of the write/edit branch: `todowrite` contains "write" but changes no
// file, and classifying it as `file_change` files it into the edit tool group
// and inflates the work log's edit count.
if (normalized.includes("todo")) {
return "dynamic_tool_call";
}
if (
normalized.includes("edit") ||
normalized.includes("write") ||
Expand Down Expand Up @@ -2090,6 +2129,37 @@ export function makeOpenCodeAdapter(
break;
}

case "todo.updated": {
// Only the parent session owns the thread's plan; a delegated child
// session's todos would otherwise overwrite it.
if (!isParentEvent) {
break;
}
const plan = extractOpenCodePlanSteps(event.properties.todos);
if (plan.length === 0) {
break;
}
// Control-character delimiters keep this deterministic without JSON;
// todo text realistically never contains them.
const fingerprint = `${turnId ?? "no-turn"}:${plan
.map((entry) => `${entry.status}\u0000${entry.step}`)
.join("\u0001")}`;
if (context.lastPlanFingerprint === fingerprint) {
break;
}
context.lastPlanFingerprint = fingerprint;
yield* emit({
...(yield* buildEventBase({
threadId: context.session.threadId,
...(turnId ? { turnId } : {}),
raw: event,
})),
type: "turn.plan.updated",
payload: { plan },
});
break;
}

case "session.status": {
if (event.properties.status.type === "busy") {
if (turnId === undefined) {
Expand Down Expand Up @@ -2477,6 +2547,7 @@ export function makeOpenCodeAdapter(
cancellation: undefined,
interruptedTurnId: undefined,
reconcileIdleStatus: false,
lastPlanFingerprint: undefined,
awaitingBusyAfterInterruption: false,
pendingIdleReconciliation: undefined,
pendingRequestRecovery: undefined,
Expand Down
6 changes: 6 additions & 0 deletions docs/user/providers-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ With a server URL, Pylon connects to that external server and uses only the pass
provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.
OpenCode uses this password for HTTP Basic authentication.

## Task progress

When OpenCode keeps a todo list for a piece of work, its steps appear in the composer's Tasks tab,
in the plan summary on the turn, and on the sidebar's working line, and they update as the agent
moves through them. Steps OpenCode cancels drop off the list rather than showing as finished.

## Refresh the model list

Pylon loads the model list when an enabled OpenCode provider starts and keeps the list in its
Expand Down
Loading