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
168 changes: 168 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2891,6 +2891,174 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
}).pipe(Effect.scoped),
);

it.effect("projects successful native todo updates into one checklist", () =>
Effect.gen(function* () {
const updates = [
[
{ content: " Inspect code ", status: "completed" },
{ content: "Implement", status: "in_progress" },
{ content: "Verify", status: "pending" },
{ content: "Abandoned", status: "cancelled" },
],
[{ content: "Inspect code", status: "completed" }],
[{ content: "Abandoned", status: "cancelled" }],
[],
[{ content: "Follow up", status: "pending" }],
];
const tool = (id: string) => ({
sessionID: SESSION,
assistantMessageID: "msg_assistant",
id,
});
const call = (id: string, todos: unknown): ReadonlyArray<ProviderReplayEntry> => [
event("session.tool.input.started", { ...tool(id), name: "todowrite" }),
event("session.tool.called", { ...tool(id), input: { todos }, executed: false }),
];
const { runtime, thread } = yield* resumed([
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
promptAccepted,
...call("call-denied", [{ content: "Not saved", status: "pending" }]),
event(
"session.tool.failed",
{
...tool("call-denied"),
error: { type: "unknown", message: "Unable to update todos" },
executed: false,
},
undefined,
2,
),
...call("call-malformed", [{ content: "Not saved", status: "pending" }]),
event(
"session.tool.success",
{
...tool("call-malformed"),
content: [{ type: "text", text: "Unrecognized todo result" }],
metadata: { todos: [{ content: "Unknown", status: "new-status" }] },
executed: false,
},
undefined,
2,
),
...updates.flatMap((todos, index) => [
...call(`call-todo-${index}`, todos),
event(
"session.tool.success",
{
...tool(`call-todo-${index}`),
content: [{ type: "text", text: "Todos updated" }],
metadata: { todos },
executed: false,
},
undefined,
2,
),
]),
event("session.execution.succeeded", { sessionID: SESSION }),
]);
const events = yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
Effect.forkScoped,
);
yield* runtime.startTurn(turnInput(thread));
const collected = yield* Fiber.join(events);
const plans = collected.flatMap((event) =>
event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [],
);
assert.deepEqual(
plans.map((plan) => plan.steps.map((step) => [step.text, step.status])),
[
[
["Inspect code", "completed"],
["Implement", "running"],
["Verify", "pending"],
],
[["Inspect code", "completed"]],
[],
[],
[["Follow up", "pending"]],
],
);
assert.equal(new Set(plans.map((plan) => plan.id)).size, 1);
assert.equal(plans[0]?.steps[0]?.id, plans[1]?.steps[0]?.id);
assert.deepEqual(
plans.map((plan) => plan.status),
["active", "completed", "completed", "completed", "active"],
);
const items = collected.flatMap((event) =>
event.type === "turn_item.updated" ? [event.turnItem] : [],
);
const checklists = items.filter((item) => item.type === "todo_list");
assert.equal(new Set(checklists.map((item) => item.id)).size, 1);
assert.deepEqual(
checklists.map((item) => item.steps),
plans.map((plan) => plan.steps),
);
assert.deepEqual(
checklists.map((item) => item.status),
["completed", "completed", "completed", "completed", "completed"],
);
assert.isFalse(
items.some(
(item) =>
item.type === "dynamic_tool" && item.nativeItemRef?.nativeId?.startsWith("call-todo-"),
),
);
assert.deepInclude(
items.find((item) => item.nativeItemRef?.nativeId === "call-denied"),
{ type: "dynamic_tool", status: "failed" },
);
assert.deepInclude(
items.find((item) => item.nativeItemRef?.nativeId === "call-malformed"),
{ type: "dynamic_tool", status: "completed" },
);
}).pipe(Effect.scoped),
);

it.effect("recovers native todo updates missed during reconnect", () =>
Effect.gen(function* () {
const todos = [{ content: "Saved during disconnect", status: "in_progress" }];
const { runtime, thread } = yield* resumed([
out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }),
promptAccepted,
...reconnected({}, "succeeded", {
content: [
{
type: "tool",
id: "call-todo-gap",
name: "todowrite",
executed: false,
state: {
status: "completed",
input: { todos },
content: [{ type: "text", text: "Todos updated" }],
metadata: { todos },
},
time: { created: 2, ran: 2, completed: 2 },
},
],
}),
]);
const events = yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
Effect.forkScoped,
);
yield* runtime.startTurn(turnInput(thread));
const collected = yield* Fiber.join(events);
const plans = collected.flatMap((event) =>
event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [],
);
assert.equal(plans.length, 1);
assert.deepEqual(
plans[0]?.steps.map((step) => [step.text, step.status]),
[["Saved during disconnect", "running"]],
);
assert.include(turnItems(collected), "todo_list:completed");
}).pipe(Effect.scoped),
);

it.effect("stores a shell command that exits non-zero as failed", () =>
Effect.gen(function* () {
const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-exit" };
Expand Down
92 changes: 90 additions & 2 deletions packages/provider-opencode/src/server/v2/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ import {
type OrchestrationV2ProviderSession,
type OrchestrationV2ProviderThread,
type OrchestrationV2ProviderTurn,
type OrchestrationV2PlanStep,
type OrchestrationV2RuntimeRequest,
type OrchestrationV2Subagent,
type OrchestrationV2TurnItem,
type OrchestrationV2UserInputQuestion,
type MessageId,
type ModelSelection,
type PlanId,
type ProviderApprovalDecision,
type ProviderInstanceId,
type RunId,
Expand Down Expand Up @@ -101,6 +103,16 @@ const NativeGoal = Schema.NullOr(
);
const decodeGoal = Schema.decodeUnknownEffect(NativeGoal);

const NativeTodoList = Schema.Struct({
todos: Schema.Array(
Schema.Struct({
content: Schema.String,
status: Schema.Literals(["pending", "in_progress", "completed", "cancelled"]),
}),
),
});
const isNativeTodoList = Schema.is(NativeTodoList);

const OpenCode2ProviderCapabilities = {
sessions: {
supportsMultipleProviderThreadsPerSession: true,
Expand Down Expand Up @@ -153,8 +165,8 @@ const OpenCode2ProviderCapabilities = {
approvalsCanOriginateFromSubagents: true,
},
planning: {
emitsPlanUpdated: false,
emitsTodoList: false,
emitsPlanUpdated: true,
emitsTodoList: true,
emitsProposedPlan: false,
supportsStructuredQuestions: true,
planDeltasHaveItemIds: false,
Expand Down Expand Up @@ -231,6 +243,7 @@ interface ActiveTurn {
before: string | null | undefined;
compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined;
compactions: number;
todoPlanId: PlanId | undefined;
retry:
| {
readonly retry: OrchestrationV2ProviderRetry;
Expand Down Expand Up @@ -1061,6 +1074,65 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
});
});

// Todo updates share the provider event span.
const emitTodo = Effect.fnUntraced(function* (
state: ThreadState,
turn: ActiveTurn,
todos: typeof NativeTodoList.Type.todos,
) {
turn.todoPlanId ??= yield* idAllocator.allocate.plan({
threadId: turn.input.threadId,
...(turn.input.runId === null ? {} : { runId: turn.input.runId }),
driver,
});
const nativeId = `${state.sessionId}:todo:${turn.providerTurn.id}`;
const now = yield* DateTime.now;
const startedAt = turn.startedAt.get(nativeId) ?? now;
turn.startedAt.set(nativeId, startedAt);
const steps: Array<OrchestrationV2PlanStep> = todos.flatMap((todo, index) =>
todo.status === "cancelled"
? []
: [
{
id: `${nativeId}:${index + 1}`,
text: todo.content.trim() || `Todo ${index + 1}`,
status:
todo.status === "completed"
? "completed"
: todo.status === "in_progress"
? "running"
: "pending",
},
],
);
const base = itemBase(state, turn, nativeId, "completed", startedAt, now, now);
yield* emitNode(state, turn, nativeId, "todo_list", "completed", startedAt, now);
yield* emit({
type: "plan.updated",
driver,
plan: {
id: turn.todoPlanId,
threadId: turn.input.threadId,
runId: turn.input.runId,
nodeId: base.nodeId,
kind: "todo_list",
status: steps.every((step) => step.status === "completed") ? "completed" : "active",
steps,
},
});
yield* emit({
type: "turn_item.updated",
driver,
turnItem: {
...base,
type: "todo_list",
title: "Todo list",
planId: turn.todoPlanId,
steps,
},
});
});

const emitTool = Effect.fnUntraced(function* (
state: ThreadState,
turn: ActiveTurn,
Expand All @@ -1074,6 +1146,21 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
) {
const tool = turn.tools.get(id);
if (tool === undefined) return;
if (tool.name === "todowrite") {
if (status === "running") return;
if (status === "completed" && isNativeTodoList(result?.metadata)) {
const projected = yield* emitTodo(state, turn, result.metadata.todos).pipe(
Effect.as(true),
Effect.catchTags({
IdAllocatorV2AllocationError: (error) =>
Effect.logWarning("Could not allocate an OpenCode todo list.", error).pipe(
Effect.as(false),
),
}),
);
if (projected) return;
}
}
const updatedAt = yield* DateTime.now;
const startedAt = turn.startedAt.get(id) ?? updatedAt;
const completedAt = status === "running" ? null : updatedAt;
Expand Down Expand Up @@ -1226,6 +1313,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
before: undefined,
compaction: undefined,
compactions: 0,
todoPlanId: undefined,
retry: undefined,
interrupted: false,
unsent: false,
Expand Down
Loading