authorgravatar for 43469098+nkoynov@users.noreply.github.comNikita Koynov <43469098+nkoynov@users.noreply.github.com> 2026-10-10 15:19:23+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 07:19:23-05:00
logdd4549eede928f9dc514778aeae15d74656a87f8
tree4a2ca0e3bb6d8bbb599d397a83de0921ed86fd63
parentee7924ff7fcabe24d5e8d940ac5e878d36477a0d
signature Signed by PGP key B5690EEEBB952194

fix(server): settle every OpenCode subagent call one report answers (#17134)


2 files changed, 222 insertions(+), 14 deletions(-)

apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+193
...@@ -953,6 +953,199 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {...@@ -953,6 +953,199 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
953 }).pipe(Effect.scoped),953 }).pipe(Effect.scoped),
954 );954 );
955955
956 /** A `subagent` call's result while its subagent runs on (`session.tool.success` is at version 2). */
957 const returnedRunning = (id: string): ProviderReplayEntry => ({
958 type: "emit_inbound",
959 frame: {
960 type: "sdk.event",
961 event: {
962 id: `evt_toolsuccess_${id}`,
963 created: 1,
964 type: "session.tool.success",
965 data: {
966 sessionID: SESSION,
967 assistantMessageID: "msg_assistant",
968 id,
969 content: [{ type: "text", text: "The subagent is working in the background." }],
970 metadata: { sessionID: CHILD, status: "running" },
971 executed: true,
972 },
973 durable: { aggregateID: SESSION, seq: 1, version: 2 },
974 },
975 },
976 });
977 /** The model calls the subagent again by its session; the tool returns while it runs on. */
978 const callAgain = (input: Record<string, unknown>): ReadonlyArray<ProviderReplayEntry> => {
979 const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" };
980 return [
981 event("session.tool.input.started", { ...tool, name: "subagent" }),
982 event("session.tool.called", {
983 ...tool,
984 name: "subagent",
985 input: { description: "Again", prompt: "also this", sessionID: CHILD, ...input },
986 executed: false,
987 }),
988 event("session.tool.progress", {
989 ...tool,
990 metadata: { sessionID: CHILD, status: "running" },
991 }),
992 // A session called again gets the thread's rules.
993 out("session.update", { sessionID: CHILD, permissions: "<any>" }),
994 reply("session.update", null),
995 returnedRunning(tool.id),
996 ];
997 };
998 const childReport = (inboxID: string, text: string) =>
999 event("session.inbox.enqueued", {
1000 inboxID,
1001 sessionID: SESSION,
1002 item: {
1003 type: "synthetic",
1004 payload: {
1005 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\n${text}\n</subagent>`,
1006 description: "Sleep",
1007 metadata: { source: "subagent", childID: CHILD, agent: "General", state: "completed" },
1008 },
1009 delivery: "steer",
1010 },
1011 });
1012 /** The subagent rows by title, the thread's background roster and the child's turns, as emitted. */
1013 const watchCalls = (
1014 runtime: ProviderAdapterV2SessionRuntime,
1015 thread: OrchestrationV2ProviderThread,
1016 ) =>
1017 Effect.gen(function* () {
1018 const calls = new Map<string, { status: string; result: string | null }>();
1019 const childTurns = new Map<string, string>();
1020 const callsAtChildTurnStart = new Map<
1021 string,
1022 Record<string, { status: string; result: string | null }>
1023 >();
1024 const state: { roster: ReadonlyArray<unknown> | undefined } = { roster: undefined };
1025 let wake = yield* Deferred.make<void>();
1026 yield* runtime.events.pipe(
1027 Stream.tap((event) =>
1028 Effect.gen(function* () {
1029 if (event.type === "subagent.updated" && event.subagent.title !== null) {
1030 calls.set(event.subagent.title, {
1031 status: event.subagent.status,
1032 result: event.subagent.result,
1033 });
1034 }
1035 if (event.type === "provider_thread.updated" && event.providerThread.id === thread.id) {
1036 state.roster = event.providerThread.pendingBackgroundTasks;
1037 }
1038 const id =
1039 event.type === "provider_turn.updated"
1040 ? event.providerTurn.nativeTurnRef?.nativeId
1041 : undefined;
1042 if (
1043 event.type === "provider_turn.updated" &&
1044 id?.startsWith(`${CHILD}:turn:`) === true
1045 ) {
1046 if (!childTurns.has(id)) {
1047 callsAtChildTurnStart.set(id, Object.fromEntries(calls));
1048 }
1049 childTurns.set(id, event.providerTurn.status);
1050 }
1051 yield* Deferred.succeed(wake, undefined);
1052 }),
1053 ),
1054 Stream.runDrain,
1055 Effect.forkScoped,
1056 );
1057 const until = (check: () => boolean): Effect.Effect<void> =>
1058 Effect.suspend(() => {
1059 if (check()) return Effect.void;
1060 return Effect.gen(function* () {
1061 wake = yield* Deferred.make<void>();
1062 if (check()) return;
1063 yield* Deferred.await(wake);
1064 yield* until(check);
1065 });
1066 });
1067 return { calls, callsAtChildTurnStart, childTurns, state, until };
1068 });
1069 const launchedAndRunning = [
1070 ...backgroundLaunch(CHILD),
1071 returnedRunning("call-background"),
1072 event("session.execution.started", { sessionID: CHILD }),
1073 ];
1074
1075 it.effect("settles every call that joined a running subagent with its one report", () =>
1076 Effect.gen(function* () {
1077 const { runtime, thread } = yield* resumed([
1078 ...launchedAndRunning,
1079 // OpenCode steers the running subagent and joins its run: one report answers both calls.
1080 ...callAgain({ background: true }),
1081 event("session.execution.succeeded", { sessionID: SESSION }),
1082 event("session.execution.succeeded", { sessionID: CHILD }),
1083 childReport("msg_report", "CHILD_OK"),
1084 ]);
1085 const watch = yield* watchCalls(runtime, thread);
1086 yield* runtime.startTurn(withLineage(thread));
1087 yield* watch.until(
1088 () =>
1089 watch.calls.get("Again")?.status === "completed" &&
1090 watch.calls.get("Sleep")?.status === "completed" &&
1091 watch.state.roster?.length === 0,
1092 );
1093 assert.deepEqual(Object.fromEntries(watch.calls), {
1094 Sleep: { status: "completed", result: "CHILD_OK" },
1095 Again: { status: "completed", result: "CHILD_OK" },
1096 });
1097 }).pipe(Effect.scoped),
1098 );
1099
1100 it.effect("keeps a foreground call that joined a background run for that run's report", () =>
1101 Effect.gen(function* () {
1102 const { runtime, thread } = yield* resumed([
1103 ...launchedAndRunning,
1104 // Without `background`, the call joins the run in the background all the same.
1105 ...callAgain({}),
1106 event("session.execution.succeeded", { sessionID: SESSION }),
1107 event("session.execution.succeeded", { sessionID: CHILD }),
1108 childReport("msg_report", "CHILD_OK"),
1109 ]);
1110 const watch = yield* watchCalls(runtime, thread);
1111 yield* runtime.startTurn(withLineage(thread));
1112 yield* watch.until(
1113 () =>
1114 watch.calls.get("Again")?.status === "completed" &&
1115 watch.calls.get("Sleep")?.status === "completed" &&
1116 watch.state.roster?.length === 0,
1117 );
1118 // Not settled empty when the turn that made it ended.
1119 assert.deepEqual(watch.calls.get("Again"), { status: "completed", result: "CHILD_OK" });
1120 }).pipe(Effect.scoped),
1121 );
1122
1123 it.effect("keeps a call to a finished subagent for its own run's report", () =>
1124 Effect.gen(function* () {
1125 const { runtime, thread } = yield* resumed([
1126 ...launchedAndRunning,
1127 event("session.execution.succeeded", { sessionID: CHILD }),
1128 // Called again after it ended but before its report: the call starts a new run.
1129 ...callAgain({ background: true }),
1130 childReport("msg_report_1", "FIRST_OK"),
1131 event("session.execution.started", { sessionID: CHILD }),
1132 event("session.execution.succeeded", { sessionID: CHILD }),
1133 event("session.execution.succeeded", { sessionID: SESSION }),
1134 childReport("msg_report_2", "SECOND_OK"),
1135 ]);
1136 const watch = yield* watchCalls(runtime, thread);
1137 yield* runtime.startTurn(withLineage(thread));
1138 // The new run's turn starts after the first report was handled.
1139 yield* watch.until(() => watch.childTurns.has(`${CHILD}:turn:2`));
1140 assert.deepEqual(watch.callsAtChildTurnStart.get(`${CHILD}:turn:2`), {
1141 Sleep: { status: "completed", result: "FIRST_OK" },
1142 Again: { status: "running", result: null },
1143 });
1144 yield* watch.until(() => watch.calls.get("Again")?.status === "completed");
1145 assert.deepEqual(watch.calls.get("Again"), { status: "completed", result: "SECOND_OK" });
1146 }).pipe(Effect.scoped),
1147 );
1148
956 it.effect("leaves no background work pending once a reconnect finds its subagent gone", () =>1149 it.effect("leaves no background work pending once a reconnect finds its subagent gone", () =>
957 Effect.gen(function* () {1150 Effect.gen(function* () {
958 // The subagent ends while the stream is down: its end, its report and1151 // The subagent ends while the stream is down: its end, its report and
packages/provider-opencode/src/server/v2/adapter.ts+29-14
...@@ -291,6 +291,8 @@ interface SubagentCall {...@@ -291,6 +291,8 @@ interface SubagentCall {
291 model: string | null;291 model: string | null;
292 /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */292 /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */
293 background: boolean;293 background: boolean;
294 /** The running call whose subagent run this call joined, so that run's one report settles both. */
295 joined: SubagentCall | undefined;
294 child: ThreadState | undefined;296 child: ThreadState | undefined;
295 status: OrchestrationV2Subagent["status"];297 status: OrchestrationV2Subagent["status"];
296 result: string | null;298 result: string | null;
...@@ -1493,6 +1495,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -1493,6 +1495,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
1493 // ran (each one's native id is its own), its grants and rules, and the1495 // ran (each one's native id is its own), its grants and rules, and the
1494 // background work an earlier call left running, which Stop must reach.1496 // background work an earlier call left running, which Stop must reach.
1495 const previous = threads.get(childId);1497 const previous = threads.get(childId);
1498 // Called again while it runs, the subagent takes the prompt as a steer
1499 // and OpenCode reports that run once, for every call that joined it.
1500 if (previous?.active !== undefined) {
1501 call.joined = [...call.state.calls.values()].findLast(
1502 (candidate) =>
1503 candidate !== call && candidate.child === previous && candidate.joined === undefined,
1504 );
1505 }
1496 const subagent = {1506 const subagent = {
1497 call,1507 call,
1498 appThread,1508 appThread,
...@@ -2179,6 +2189,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -2179,6 +2189,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
2179 agent: undefined,2189 agent: undefined,
2180 model: null,2190 model: null,
2181 background: false,2191 background: false,
2192 joined: undefined,
2182 child: undefined,2193 child: undefined,
2183 status: "running",2194 status: "running",
2184 result: null,2195 result: null,
...@@ -2244,8 +2255,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -2244,8 +2255,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
2244 if (call !== undefined) {2255 if (call !== undefined) {
2245 const childId = stringField(event.data.metadata, "sessionID");2256 const childId = stringField(event.data.metadata, "sessionID");
2246 if (childId !== undefined) yield* attachChild(call, childId);2257 if (childId !== undefined) yield* attachChild(call, childId);
2247 // A background call returns at launch; its report settles it.2258 // A background call returns at launch, and so does one that joined a
2248 if (event.data.metadata?.["status"] === "running") return;2259 // background run; the report settles it.
2260 if (event.data.metadata?.["status"] === "running") {
2261 if (call.background) return;
2262 call.background = true;
2263 return yield* emitSubagent(call);
2264 }
2249 return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content)));2265 return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content)));
2250 }2266 }
2251 const output = textOf(event.data.content);2267 const output = textOf(event.data.content);
...@@ -2383,6 +2399,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -2383,6 +2399,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
2383 const call = [...state.calls.values()].find(2399 const call = [...state.calls.values()].find(
2384 (candidate) => candidate.child?.sessionId === childId,2400 (candidate) => candidate.child?.sessionId === childId,
2385 );2401 );
2402 const calls = [...state.calls.values()].filter(
2403 (candidate) => candidate === call || (call !== undefined && candidate.joined === call),
2404 );
2386 const outcome = reportOutcome(stringField(payload.metadata, "state"));2405 const outcome = reportOutcome(stringField(payload.metadata, "state"));
2387 state.reports.set(inboxId, {2406 state.reports.set(inboxId, {
2388 inboxId,2407 inboxId,
...@@ -2395,18 +2414,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -2395,18 +2414,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
2395 outcome,2414 outcome,
2396 },2415 },
2397 });2416 });
2398 if (call === undefined) return;2417 const status = state.stoppedChildren.has(childId)
2399 yield* settleCall(2418 ? "interrupted"
2400 call,2419 : outcome === "failed"
2401 state.stoppedChildren.has(childId)2420 ? "failed"
2402 ? "interrupted"2421 : outcome === "cancelled"
2403 : outcome === "failed"2422 ? "cancelled"
2404 ? "failed"2423 : "completed";
2405 : outcome === "cancelled"2424 for (const each of calls) yield* settleCall(each, status, subagentOutput(payload.text));
2406 ? "cancelled"
2407 : "completed",
2408 subagentOutput(payload.text),
2409 );
2410 });2425 });
24112426
2412 /**2427 /**