authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 05:41:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 05:41:32-07:00
log01f3e50eca5102ccd881de6f942a98fe6a518ad4
treeac5ae9ace2bc0f97029793d703ccf19bc977747b
parentf0347322441f3b8e473a8d13ea7006cbcb4fb761
signature Signed by PGP key B5690EEEBB952194

fix(server): unblock OpenCode approvals and stop (#9653)

OpenCode could show an Approval badge with no controls, appear stuck on TodoWrite, and keep showing a running turn after Stop. - Show every permission, including old saved requests. Keep failed replies retryable and close completed requests even when reply events are lost. - Keep OpenCode output pipes drained and automatic replies out of the event loop. Handle disconnects, reconnects, and confirmed stops without stale requests or running states. - Show native task progress and command results. Do not treat TodoWrite or approval history as file edits or executed commands. - Ignore late aborts and task updates after a turn finishes. Fixes #4795 Fixes #7113 Fixes #5760 Created with GPT-6 Astra (preview) in Codex. Reviewed and merged with Claude Fable 5.1 in Claude Code.

17 files changed, 1935 insertions(+), 218 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+62
......@@ -263,6 +263,68 @@ describe("pending user input answers", () => {
263263});
264264
265265describe("pending approvals", () => {
266 it.each([{}, { requestType: "unknown" }])(
267 "exposes legacy OpenCode approvals without a known request kind: %j",
268 (legacyPayload) => {
269 const requested = makeActivity({
270 id: EventId.make("approval-legacy"),
271 kind: "approval.requested",
272 summary: "Approval requested",
273 createdAt: "2026-08-24T00:00:00.000Z",
274 payload: { requestId: "per-legacy", detail: "*", ...legacyPayload },
275 });
276
277 expect(derivePendingApprovals([requested])).toEqual([
278 {
279 requestId: "per-legacy",
280 requestKind: "command",
281 createdAt: requested.createdAt,
282 detail: "*",
283 },
284 ]);
285 },
286 );
287
288 it.each(["tool_user_input", "auth_tokens_refresh"])(
289 "does not turn %s into an approval",
290 (requestType) => {
291 const activity = makeActivity({
292 id: EventId.make("approval-non-approval"),
293 kind: "approval.requested",
294 summary: "Approval requested",
295 createdAt: "2026-08-24T00:00:00.000Z",
296 payload: { requestId: "not-an-approval", requestType },
297 });
298
299 expect(derivePendingApprovals([activity])).toEqual([]);
300 },
301 );
302
303 it.each(["approval.resolved", "provider.approval.respond.failed"])(
304 "removes legacy approvals after %s",
305 (kind) => {
306 const requested = makeActivity({
307 id: EventId.make("approval-legacy-open"),
308 kind: "approval.requested",
309 summary: "Approval requested",
310 createdAt: "2026-08-24T00:00:00.000Z",
311 payload: { requestId: "per-legacy", requestType: "unknown" },
312 });
313 const resolved = makeActivity({
314 id: EventId.make("approval-legacy-resolved"),
315 kind,
316 summary: "Approval resolved",
317 createdAt: "2026-08-24T00:00:01.000Z",
318 payload: {
319 requestId: "per-legacy",
320 detail: "Unknown pending permission request: per-legacy",
321 },
322 });
323
324 expect(derivePendingApprovals([requested, resolved])).toEqual([]);
325 },
326 );
327
266328 it("keeps app access approvals and persistence choices from remote environments", () => {
267329 const options = [
268330 { decision: "decline", label: "Decline" },
apps/mobile/src/lib/threadActivity.ts+8-2
......@@ -1754,10 +1754,16 @@ export function derivePendingApprovals(
17541754 ? payload.options.filter(isProviderApprovalOption)
17551755 : undefined;
17561756
1757 if (activity.kind === "approval.requested" && requestId && requestKind) {
1757 if (
1758 activity.kind === "approval.requested" &&
1759 requestId &&
1760 payload?.requestType !== "tool_user_input" &&
1761 payload?.requestType !== "auth_tokens_refresh"
1762 ) {
17581763 openByRequestId.set(requestId, {
17591764 requestId,
1760 requestKind,
1765 // Older OpenCode requests can have no recognized approval kind.
1766 requestKind: requestKind ?? "command",
17611767 createdAt: activity.createdAt,
17621768 ...(detail ? { detail } : {}),
17631769 ...(appName ? { appName } : {}),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+81-1
......@@ -1,4 +1,5 @@
11import {
2 ApprovalRequestId,
23 CheckpointRef,
34 CommandId,
45 CorrelationId,
......@@ -2744,7 +2745,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
27442745 }),
27452746 );
27462747
2747 it.effect("ignores non-stale provider approval response failures", () =>
2748 it.effect("restores pending approvals when a provider reply fails", () =>
27482749 Effect.gen(function* () {
27492750 const projectionPipeline = yield* OrchestrationProjectionPipeline;
27502751 const eventStore = yield* OrchestrationEventStore;
......@@ -2829,6 +2830,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
28292830 },
28302831 });
28312832
2833 yield* appendAndProject({
2834 type: "thread.approval-response-requested",
2835 eventId: EventId.make("evt-nonstale-approval-response"),
2836 aggregateKind: "thread",
2837 aggregateId: ThreadId.make("thread-nonstale-approval"),
2838 occurredAt: "2026-02-26T12:45:02.500Z",
2839 commandId: CommandId.make("cmd-nonstale-approval-response"),
2840 causationEventId: null,
2841 correlationId: CorrelationId.make("cmd-nonstale-approval-response"),
2842 metadata: {},
2843 payload: {
2844 threadId: ThreadId.make("thread-nonstale-approval"),
2845 requestId: ApprovalRequestId.make("approval-request-nonstale-existing"),
2846 decision: "accept",
2847 createdAt: "2026-02-26T12:45:02.500Z",
2848 },
2849 });
2850
28322851 yield* appendAndProject({
28332852 type: "thread.activity-appended",
28342853 eventId: EventId.make("evt-nonstale-approval-4"),
......@@ -2921,6 +2940,67 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
29212940 WHERE thread_id = 'thread-nonstale-approval'
29222941 `;
29232942 assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]);
2943
2944 yield* appendAndProject({
2945 type: "thread.activity-appended",
2946 eventId: EventId.make("evt-nonstale-approval-resolved"),
2947 aggregateKind: "thread",
2948 aggregateId: ThreadId.make("thread-nonstale-approval"),
2949 occurredAt: "2026-02-26T12:45:05.000Z",
2950 commandId: CommandId.make("cmd-nonstale-approval-resolved"),
2951 causationEventId: null,
2952 correlationId: CorrelationId.make("cmd-nonstale-approval-resolved"),
2953 metadata: {},
2954 payload: {
2955 threadId: ThreadId.make("thread-nonstale-approval"),
2956 activity: {
2957 id: EventId.make("activity-nonstale-approval-resolved"),
2958 tone: "approval",
2959 kind: "approval.resolved",
2960 summary: "Approval resolved",
2961 payload: {
2962 requestId: "approval-request-nonstale-existing",
2963 decision: "accept",
2964 },
2965 turnId: null,
2966 createdAt: "2026-02-26T12:45:05.000Z",
2967 },
2968 },
2969 });
2970
2971 yield* appendAndProject({
2972 type: "thread.activity-appended",
2973 eventId: EventId.make("evt-nonstale-approval-late-failure"),
2974 aggregateKind: "thread",
2975 aggregateId: ThreadId.make("thread-nonstale-approval"),
2976 occurredAt: "2026-02-26T12:45:06.000Z",
2977 commandId: CommandId.make("cmd-nonstale-approval-late-failure"),
2978 causationEventId: null,
2979 correlationId: CorrelationId.make("cmd-nonstale-approval-late-failure"),
2980 metadata: {},
2981 payload: {
2982 threadId: ThreadId.make("thread-nonstale-approval"),
2983 activity: {
2984 id: EventId.make("activity-nonstale-approval-late-failure"),
2985 tone: "error",
2986 kind: "provider.approval.respond.failed",
2987 summary: "Provider approval response failed",
2988 payload: {
2989 requestId: "approval-request-nonstale-existing",
2990 detail: "Provider timed out while responding to approval request",
2991 },
2992 turnId: null,
2993 createdAt: "2026-02-26T12:45:06.000Z",
2994 },
2995 },
2996 });
2997
2998 const resolvedThreadRows = yield* sql<{ readonly pendingApprovalCount: number }>`
2999 SELECT pending_approval_count AS "pendingApprovalCount"
3000 FROM projection_threads
3001 WHERE thread_id = 'thread-nonstale-approval'
3002 `;
3003 assert.deepEqual(resolvedThreadRows, [{ pendingApprovalCount: 0 }]);
29243004 }),
29253005 );
29263006
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+38
......@@ -1728,6 +1728,44 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
17281728 });
17291729 return;
17301730 }
1731 if (Option.isNone(existingRow) || existingRow.value.status !== "resolved") {
1732 return;
1733 }
1734
1735 // Sending a reply clears the badge before the provider accepts it.
1736 // A failed reply must restore the request unless a terminal event
1737 // already closed it, including a reply from another client.
1738 const requestActivities = (yield* projectionThreadActivityRepository.listByThreadId({
1739 threadId: existingRow.value.threadId,
1740 })).filter((activity) => extractActivityRequestId(activity.payload) === requestId);
1741 const wasRequested = requestActivities.some(
1742 (activity) => activity.kind === "approval.requested",
1743 );
1744 const wasResolved = requestActivities.some((activity) => {
1745 if (activity.kind === "approval.resolved") {
1746 return true;
1747 }
1748 if (activity.kind !== "provider.approval.respond.failed") {
1749 return false;
1750 }
1751 const activityPayload =
1752 typeof activity.payload === "object" && activity.payload !== null
1753 ? (activity.payload as Record<string, unknown>)
1754 : null;
1755 return isStalePendingApprovalFailureDetail(
1756 typeof activityPayload?.detail === "string"
1757 ? activityPayload.detail.toLowerCase()
1758 : null,
1759 );
1760 });
1761 if (wasRequested && !wasResolved) {
1762 yield* projectionPendingApprovalRepository.upsert({
1763 ...existingRow.value,
1764 status: "pending",
1765 decision: null,
1766 resolvedAt: null,
1767 });
1768 }
17311769 return;
17321770 }
17331771 // Only approval-requested activities should create pending-approval
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+190
......@@ -371,6 +371,196 @@ describe("ProviderRuntimeIngestion", () => {
371371 expect(thread.session?.lastError).toBe("turn failed");
372372 });
373373
374 it.each([
375 { delivery: "buffered", enableLegacyTokenStreaming: false },
376 { delivery: "streamed", enableLegacyTokenStreaming: true },
377 ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => {
378 const harness = await createHarness({
379 serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming },
380 });
381 const threadId = asThreadId("thread-1");
382 const turnId = asTurnId("opencode-aborted-turn");
383 const base = {
384 provider: ProviderDriverKind.make("opencode"),
385 threadId,
386 turnId,
387 createdAt: "2026-01-01T00:00:01.000Z",
388 };
389 harness.emit({ ...base, type: "turn.started", eventId: asEventId("opencode-started") });
390 harness.emit({
391 ...base,
392 type: "content.delta",
393 eventId: asEventId("opencode-partial-text"),
394 itemId: asItemId("opencode-text-part"),
395 payload: { streamKind: "assistant_text", delta: "Work before the stop." },
396 });
397 harness.emit({
398 ...base,
399 type: "turn.aborted",
400 eventId: asEventId("opencode-aborted"),
401 createdAt: "2026-01-01T00:00:02.000Z",
402 payload: { reason: "Interrupted by user." },
403 });
404
405 await harness.drain();
406 const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId);
407 expect(thread?.session).toMatchObject({
408 status: "interrupted",
409 activeTurnId: null,
410 lastError: null,
411 });
412 expect(thread?.latestTurn).toMatchObject({
413 turnId,
414 state: "interrupted",
415 completedAt: "2026-01-01T00:00:02.000Z",
416 });
417 expect(thread?.messages).toEqual([
418 expect.objectContaining({
419 role: "assistant",
420 turnId,
421 text: "Work before the stop.",
422 streaming: false,
423 }),
424 ]);
425 });
426
427 it.each([
428 { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") },
429 { source: "an unspecified turn", turnId: undefined },
430 ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => {
431 const harness = await createHarness({
432 serverSettings: { enableLegacyTokenStreaming: true },
433 });
434 const threadId = asThreadId("thread-1");
435 const stoppedTurnId = asTurnId("opencode-stopped-turn");
436 const nextTurnId = asTurnId("opencode-next-turn");
437 const base = {
438 provider: ProviderDriverKind.make("opencode"),
439 threadId,
440 createdAt: "2026-01-01T00:00:01.000Z",
441 };
442 harness.emit({
443 ...base,
444 type: "turn.started",
445 eventId: asEventId("opencode-first-started"),
446 turnId: stoppedTurnId,
447 });
448 harness.emit({
449 ...base,
450 type: "turn.aborted",
451 eventId: asEventId("opencode-first-aborted"),
452 turnId: stoppedTurnId,
453 payload: { reason: "Interrupted by user." },
454 });
455 harness.emit({
456 ...base,
457 type: "turn.started",
458 eventId: asEventId("opencode-next-started"),
459 turnId: nextTurnId,
460 });
461 harness.emit({
462 ...base,
463 type: "content.delta",
464 eventId: asEventId("opencode-next-partial-text"),
465 turnId: nextTurnId,
466 itemId: asItemId("opencode-next-text-part"),
467 payload: { streamKind: "assistant_text", delta: "The next turn is running." },
468 });
469 await harness.drain();
470
471 harness.emit({
472 ...base,
473 type: "turn.aborted",
474 eventId: asEventId("opencode-late-abort"),
475 ...(lateAbort.turnId ? { turnId: lateAbort.turnId } : {}),
476 payload: { reason: "Interrupted by user." },
477 });
478 await harness.drain();
479
480 const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId);
481 expect(thread?.session).toMatchObject({ status: "running", activeTurnId: nextTurnId });
482 expect(thread?.latestTurn).toMatchObject({ turnId: nextTurnId, state: "running" });
483 expect(thread?.messages).toEqual([
484 expect.objectContaining({
485 turnId: nextTurnId,
486 text: "The next turn is running.",
487 streaming: true,
488 }),
489 ]);
490
491 harness.emit({
492 ...base,
493 type: "turn.completed",
494 eventId: asEventId("opencode-next-completed"),
495 turnId: nextTurnId,
496 createdAt: "2026-01-01T00:00:02.000Z",
497 payload: { state: "completed" },
498 });
499 await harness.drain();
500
501 const pendingAt = "2026-01-01T00:00:03.000Z";
502 for (const hasPendingStart of [false, true]) {
503 if (hasPendingStart) {
504 await harness.dispatch({
505 type: "thread.turn.start",
506 commandId: CommandId.make("opencode-pending-start"),
507 threadId,
508 message: {
509 messageId: asMessageId("opencode-pending-message"),
510 role: "user",
511 text: "Start another turn.",
512 attachments: [],
513 },
514 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
515 runtimeMode: "approval-required",
516 createdAt: pendingAt,
517 });
518 harness.emit({
519 ...base,
520 type: "session.state.changed",
521 eventId: asEventId("opencode-pending-starting"),
522 createdAt: pendingAt,
523 payload: { state: "starting" },
524 });
525 }
526 harness.emit({
527 ...base,
528 type: "turn.aborted",
529 eventId: asEventId(`opencode-late-abort-after-completion-${hasPendingStart}`),
530 ...(lateAbort.turnId ? { turnId: lateAbort.turnId } : {}),
531 createdAt: "2026-01-01T00:00:04.000Z",
532 payload: { reason: "Interrupted by user." },
533 });
534 await harness.drain();
535
536 const completedThread = (await harness.readModel()).threads.find(
537 (entry) => entry.id === threadId,
538 );
539 expect(completedThread?.session).toMatchObject({
540 status: hasPendingStart ? "starting" : "ready",
541 activeTurnId: null,
542 });
543 expect(completedThread?.latestTurn).toMatchObject({ turnId: nextTurnId, state: "completed" });
544 }
545
546 harness.emit({
547 ...base,
548 type: "turn.started",
549 eventId: asEventId("opencode-pending-started"),
550 turnId: asTurnId("opencode-pending-turn"),
551 createdAt: "2026-01-01T00:00:05.000Z",
552 });
553 await harness.drain();
554 const startedThread = (await harness.readModel()).threads.find(
555 (entry) => entry.id === threadId,
556 );
557 expect(startedThread?.latestTurn).toMatchObject({
558 turnId: asTurnId("opencode-pending-turn"),
559 state: "running",
560 requestedAt: pendingAt,
561 });
562 });
563
374564 it("applies provider session.state.changed transitions directly", async () => {
375565 const harness = await createHarness();
376566 const waitingAt = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+14-14
......@@ -1578,6 +1578,7 @@ const make = Effect.gen(function* () {
15781578 const now = event.createdAt;
15791579 const eventTurnId = toTurnId(event.turnId);
15801580 const activeTurnId = thread.session?.activeTurnId ?? null;
1581 const isTerminalTurn = event.type === "turn.completed" || event.type === "turn.aborted";
15811582 const isCompactedThreadState =
15821583 event.type === "thread.state.changed" && event.payload.state === "compacted";
15831584 const pendingTurnStart =
......@@ -1586,7 +1587,7 @@ const make = Effect.gen(function* () {
15861587 event.type === "session.exited" ||
15871588 event.type === "thread.started" ||
15881589 event.type === "turn.started" ||
1589 event.type === "turn.completed" ||
1590 isTerminalTurn ||
15901591 isCompactedThreadState
15911592 ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({
15921593 threadId: thread.id,
......@@ -1624,6 +1625,7 @@ const make = Effect.gen(function* () {
16241625 case "turn.started":
16251626 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;
16261627 case "turn.completed":
1628 case "turn.aborted":
16271629 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
16281630 return false;
16291631 }
......@@ -1631,14 +1633,10 @@ const make = Effect.gen(function* () {
16311633 if (activeTurnId !== null && eventTurnId !== undefined) {
16321634 return sameId(activeTurnId, eventTurnId);
16331635 }
1634 // No active turn tracked: accept only completions that name their
1635 // turn (covers a real completion whose turn.started was lost). An
1636 // untargeted completion cannot prove it belongs to any turn this
1637 // thread ran — the known emitter was the Claude resume handshake
1638 // (system/init + result(num_turns: 0)), which is not a turn at
1639 // all — and applying it here stomps the "starting" lifecycle
1640 // state while a turn start is pending.
1641 return eventTurnId !== undefined;
1636 // A named completion can recover a lost turn.started event.
1637 // An abort needs an active turn so a delayed stop cannot replace
1638 // a ready session or clear a newer pending start.
1639 return event.type === "turn.completed" && eventTurnId !== undefined;
16421640 default:
16431641 return true;
16441642 }
......@@ -1654,7 +1652,7 @@ const make = Effect.gen(function* () {
16541652 event.type === "session.exited" ||
16551653 event.type === "thread.started" ||
16561654 event.type === "turn.started" ||
1657 event.type === "turn.completed"
1655 isTerminalTurn
16581656 ) {
16591657 const status = (() => {
16601658 switch (event.type) {
......@@ -1666,6 +1664,8 @@ const make = Effect.gen(function* () {
16661664 return "running";
16671665 case "session.exited":
16681666 return "stopped";
1667 case "turn.aborted":
1668 return "interrupted";
16691669 case "turn.completed":
16701670 return normalizeRuntimeTurnState(event.payload.state) === "failed"
16711671 ? "error"
......@@ -1680,7 +1680,7 @@ const make = Effect.gen(function* () {
16801680 const nextActiveTurnId =
16811681 event.type === "turn.started"
16821682 ? (eventTurnId ?? null)
1683 : event.type === "turn.completed" || event.type === "session.exited"
1683 : isTerminalTurn || event.type === "session.exited"
16841684 ? null
16851685 : event.type === "session.state.changed" &&
16861686 !sessionStatusAllowsActiveTurn(
......@@ -1694,7 +1694,7 @@ const make = Effect.gen(function* () {
16941694 : event.type === "turn.completed" &&
16951695 normalizeRuntimeTurnState(event.payload.state) === "failed"
16961696 ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed")
1697 : status === "ready"
1697 : status === "ready" || status === "interrupted"
16981698 ? null
16991699 : (thread.session?.lastError ?? null);
17001700
......@@ -1922,7 +1922,7 @@ const make = Effect.gen(function* () {
19221922 });
19231923 }
19241924
1925 if (event.type === "turn.completed") {
1925 if (isTerminalTurn) {
19261926 const detailedThread = yield* getLoadedThreadDetail();
19271927 const messages = detailedThread?.messages ?? [];
19281928 const proposedPlans = detailedThread?.proposedPlans ?? [];
......@@ -2055,7 +2055,7 @@ const make = Effect.gen(function* () {
20552055 } else if (!conflictsWithActiveTurn) {
20562056 if (event.type === "turn.plan.updated") {
20572057 threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan);
2058 } else if (event.type === "turn.completed" || event.type === "turn.aborted") {
2058 } else if (isTerminalTurn && shouldApplyThreadLifecycle) {
20592059 threadPlanProgress.clearThreadPlanProgress(thread.id);
20602060 }
20612061 }
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+817-69
......@@ -15,7 +15,11 @@ import * as Scope from "effect/Scope";
1515import * as Stream from "effect/Stream";
1616import * as TestClock from "effect/testing/TestClock";
1717import { beforeEach } from "vite-plus/test";
18import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
18import type {
19 Event as OpenCodeEvent,
20 PermissionRequest,
21 QuestionRequest,
22} from "@opencode-ai/sdk/v2";
1923
2024import {
2125 ApprovalRequestId,
......@@ -85,23 +89,30 @@ const runtimeMock = {
8589 promptAsyncImplementation: null as (() => Promise<void>) | null,
8690 autoPromptEcho: true,
8791 autoConnect: true,
92 endEventStream: false,
8893 promptEchoEvents: [] as Array<unknown>,
8994 closeError: null as Error | null,
9095 messages: [] as MessageEntry[],
9196 subscribedEvents: [] as Array<unknown | Promise<unknown>>,
9297 eventSubscribeObserved: null as (() => void) | null,
98 eventStreamError: null as ((cause: unknown) => void) | null,
9399 permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>,
94 permissionReplyImplementation: null as (() => Promise<void>) | null,
100 permissionReplyImplementation: null as ((signal?: AbortSignal) => Promise<void>) | null,
101 permissionReplySignals: [] as AbortSignal[],
95102 questionReplyCalls: [] as Array<{
96103 requestID: string;
97104 answers: ReadonlyArray<ReadonlyArray<string>>;
98105 }>,
106 questionReplyImplementation: null as ((signal?: AbortSignal) => Promise<void>) | null,
99107 sessionStatus: "idle" as "idle" | "busy",
100108 sessionStatusFailures: 0,
101109 sessionStatusCalls: 0,
102110 sessionStatusImplementation: null as (() => Promise<unknown>) | null,
103111 sessionGetIds: [] as string[],
104112 sessionGetObserved: null as ((sessionID: string) => void) | null,
113 sessionGetImplementation: null as
114 | ((sessionID: string, signal?: AbortSignal) => Promise<void>)
115 | null,
105116 missingSessionIds: new Set<string>(),
106117 transientErrorSessionIds: new Set<string>(),
107118 sessionDirectoryById: new Map<string, string>(),
......@@ -137,20 +148,25 @@ const runtimeMock = {
137148 this.state.promptAsyncImplementation = null;
138149 this.state.autoPromptEcho = true;
139150 this.state.autoConnect = true;
151 this.state.endEventStream = false;
140152 this.state.promptEchoEvents.length = 0;
141153 this.state.closeError = null;
142154 this.state.messages = [];
143155 this.state.subscribedEvents = [];
144156 this.state.eventSubscribeObserved = null;
157 this.state.eventStreamError = null;
145158 this.state.permissionReplyCalls.length = 0;
146159 this.state.permissionReplyImplementation = null;
160 this.state.permissionReplySignals.length = 0;
147161 this.state.questionReplyCalls.length = 0;
162 this.state.questionReplyImplementation = null;
148163 this.state.sessionStatus = "idle";
149164 this.state.sessionStatusFailures = 0;
150165 this.state.sessionStatusCalls = 0;
151166 this.state.sessionStatusImplementation = null;
152167 this.state.sessionGetIds.length = 0;
153168 this.state.sessionGetObserved = null;
169 this.state.sessionGetImplementation = null;
154170 this.state.missingSessionIds.clear();
155171 this.state.transientErrorSessionIds.clear();
156172 this.state.sessionDirectoryById.clear();
......@@ -222,9 +238,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
222238 data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` },
223239 };
224240 },
225 get: async ({ sessionID }: { sessionID: string }) => {
241 get: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => {
226242 runtimeMock.state.sessionGetIds.push(sessionID);
227243 runtimeMock.state.sessionGetObserved?.(sessionID);
244 if (runtimeMock.state.sessionGetImplementation) {
245 await runtimeMock.state.sessionGetImplementation(sessionID, options?.signal);
246 }
228247 // The real client is `throwOnError: true`: non-2xx rejects rather
229248 // than resolving, so missing → 404 throw, transient → 500 throw.
230249 if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) {
......@@ -264,6 +283,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
264283 runtimeMock.state.abortSignals.push(options.signal);
265284 }
266285 await runtimeMock.state.abortImplementation?.(sessionID, options?.signal);
286 runtimeMock.state.pendingPermissions = runtimeMock.state.pendingPermissions.filter(
287 (request) => request.sessionID !== sessionID,
288 );
289 runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter(
290 (request) => request.sessionID !== sessionID,
291 );
267292 },
268293 children: async ({ sessionID }: { sessionID: string }) => {
269294 runtimeMock.state.sessionChildrenCalls.push(sessionID);
......@@ -357,19 +382,60 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
357382 },
358383 },
359384 event: {
360 subscribe: async () => {
385 subscribe: async (
386 _input: unknown,
387 options?: { signal?: AbortSignal; onSseError?: (cause: unknown) => void },
388 ) => {
361389 runtimeMock.state.eventSubscribeObserved?.();
390 runtimeMock.state.eventStreamError = options?.onSseError ?? null;
362391 return {
363392 stream: (async function* () {
364 if (runtimeMock.state.autoConnect) {
365 yield { id: "evt-auto-connected", type: "server.connected", properties: {} };
366 }
367 for (const event of runtimeMock.state.subscribedEvents) {
368 const resolved = await event;
369 while (runtimeMock.state.promptEchoEvents.length > 0) {
370 yield runtimeMock.state.promptEchoEvents.shift();
393 const aborted = promiseWithResolvers<void>();
394 const onAbort = () => aborted.resolve(undefined);
395 options?.signal?.addEventListener("abort", onAbort, { once: true });
396 try {
397 if (runtimeMock.state.autoConnect) {
398 yield { id: "evt-auto-connected", type: "server.connected", properties: {} };
399 }
400 for (const event of runtimeMock.state.subscribedEvents) {
401 if (options?.signal?.aborted) return;
402 const resolved = await Promise.race([event, aborted.promise]);
403 if (options?.signal?.aborted) return;
404 while (runtimeMock.state.promptEchoEvents.length > 0) {
405 yield runtimeMock.state.promptEchoEvents.shift();
406 }
407 const nativeEvent = resolved as OpenCodeEvent;
408 if (nativeEvent.type === "permission.asked") {
409 runtimeMock.state.pendingPermissions =
410 runtimeMock.state.pendingPermissions.filter(
411 (request) => request.id !== nativeEvent.properties.id,
412 );
413 runtimeMock.state.pendingPermissions.push(nativeEvent.properties);
414 } else if (nativeEvent.type === "permission.replied") {
415 runtimeMock.state.pendingPermissions =
416 runtimeMock.state.pendingPermissions.filter(
417 (request) => request.id !== nativeEvent.properties.requestID,
418 );
419 } else if (nativeEvent.type === "question.asked") {
420 runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter(
421 (request) => request.id !== nativeEvent.properties.id,
422 );
423 runtimeMock.state.pendingQuestions.push(nativeEvent.properties);
424 } else if (
425 nativeEvent.type === "question.replied" ||
426 nativeEvent.type === "question.rejected"
427 ) {
428 runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter(
429 (request) => request.id !== nativeEvent.properties.requestID,
430 );
431 }
432 yield resolved;
371433 }
372 yield resolved;
434 if (!runtimeMock.state.endEventStream && !options?.signal?.aborted) {
435 await aborted.promise;
436 }
437 } finally {
438 options?.signal?.removeEventListener("abort", onAbort);
373439 }
374440 })(),
375441 };
......@@ -384,11 +450,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
384450 : runtimeMock.state.pendingPermissions,
385451 };
386452 },
387 reply: async ({ requestID, reply }: { requestID: string; reply: string }) => {
453 reply: async (
454 { requestID, reply }: { requestID: string; reply: string },
455 options?: { signal?: AbortSignal },
456 ) => {
388457 runtimeMock.state.permissionReplyCalls.push({ requestID, reply });
458 if (options?.signal) runtimeMock.state.permissionReplySignals.push(options.signal);
389459 if (runtimeMock.state.permissionReplyImplementation) {
390 await runtimeMock.state.permissionReplyImplementation();
460 await runtimeMock.state.permissionReplyImplementation(options?.signal);
391461 }
462 runtimeMock.state.pendingPermissions = runtimeMock.state.pendingPermissions.filter(
463 (request) => request.id !== requestID,
464 );
392465 },
393466 },
394467 question: {
......@@ -400,14 +473,21 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
400473 : runtimeMock.state.pendingQuestions,
401474 };
402475 },
403 reply: async ({
404 requestID,
405 answers,
406 }: {
407 requestID: string;
408 answers: ReadonlyArray<ReadonlyArray<string>>;
409 }) => {
476 reply: async (
477 {
478 requestID,
479 answers,
480 }: {
481 requestID: string;
482 answers: ReadonlyArray<ReadonlyArray<string>>;
483 },
484 options?: { signal?: AbortSignal },
485 ) => {
410486 runtimeMock.state.questionReplyCalls.push({ requestID, answers });
487 await runtimeMock.state.questionReplyImplementation?.(options?.signal);
488 runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter(
489 (request) => request.id !== requestID,
490 );
411491 },
412492 },
413493 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
......@@ -2576,6 +2656,411 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
25762656 }),
25772657 );
25782658
2659 it.effect.each([
2660 { permission: "external_directory", decision: "accept", reply: "once" },
2661 { permission: "doom_loop", decision: "acceptForSession", reply: "always" },
2662 { permission: "todowrite", decision: "decline", reply: "reject" },
2663 { permission: "webfetch", decision: "cancel", reply: "reject" },
2664 { permission: "custom_tool", decision: "accept", reply: "once" },
2665 ] as const)(
2666 "shows $permission approval and resolves its $decision reply without SSE",
2667 ({ permission, decision, reply }) =>
2668 Effect.gen(function* () {
2669 const adapter = yield* OpenCodeAdapter;
2670 const threadId = asThreadId(`thread-permission-${permission}`);
2671 const request = {
2672 ...permissionRequest(`per_${permission}`, "http://127.0.0.1:9999/session"),
2673 permission,
2674 patterns: ["*"],
2675 };
2676 runtimeMock.state.subscribedEvents = [
2677 {
2678 id: "evt-permission",
2679 type: "permission.asked",
2680 properties: request,
2681 } satisfies OpenCodeEvent,
2682 ];
2683 const openedFiber = yield* adapter.streamEvents.pipe(
2684 Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
2685 Stream.runHead,
2686 Effect.forkChild,
2687 );
2688 yield* adapter.startSession({
2689 provider: ProviderDriverKind.make("opencode"),
2690 threadId,
2691 runtimeMode: "approval-required",
2692 });
2693 const opened = Option.getOrThrow(yield* Fiber.join(openedFiber));
2694 NodeAssert.ok(opened.type === "request.opened");
2695 NodeAssert.equal(opened.payload.requestType, "command_execution_approval");
2696 NodeAssert.equal(opened.payload.detail, permission.replaceAll("_", " "));
2697 NodeAssert.deepEqual(
2698 opened.payload.options?.map((option) => option.label),
2699 ["Allow once", "Allow for workspace", "Deny"],
2700 );
2701 const resolvedFiber = yield* adapter.streamEvents.pipe(
2702 Stream.filter(
2703 (event) => event.threadId === threadId && event.type === "request.resolved",
2704 ),
2705 Stream.runHead,
2706 Effect.forkChild,
2707 );
2708 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), decision);
2709 const resolved = Option.getOrThrow(yield* Fiber.join(resolvedFiber));
2710 NodeAssert.equal(resolved.requestId, request.id);
2711 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), decision);
2712 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [
2713 { requestID: request.id, reply },
2714 ]);
2715 yield* adapter.stopSession(threadId);
2716 }),
2717 );
2718
2719 it.effect("keeps a permission reply retryable after its HTTP request times out", () =>
2720 Effect.gen(function* () {
2721 const adapter = yield* OpenCodeAdapter;
2722 const threadId = asThreadId("thread-permission-timeout");
2723 const request = permissionRequest("per_timeout", "http://127.0.0.1:9999/session");
2724 const replyStarted = promiseWithResolvers<void>();
2725 runtimeMock.state.permissionReplyImplementation = async () => {
2726 replyStarted.resolve(undefined);
2727 await new Promise<void>(() => {});
2728 };
2729 runtimeMock.state.subscribedEvents = [
2730 { id: "evt-ask", type: "permission.asked", properties: request },
2731 ];
2732 const openedFiber = yield* adapter.streamEvents.pipe(
2733 Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
2734 Stream.runHead,
2735 Effect.forkChild,
2736 );
2737 yield* adapter.startSession({
2738 provider: ProviderDriverKind.make("opencode"),
2739 threadId,
2740 runtimeMode: "approval-required",
2741 });
2742 yield* Fiber.join(openedFiber);
2743 const replyFiber = yield* adapter
2744 .respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept")
2745 .pipe(Effect.exit, Effect.forkChild);
2746 yield* Effect.promise(() => replyStarted.promise);
2747 yield* Effect.yieldNow;
2748 yield* advanceTestClock(10_000);
2749 NodeAssert.equal(Exit.isFailure(yield* Fiber.join(replyFiber)), true);
2750 NodeAssert.equal(runtimeMock.state.permissionReplySignals[0]?.aborted, true);
2751 runtimeMock.state.permissionReplyImplementation = null;
2752 const resolvedFiber = yield* adapter.streamEvents.pipe(
2753 Stream.filter((event) => event.threadId === threadId && event.type === "request.resolved"),
2754 Stream.runHead,
2755 Effect.forkChild,
2756 );
2757 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept");
2758 NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(resolvedFiber)).requestId, request.id);
2759 yield* adapter.stopSession(threadId);
2760 }),
2761 );
2762
2763 it.effect("keeps a recovering permission retryable until its native request is loaded", () =>
2764 Effect.gen(function* () {
2765 const adapter = yield* OpenCodeAdapter;
2766 const threadId = asThreadId("thread-permission-recovering");
2767 const request = permissionRequest("per_recovering", "ses_resumed");
2768 const listStarted = promiseWithResolvers<void>();
2769 const releaseList = promiseWithResolvers<PermissionRequest[]>();
2770 runtimeMock.state.permissionListImplementation = async () => {
2771 listStarted.resolve(undefined);
2772 return await releaseList.promise;
2773 };
2774 yield* adapter.startSession({
2775 provider: ProviderDriverKind.make("opencode"),
2776 threadId,
2777 runtimeMode: "approval-required",
2778 resumeCursor: { schemaVersion: 1, sessionId: request.sessionID },
2779 });
2780 yield* Effect.promise(() => listStarted.promise);
2781 const reply = yield* adapter
2782 .respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept")
2783 .pipe(Effect.result);
2784 NodeAssert.equal(reply._tag, "Failure");
2785 if (reply._tag === "Failure" && reply.failure._tag === "ProviderAdapterRequestError") {
2786 NodeAssert.match(reply.failure.detail, /still loading/);
2787 }
2788 const openedFiber = yield* adapter.streamEvents.pipe(
2789 Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
2790 Stream.runHead,
2791 Effect.forkChild,
2792 );
2793 releaseList.resolve([request]);
2794 yield* Fiber.join(openedFiber);
2795 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept");
2796 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [
2797 { requestID: request.id, reply: "once" },
2798 ]);
2799 yield* adapter.stopSession(threadId);
2800 }),
2801 );
2802
2803 it.effect("closes missing permissions and questions after reconnect", () =>
2804 Effect.gen(function* () {
2805 const adapter = yield* OpenCodeAdapter;
2806 const threadId = asThreadId("thread-missing-requests");
2807 const sessionID = "http://127.0.0.1:9999/session";
2808 const reconnect = promiseWithResolvers<unknown>();
2809 runtimeMock.state.subscribedEvents = [
2810 {
2811 id: "evt-permission",
2812 type: "permission.asked",
2813 properties: permissionRequest("per_missing", sessionID),
2814 },
2815 {
2816 id: "evt-question",
2817 type: "question.asked",
2818 properties: questionRequest("que_missing", sessionID),
2819 },
2820 reconnect.promise,
2821 ];
2822 const openedFiber = yield* adapter.streamEvents.pipe(
2823 Stream.filter(
2824 (event) =>
2825 event.threadId === threadId &&
2826 (event.type === "request.opened" || event.type === "user-input.requested"),
2827 ),
2828 Stream.take(2),
2829 Stream.runCollect,
2830 Effect.forkChild,
2831 );
2832 yield* adapter.startSession({
2833 provider: ProviderDriverKind.make("opencode"),
2834 threadId,
2835 runtimeMode: "approval-required",
2836 });
2837 yield* Fiber.join(openedFiber);
2838 const resolvedFiber = yield* adapter.streamEvents.pipe(
2839 Stream.filter(
2840 (event) =>
2841 event.threadId === threadId &&
2842 (event.type === "request.resolved" || event.type === "user-input.resolved"),
2843 ),
2844 Stream.take(2),
2845 Stream.runCollect,
2846 Effect.forkChild,
2847 );
2848 runtimeMock.state.pendingPermissions = [];
2849 runtimeMock.state.pendingQuestions = [];
2850 reconnect.resolve({ id: "evt-reconnected", type: "server.connected", properties: {} });
2851 const resolved = yield* Fiber.join(resolvedFiber);
2852 NodeAssert.deepEqual(
2853 resolved.map((event) => event.requestId),
2854 ["per_missing", "que_missing"],
2855 );
2856 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []);
2857 NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, []);
2858 yield* adapter.stopSession(threadId);
2859 }),
2860 );
2861
2862 it.effect("closes pending requests after Stop and ignores late requests from that turn", () =>
2863 Effect.gen(function* () {
2864 const adapter = yield* OpenCodeAdapter;
2865 const threadId = asThreadId("thread-stop-requests");
2866 const sessionID = "http://127.0.0.1:9999/session";
2867 const startRequests = promiseWithResolvers<unknown>();
2868 const lateRequests = promiseWithResolvers<unknown>();
2869 runtimeMock.state.subscribedEvents = [
2870 startRequests.promise,
2871 {
2872 id: "evt-question",
2873 type: "question.asked",
2874 properties: questionRequest("que_stop", sessionID),
2875 },
2876 lateRequests.promise,
2877 {
2878 id: "evt-late-question",
2879 type: "question.asked",
2880 properties: questionRequest("que_late", sessionID),
2881 },
2882 { id: "evt-drained", type: "session.compacted", properties: { sessionID } },
2883 ];
2884 yield* adapter.startSession({
2885 provider: ProviderDriverKind.make("opencode"),
2886 threadId,
2887 runtimeMode: "approval-required",
2888 });
2889 const turn = yield* adapter.sendTurn({
2890 threadId,
2891 input: "Work",
2892 modelSelection: createModelSelection(
2893 ProviderInstanceId.make("opencode"),
2894 "opencode/kimi-k3",
2895 ),
2896 });
2897 const openedFiber = yield* adapter.streamEvents.pipe(
2898 Stream.filter(
2899 (event) =>
2900 event.threadId === threadId &&
2901 (event.type === "request.opened" || event.type === "user-input.requested"),
2902 ),
2903 Stream.take(2),
2904 Stream.runCollect,
2905 Effect.forkChild,
2906 );
2907 startRequests.resolve({
2908 id: "evt-permission",
2909 type: "permission.asked",
2910 properties: permissionRequest("per_stop", sessionID),
2911 });
2912 yield* Fiber.join(openedFiber);
2913 const stoppedFiber = yield* adapter.streamEvents.pipe(
2914 Stream.filter((event) => event.threadId === threadId),
2915 Stream.takeUntil((event) => event.type === "turn.aborted"),
2916 Stream.runCollect,
2917 Effect.forkChild,
2918 );
2919 yield* adapter.interruptTurn(threadId, turn.turnId);
2920 const stopped = yield* Fiber.join(stoppedFiber);
2921 NodeAssert.deepEqual(
2922 stopped.map((event) => event.type),
2923 ["request.resolved", "user-input.resolved", "turn.aborted"],
2924 );
2925 const lateFiber = yield* adapter.streamEvents.pipe(
2926 Stream.filter((event) => event.threadId === threadId),
2927 Stream.takeUntil((event) => event.type === "thread.state.changed"),
2928 Stream.runCollect,
2929 Effect.forkChild,
2930 );
2931 lateRequests.resolve({
2932 id: "evt-late-permission",
2933 type: "permission.asked",
2934 properties: permissionRequest("per_late", sessionID),
2935 });
2936 const late = yield* Fiber.join(lateFiber);
2937 NodeAssert.deepEqual(
2938 late.map((event) => event.type),
2939 ["thread.state.changed"],
2940 );
2941 yield* adapter.stopSession(threadId);
2942 }),
2943 );
2944
2945 it.effect("keeps progress live during automatic approval and never reopens a finished turn", () =>
2946 Effect.gen(function* () {
2947 const adapter = yield* OpenCodeAdapter;
2948 const threadId = asThreadId("thread-auto-approval-progress");
2949 const sessionID = "http://127.0.0.1:9999/session";
2950 const ask = promiseWithResolvers<unknown>();
2951 const idle = promiseWithResolvers<unknown>();
2952 const replyStarted = promiseWithResolvers<void>();
2953 const releaseReply = promiseWithResolvers<void>();
2954 runtimeMock.state.permissionReplyImplementation = async () => {
2955 replyStarted.resolve(undefined);
2956 await releaseReply.promise;
2957 throw new Error("reply response lost");
2958 };
2959 runtimeMock.state.subscribedEvents = [ask.promise, idle.promise];
2960 yield* adapter.startSession({
2961 provider: ProviderDriverKind.make("opencode"),
2962 threadId,
2963 runtimeMode: "full-access",
2964 });
2965 yield* adapter.sendTurn({
2966 threadId,
2967 input: "Work",
2968 modelSelection: createModelSelection(
2969 ProviderInstanceId.make("opencode"),
2970 "opencode/kimi-k3",
2971 ),
2972 });
2973 const completedFiber = yield* adapter.streamEvents.pipe(
2974 Stream.filter((event) => event.threadId === threadId),
2975 Stream.takeUntil((event) => event.type === "turn.completed"),
2976 Stream.runCollect,
2977 Effect.forkChild,
2978 );
2979 ask.resolve({
2980 id: "evt-ask",
2981 type: "permission.asked",
2982 properties: permissionRequest("per_slow_auto", sessionID),
2983 });
2984 yield* Effect.promise(() => replyStarted.promise);
2985 idle.resolve({
2986 id: "evt-idle",
2987 type: "session.status",
2988 properties: { sessionID, status: { type: "idle" } },
2989 });
2990 const completed = yield* Fiber.join(completedFiber);
2991 NodeAssert.equal(
2992 completed.some((event) => event.type === "request.opened"),
2993 false,
2994 );
2995 const remainingFiber = yield* adapter.streamEvents.pipe(
2996 Stream.filter((event) => event.threadId === threadId),
2997 Stream.takeUntil((event) => event.type === "session.exited"),
2998 Stream.runCollect,
2999 Effect.forkChild,
3000 );
3001 releaseReply.resolve(undefined);
3002 yield* advanceTestClock(10_000);
3003 yield* adapter.stopSession(threadId);
3004 const remaining = yield* Fiber.join(remainingFiber);
3005 NodeAssert.equal(
3006 remaining.some((event) => event.type === "request.opened"),
3007 false,
3008 );
3009 }),
3010 );
3011
3012 it.effect("keeps automatic approval fallback available after a steer", () =>
3013 Effect.gen(function* () {
3014 const adapter = yield* OpenCodeAdapter;
3015 const threadId = asThreadId("thread-auto-approval-steer");
3016 const ask = promiseWithResolvers<unknown>();
3017 const replyStarted = promiseWithResolvers<void>();
3018 const releaseReply = promiseWithResolvers<void>();
3019 runtimeMock.state.sessionStatus = "busy";
3020 runtimeMock.state.permissionReplyImplementation = async () => {
3021 replyStarted.resolve(undefined);
3022 await releaseReply.promise;
3023 throw new Error("reply failed");
3024 };
3025 runtimeMock.state.subscribedEvents = [ask.promise];
3026 yield* adapter.startSession({
3027 provider: ProviderDriverKind.make("opencode"),
3028 threadId,
3029 runtimeMode: "full-access",
3030 });
3031 const modelSelection = createModelSelection(
3032 ProviderInstanceId.make("opencode"),
3033 "opencode/kimi-k3",
3034 );
3035 const turn = yield* adapter.sendTurn({ threadId, input: "Work", modelSelection });
3036 ask.resolve({
3037 id: "evt-ask",
3038 type: "permission.asked",
3039 properties: permissionRequest("per_steer_auto", "http://127.0.0.1:9999/session"),
3040 });
3041 yield* Effect.promise(() => replyStarted.promise);
3042 const steered = yield* adapter.sendTurn({
3043 threadId,
3044 input: "Keep the change small",
3045 modelSelection,
3046 });
3047 NodeAssert.equal(steered.turnId, turn.turnId);
3048 const openedFiber = yield* adapter.streamEvents.pipe(
3049 Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
3050 Stream.runHead,
3051 Effect.forkChild,
3052 );
3053 releaseReply.resolve(undefined);
3054 NodeAssert.equal(
3055 Option.getOrThrow(yield* Fiber.join(openedFiber)).requestId,
3056 "per_steer_auto",
3057 );
3058 runtimeMock.state.permissionReplyImplementation = null;
3059 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_steer_auto"), "accept");
3060 yield* adapter.stopSession(threadId);
3061 }),
3062 );
3063
25793064 it.effect("routes child-session approval requests and replies through the parent thread", () =>
25803065 Effect.gen(function* () {
25813066 const adapter = yield* OpenCodeAdapter;
......@@ -2691,6 +3176,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
26913176 Effect.gen(function* () {
26923177 const adapter = yield* OpenCodeAdapter;
26933178 const threadId = asThreadId(`thread-full-access-${requestId}`);
3179 const replyStarted = promiseWithResolvers<void>();
3180 runtimeMock.state.permissionReplyImplementation = async () =>
3181 replyStarted.resolve(undefined);
26943182 runtimeMock.state.subscribedEvents = [
26953183 {
26963184 id: "evt-child-created",
......@@ -2709,11 +3197,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
27093197 type: "permission.asked",
27103198 properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always },
27113199 },
2712 {
3200 replyStarted.promise.then(() => ({
27133201 id: "evt-permission-replied",
27143202 type: "permission.replied",
27153203 properties: { sessionID, requestID: requestId, reply: "once" },
2716 },
3204 })),
27173205 // The suppressed ask emits nothing, so an empty question serves as a
27183206 // sentinel that closes the collected stream once the pump is past it.
27193207 {
......@@ -3000,53 +3488,68 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
30003488 }),
30013489 );
30023490
3003 it.effect("retries ancestry for one live child request after a transient failure", () =>
3004 Effect.gen(function* () {
3005 const adapter = yield* OpenCodeAdapter;
3006 const threadId = asThreadId("thread-child-request-ancestry-retry");
3007 const parentId = "http://127.0.0.1:9999/session";
3008 const ancestryAttempted = promiseWithResolvers<void>();
3009 runtimeMock.state.sessionParentById.set("ses_existing_child", parentId);
3010 runtimeMock.state.transientErrorSessionIds.add("ses_existing_child");
3011 runtimeMock.state.sessionGetObserved = (sessionID) => {
3012 if (sessionID === "ses_existing_child") {
3013 ancestryAttempted.resolve(undefined);
3491 it.effect.each(["failure", "timeout"] as const)(
3492 "retries ancestry for a child request after a transient %s",
3493 (lookupFailure) =>
3494 Effect.gen(function* () {
3495 const adapter = yield* OpenCodeAdapter;
3496 const threadId = asThreadId(`thread-child-request-ancestry-retry-${lookupFailure}`);
3497 const parentId = "http://127.0.0.1:9999/session";
3498 const ancestryAttempted = promiseWithResolvers<void>();
3499 runtimeMock.state.sessionParentById.set("ses_existing_child", parentId);
3500 runtimeMock.state.transientErrorSessionIds.add("ses_existing_child");
3501 let lookupSignal: AbortSignal | undefined;
3502 if (lookupFailure === "timeout") {
3503 runtimeMock.state.sessionGetImplementation = async (_sessionID, signal) => {
3504 lookupSignal = signal;
3505 await new Promise<void>(() => {});
3506 };
30143507 }
3015 };
3016 runtimeMock.state.subscribedEvents = [
3017 {
3018 id: "evt-existing-child-permission",
3019 type: "permission.asked",
3020 properties: permissionRequest("per_retry", "ses_existing_child"),
3021 },
3022 ];
3508 runtimeMock.state.sessionGetObserved = (sessionID) => {
3509 if (sessionID === "ses_existing_child") {
3510 ancestryAttempted.resolve(undefined);
3511 }
3512 };
3513 runtimeMock.state.subscribedEvents = [
3514 {
3515 id: "evt-existing-child-permission",
3516 type: "permission.asked",
3517 properties: permissionRequest("per_retry", "ses_existing_child"),
3518 },
3519 ];
30233520
3024 const eventsFiber = yield* adapter.streamEvents.pipe(
3025 Stream.filter(
3026 (event) =>
3027 event.threadId === threadId &&
3028 (event.type === "runtime.warning" || event.type === "request.opened"),
3029 ),
3030 Stream.take(2),
3031 Stream.runCollect,
3032 Effect.forkChild,
3033 );
3034 yield* adapter.startSession({
3035 provider: ProviderDriverKind.make("opencode"),
3036 threadId,
3037 runtimeMode: "approval-required",
3038 });
3039 yield* Effect.promise(() => ancestryAttempted.promise);
3040 runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child");
3041 yield* advanceTestClock(250);
3521 const eventsFiber = yield* adapter.streamEvents.pipe(
3522 Stream.filter(
3523 (event) =>
3524 event.threadId === threadId &&
3525 (event.type === "runtime.warning" || event.type === "request.opened"),
3526 ),
3527 Stream.take(2),
3528 Stream.runCollect,
3529 Effect.forkChild,
3530 );
3531 yield* adapter.startSession({
3532 provider: ProviderDriverKind.make("opencode"),
3533 threadId,
3534 runtimeMode: "approval-required",
3535 });
3536 yield* Effect.promise(() => ancestryAttempted.promise);
3537 if (lookupFailure === "timeout") {
3538 yield* Effect.yieldNow;
3539 yield* advanceTestClock(10_000);
3540 NodeAssert.equal(lookupSignal?.aborted, true);
3541 runtimeMock.state.sessionGetImplementation = null;
3542 }
3543 runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child");
3544 yield* advanceTestClock(250);
30423545
3043 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
3044 NodeAssert.deepEqual(
3045 events.map((event) => event.type),
3046 ["runtime.warning", "request.opened"],
3047 );
3048 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept");
3049 }),
3546 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
3547 NodeAssert.deepEqual(
3548 events.map((event) => event.type),
3549 ["runtime.warning", "request.opened"],
3550 );
3551 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept");
3552 }),
30503553 );
30513554
30523555 it.effect("does not resurrect a recovered child request after its live reply", () =>
......@@ -3101,7 +3604,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
31013604 const response = yield* Effect.exit(
31023605 adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"),
31033606 );
3104 NodeAssert.equal(Exit.isFailure(response), true);
3607 NodeAssert.equal(Exit.isSuccess(response), true);
3608 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []);
31053609 }),
31063610 );
31073611
......@@ -3153,7 +3657,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
31533657 const response = yield* Effect.exit(
31543658 adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"),
31553659 );
3156 NodeAssert.equal(Exit.isFailure(response), true);
3660 NodeAssert.equal(Exit.isSuccess(response), true);
3661 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []);
31573662 }),
31583663 );
31593664
......@@ -5286,6 +5791,249 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
52865791 }),
52875792 );
52885793
5794 it.effect("maps native task progress only while a turn is active", () =>
5795 Effect.gen(function* () {
5796 const adapter = yield* OpenCodeAdapter;
5797 const threadId = asThreadId("thread-native-progress");
5798 const sessionID = "http://127.0.0.1:9999/session";
5799 const startProgress = promiseWithResolvers<OpenCodeEvent>();
5800 const finishTurn = promiseWithResolvers<OpenCodeEvent>();
5801 const lateProgress = promiseWithResolvers<OpenCodeEvent>();
5802 const todos = [
5803 { content: "Read files", status: "completed", priority: "high" },
5804 { content: "Fix OpenCode", status: "in_progress", priority: "high" },
5805 { content: "Run tests", status: "pending", priority: "medium" },
5806 { content: "Old task", status: "cancelled", priority: "low" },
5807 ];
5808 const todoEvent = {
5809 id: "evt-todos",
5810 type: "todo.updated",
5811 properties: { sessionID, todos },
5812 } satisfies OpenCodeEvent;
5813 runtimeMock.state.subscribedEvents = [
5814 startProgress.promise,
5815 ...["todowrite", "bash"].map(
5816 (tool) =>
5817 ({
5818 id: `evt-${tool}`,
5819 type: "message.part.updated",
5820 properties: {
5821 sessionID,
5822 time: 2,
5823 part: {
5824 id: `part-${tool}`,
5825 sessionID,
5826 messageID: "msg-tools",
5827 type: "tool",
5828 callID: `call-${tool}`,
5829 tool,
5830 state: {
5831 status: "completed",
5832 input: tool === "bash" ? { command: "pwd" } : { todos },
5833 output: tool === "bash" ? "/repo\n" : "Tasks updated",
5834 title: tool === "bash" ? "Working directory" : "Tasks updated",
5835 metadata: {},
5836 time: { start: 1, end: 2 },
5837 },
5838 },
5839 },
5840 }) satisfies OpenCodeEvent,
5841 ),
5842 finishTurn.promise,
5843 lateProgress.promise,
5844 { id: "evt-progress-drained", type: "session.compacted", properties: { sessionID } },
5845 ];
5846 const eventsFiber = yield* adapter.streamEvents.pipe(
5847 Stream.filter(
5848 (event) =>
5849 event.threadId === threadId &&
5850 (event.type === "turn.plan.updated" || event.type === "item.completed"),
5851 ),
5852 Stream.take(3),
5853 Stream.runCollect,
5854 Effect.forkChild,
5855 );
5856 yield* adapter.startSession({
5857 provider: ProviderDriverKind.make("opencode"),
5858 threadId,
5859 runtimeMode: "full-access",
5860 });
5861 const turn = yield* adapter.sendTurn({
5862 threadId,
5863 input: "Work through the task list",
5864 modelSelection: createModelSelection(
5865 ProviderInstanceId.make("opencode"),
5866 "opencode/kimi-k3",
5867 ),
5868 });
5869 startProgress.resolve(todoEvent);
5870 const events = yield* Fiber.join(eventsFiber);
5871 const plan = events.find((event) => event.type === "turn.plan.updated");
5872 NodeAssert.equal(plan?.turnId, turn.turnId);
5873 NodeAssert.deepEqual(plan?.payload.plan, [
5874 { step: "Read files", status: "completed" },
5875 { step: "Fix OpenCode", status: "inProgress" },
5876 { step: "Run tests", status: "pending" },
5877 ]);
5878 const tools = events.filter((event) => event.type === "item.completed");
5879 NodeAssert.equal(tools[0]?.payload.itemType, "dynamic_tool_call");
5880 NodeAssert.equal(tools[1]?.payload.title, "Working directory");
5881 NodeAssert.partialDeepStrictEqual(tools[1]?.payload.data, {
5882 command: "pwd",
5883 result: "/repo\n",
5884 });
5885 const completedFiber = yield* adapter.streamEvents.pipe(
5886 Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
5887 Stream.runHead,
5888 Effect.forkChild,
5889 );
5890 finishTurn.resolve({
5891 id: "evt-progress-completed",
5892 type: "session.status",
5893 properties: { sessionID, status: { type: "idle" } },
5894 });
5895 yield* Fiber.join(completedFiber);
5896 const lateEventsFiber = yield* adapter.streamEvents.pipe(
5897 Stream.filter((event) => event.threadId === threadId),
5898 Stream.takeUntil((event) => event.type === "thread.state.changed"),
5899 Stream.runCollect,
5900 Effect.forkChild,
5901 );
5902 lateProgress.resolve({ ...todoEvent, id: "evt-late-todos" });
5903 NodeAssert.deepEqual(
5904 (yield* Fiber.join(lateEventsFiber)).map((event) => event.type),
5905 ["thread.state.changed"],
5906 );
5907 yield* adapter.stopSession(threadId);
5908 }),
5909 );
5910
5911 it.effect("warns on disconnection and recovers a completion missed during reconnect", () =>
5912 Effect.gen(function* () {
5913 const adapter = yield* OpenCodeAdapter;
5914 const threadId = asThreadId("thread-reconnect-completion");
5915 const reconnect = promiseWithResolvers<unknown>();
5916 runtimeMock.state.subscribedEvents = [reconnect.promise];
5917 runtimeMock.state.sessionStatus = "busy";
5918 yield* adapter.startSession({
5919 provider: ProviderDriverKind.make("opencode"),
5920 threadId,
5921 runtimeMode: "full-access",
5922 });
5923 const turn = yield* adapter.sendTurn({
5924 threadId,
5925 input: "Work",
5926 modelSelection: createModelSelection(
5927 ProviderInstanceId.make("opencode"),
5928 "opencode/kimi-k3",
5929 ),
5930 });
5931 const warningFiber = yield* adapter.streamEvents.pipe(
5932 Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"),
5933 Stream.runHead,
5934 Effect.forkChild,
5935 );
5936 runtimeMock.state.eventStreamError?.(new Error("socket closed"));
5937 const warning = Option.getOrThrow(yield* Fiber.join(warningFiber));
5938 NodeAssert.ok(warning.type === "runtime.warning");
5939 NodeAssert.equal(warning.payload.message, "OpenCode connection lost. Reconnecting.");
5940 const completedFiber = yield* adapter.streamEvents.pipe(
5941 Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
5942 Stream.runHead,
5943 Effect.forkChild,
5944 );
5945 runtimeMock.state.sessionStatus = "idle";
5946 reconnect.resolve({
5947 id: "evt-reconnected",
5948 type: "server.connected",
5949 properties: {},
5950 } satisfies OpenCodeEvent);
5951 NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(completedFiber)).turnId, turn.turnId);
5952 NodeAssert.equal(
5953 (yield* adapter.listSessions()).find((session) => session.threadId === threadId)?.status,
5954 "ready",
5955 );
5956 yield* adapter.stopSession(threadId);
5957 }),
5958 );
5959
5960 it.effect(
5961 "ends a running session on clean stream closure without discarding unresolved permissions",
5962 () =>
5963 Effect.gen(function* () {
5964 const adapter = yield* OpenCodeAdapter;
5965 const threadId = asThreadId("thread-stream-closed");
5966 const endStream = promiseWithResolvers<unknown>();
5967 const request = permissionRequest("per_disconnect", "http://127.0.0.1:9999/session");
5968 runtimeMock.state.pendingPermissions = [request];
5969 runtimeMock.state.subscribedEvents = [endStream.promise];
5970 const openedFiber = yield* adapter.streamEvents.pipe(
5971 Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
5972 Stream.runHead,
5973 Effect.forkChild,
5974 );
5975 const session = yield* adapter.startSession({
5976 provider: ProviderDriverKind.make("opencode"),
5977 threadId,
5978 runtimeMode: "approval-required",
5979 });
5980 yield* Fiber.join(openedFiber);
5981 yield* adapter.sendTurn({
5982 threadId,
5983 input: "Work",
5984 modelSelection: createModelSelection(
5985 ProviderInstanceId.make("opencode"),
5986 "opencode/kimi-k3",
5987 ),
5988 });
5989 const exitedFiber = yield* adapter.streamEvents.pipe(
5990 Stream.filter((event) => event.threadId === threadId),
5991 Stream.takeUntil((event) => event.type === "session.exited"),
5992 Stream.runCollect,
5993 Effect.forkChild,
5994 );
5995 runtimeMock.state.endEventStream = true;
5996 runtimeMock.state.abortImplementation = async () => {
5997 throw new Error("server unreachable");
5998 };
5999 endStream.resolve({
6000 id: "evt-busy",
6001 type: "session.status",
6002 properties: { sessionID: request.sessionID, status: { type: "busy" } },
6003 });
6004 const exited = yield* Fiber.join(exitedFiber);
6005 NodeAssert.equal(
6006 exited.some((event) => event.type === "request.resolved"),
6007 false,
6008 );
6009 NodeAssert.match(
6010 exited.find((event) => event.type === "runtime.error")?.payload.message ?? "",
6011 /event stream ended/,
6012 );
6013 NodeAssert.equal(yield* adapter.hasSession(threadId), false);
6014 runtimeMock.state.endEventStream = false;
6015 runtimeMock.state.subscribedEvents = [];
6016 runtimeMock.state.abortImplementation = null;
6017 const recoveredFiber = yield* adapter.streamEvents.pipe(
6018 Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
6019 Stream.runHead,
6020 Effect.forkChild,
6021 );
6022 yield* adapter.startSession({
6023 provider: ProviderDriverKind.make("opencode"),
6024 threadId,
6025 runtimeMode: "approval-required",
6026 resumeCursor: session.resumeCursor,
6027 });
6028 NodeAssert.equal(
6029 Option.getOrThrow(yield* Fiber.join(recoveredFiber)).requestId,
6030 request.id,
6031 );
6032 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept");
6033 yield* adapter.stopSession(threadId);
6034 }),
6035 );
6036
52896037 it.effect("lets OpenCode own session title generation and emits title metadata updates", () =>
52906038 Effect.gen(function* () {
52916039 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+383-97
......@@ -417,6 +417,9 @@ type EventBaseInput = {
417417
418418function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {
419419 const normalized = toolName.toLowerCase();
420 if (normalized === "todowrite" || normalized === "todoread") {
421 return "dynamic_tool_call";
422 }
420423 if (normalized.includes("bash") || normalized.includes("command")) {
421424 return "command_execution";
422425 }
......@@ -449,16 +452,15 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {
449452
450453function mapPermissionToRequestType(
451454 permission: string,
452): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" {
455): "command_execution_approval" | "file_read_approval" | "file_change_approval" {
453456 switch (permission) {
454 case "bash":
455 return "command_execution_approval";
456457 case "read":
457458 return "file_read_approval";
458459 case "edit":
459460 return "file_change_approval";
460461 default:
461 return "unknown";
462 // Every OpenCode permission needs an actionable approval in each client.
463 return "command_execution_approval";
462464 }
463465}
464466
......@@ -1062,6 +1064,10 @@ export function makeOpenCodeAdapter(
10621064 context.interruptedTurnId = undefined;
10631065 context.awaitingBusyAfterInterruption = false;
10641066 context.reconcileIdleStatus = false;
1067 for (const requestId of context.autoRepliedRequestIds) {
1068 context.emittedTerminalRequestIds.add(requestId);
1069 }
1070 context.autoRepliedRequestIds.clear();
10651071 applyProviderSessionUpdate(
10661072 context,
10671073 { status: "ready" },
......@@ -1071,6 +1077,7 @@ export function makeOpenCodeAdapter(
10711077 if (pendingIdleReconciliation?.fiber) {
10721078 yield* Fiber.interrupt(pendingIdleReconciliation.fiber);
10731079 }
1080 yield* schedulePendingRequestRecovery(context);
10741081 yield* emit({
10751082 ...(yield* buildEventBase({
10761083 threadId: context.session.threadId,
......@@ -1425,6 +1432,7 @@ export function makeOpenCodeAdapter(
14251432 { clearActiveTurnId: true, clearLastError: true },
14261433 );
14271434 }
1435 yield* clearPendingOpenCodeRequests(context, { type: "session.abort" });
14281436 yield* emit({
14291437 ...(yield* buildEventBase({
14301438 threadId: context.session.threadId,
......@@ -1578,7 +1586,19 @@ export function makeOpenCodeAdapter(
15781586
15791587 const seen = new Set<string>();
15801588 const getSession = (sessionID: string) =>
1581 runOpenCodeSdk("session.get", () => context.client.session.get({ sessionID })).pipe(
1589 runOpenCodeSdk("session.get", (signal) =>
1590 context.client.session.get({ sessionID }, { signal }),
1591 ).pipe(
1592 Effect.timeoutOrElse({
1593 duration: "10 seconds",
1594 orElse: () =>
1595 Effect.fail(
1596 new OpenCodeRuntimeError({
1597 operation: "session.get",
1598 detail: "OpenCode session ancestry lookup did not complete within 10 seconds.",
1599 }),
1600 ),
1601 }),
15821602 Effect.catchIf(
15831603 (cause) => isOpenCodeNotFound(cause),
15841604 () => Effect.succeed(undefined),
......@@ -1610,6 +1630,52 @@ export function makeOpenCodeAdapter(
16101630 return false;
16111631 });
16121632
1633 const openPermissionRequest = Effect.fn("openPermissionRequest")(function* (
1634 context: OpenCodeSessionContext,
1635 request: PermissionRequest,
1636 raw: unknown,
1637 ) {
1638 const base = yield* buildEventBase({
1639 threadId: context.session.threadId,
1640 turnId: context.activeTurnId,
1641 requestId: request.id,
1642 raw,
1643 });
1644 const stopped = yield* Ref.get(context.stopped);
1645 if (
1646 stopped ||
1647 context.emittedTerminalRequestIds.has(request.id) ||
1648 context.pendingPermissions.has(request.id)
1649 ) {
1650 return;
1651 }
1652 const patterns = request.patterns.filter((pattern) => pattern !== "*");
1653 const detail =
1654 request.permission === "bash" && patterns.length > 0
1655 ? patterns.join("\n")
1656 : [request.permission.replaceAll("_", " "), ...patterns].join("\n");
1657 context.autoRepliedRequestIds.delete(request.id);
1658 context.pendingPermissions.set(request.id, request);
1659 emitUnsafe({
1660 ...base,
1661 type: "request.opened",
1662 payload: {
1663 requestType: mapPermissionToRequestType(request.permission),
1664 detail,
1665 args: request.metadata,
1666 options: [
1667 { decision: "accept", label: "Allow once" },
1668 {
1669 decision: "acceptForSession",
1670 label: "Allow for workspace",
1671 warning: "Applies to matching requests in other OpenCode sessions in this workspace.",
1672 },
1673 { decision: "decline", label: "Deny" },
1674 ],
1675 },
1676 });
1677 });
1678
16131679 // Full access means the user already granted everything, but two upstream
16141680 // paths never consult the session ruleset we send: doom-loop detection
16151681 // (evaluated against the agent ruleset only) and subagent sessions (which
......@@ -1622,15 +1688,12 @@ export function makeOpenCodeAdapter(
16221688 const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* (
16231689 context: OpenCodeSessionContext,
16241690 request: PermissionRequest,
1691 raw: unknown,
16251692 ) {
1626 // Mark before awaiting: retry and recovery fibers re-enter the ask path,
1627 // and the matching `permission.replied` can arrive, while the SDK call
1628 // is in flight. Marked ids skip the ask and swallow the terminal event.
1629 context.resolvedRequestIds.add(request.id);
1630 context.autoRepliedRequestIds.add(request.id);
1631 const replied = yield* runOpenCodeSdk("permission.reply", () =>
1632 context.client.permission.reply({ requestID: request.id, reply: "once" }),
1693 const replied = yield* runOpenCodeSdk("permission.reply", (signal) =>
1694 context.client.permission.reply({ requestID: request.id, reply: "once" }, { signal }),
16331695 ).pipe(
1696 Effect.timeout("10 seconds"),
16341697 Effect.as(true),
16351698 Effect.orElseSucceed(() => false),
16361699 );
......@@ -1638,9 +1701,8 @@ export function makeOpenCodeAdapter(
16381701 // Fall back to the dialog. The id stays resolved so a recovered copy
16391702 // of this ask cannot reopen after the user answers;
16401703 // `pendingPermissions` gates re-asks while the dialog is open.
1641 context.autoRepliedRequestIds.delete(request.id);
1704 yield* openPermissionRequest(context, request, raw);
16421705 }
1643 return replied;
16441706 });
16451707
16461708 const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* (
......@@ -1651,39 +1713,26 @@ export function makeOpenCodeAdapter(
16511713 if (context.resolvedRequestIds.has(event.properties.id)) {
16521714 return;
16531715 }
1716 if (context.activeTurnId === undefined && context.reconcileIdleStatus) {
1717 context.resolvedRequestIds.add(event.properties.id);
1718 return;
1719 }
16541720 if (event.type === "permission.asked") {
16551721 const request = event.properties;
16561722 if (context.pendingPermissions.has(request.id)) {
16571723 return;
16581724 }
1659 if (
1660 context.session.runtimeMode === "full-access" &&
1661 (yield* autoReplyFullAccess(context, request))
1662 ) {
1663 return;
1664 }
1665 const base = yield* buildEventBase({
1666 threadId: context.session.threadId,
1667 turnId: context.activeTurnId,
1668 requestId: request.id,
1669 raw,
1670 });
1671 // No yield between this check and the publish: a terminal
1672 // `permission.replied` delivered on the pump in between would leave a
1673 // dialog that can never close.
1674 if (context.emittedTerminalRequestIds.has(request.id)) {
1725 if (context.session.runtimeMode === "full-access") {
1726 // Reply outside the event pump so a slow HTTP response cannot hide
1727 // progress, terminal replies, or the acknowledgment for Stop.
1728 context.resolvedRequestIds.add(request.id);
1729 context.autoRepliedRequestIds.add(request.id);
1730 yield* autoReplyFullAccess(context, request, raw).pipe(
1731 Effect.forkIn(context.sessionScope),
1732 );
16751733 return;
16761734 }
1677 context.pendingPermissions.set(request.id, request);
1678 emitUnsafe({
1679 ...base,
1680 type: "request.opened",
1681 payload: {
1682 requestType: mapPermissionToRequestType(request.permission),
1683 detail: request.patterns.length > 0 ? request.patterns.join("\n") : request.permission,
1684 args: request.metadata,
1685 },
1686 });
1735 yield* openPermissionRequest(context, request, raw);
16871736 return;
16881737 }
16891738
......@@ -1691,14 +1740,19 @@ export function makeOpenCodeAdapter(
16911740 if (context.pendingQuestions.has(request.id)) {
16921741 return;
16931742 }
1743 const base = yield* buildEventBase({
1744 threadId: context.session.threadId,
1745 turnId: context.activeTurnId,
1746 requestId: request.id,
1747 raw,
1748 });
1749 const stopped = yield* Ref.get(context.stopped);
1750 if (stopped || context.resolvedRequestIds.has(request.id)) {
1751 return;
1752 }
16941753 context.pendingQuestions.set(request.id, request);
1695 yield* emit({
1696 ...(yield* buildEventBase({
1697 threadId: context.session.threadId,
1698 turnId: context.activeTurnId,
1699 requestId: request.id,
1700 raw,
1701 })),
1754 emitUnsafe({
1755 ...base,
17021756 type: "user-input.requested",
17031757 payload: { questions: normalizeQuestionRequest(request) },
17041758 });
......@@ -1719,26 +1773,32 @@ export function makeOpenCodeAdapter(
17191773 const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* (
17201774 context: OpenCodeSessionContext,
17211775 event: OpenCodeTerminalRequestEvent,
1776 raw: unknown = event,
17221777 ) {
17231778 const requestId = event.properties.requestID;
17241779 if (context.emittedTerminalRequestIds.has(requestId)) {
17251780 return;
17261781 }
1727 context.emittedTerminalRequestIds.add(requestId);
17281782 if (context.autoRepliedRequestIds.delete(requestId)) {
1783 context.emittedTerminalRequestIds.add(requestId);
17291784 return;
17301785 }
1786 const base = yield* buildEventBase({
1787 threadId: context.session.threadId,
1788 turnId: context.activeTurnId,
1789 requestId,
1790 raw,
1791 });
1792 if (context.emittedTerminalRequestIds.has(requestId)) return;
1793 context.emittedTerminalRequestIds.add(requestId);
17311794 if (event.type === "permission.replied") {
1732 yield* emit({
1733 ...(yield* buildEventBase({
1734 threadId: context.session.threadId,
1735 turnId: context.activeTurnId,
1736 requestId,
1737 raw: event,
1738 })),
1795 const request = context.pendingPermissions.get(requestId);
1796 context.pendingPermissions.delete(requestId);
1797 emitUnsafe({
1798 ...base,
17391799 type: "request.resolved",
17401800 payload: {
1741 requestType: "unknown",
1801 requestType: request ? mapPermissionToRequestType(request.permission) : "unknown",
17421802 decision: mapPermissionDecision(event.properties.reply),
17431803 },
17441804 });
......@@ -1746,6 +1806,7 @@ export function makeOpenCodeAdapter(
17461806 }
17471807
17481808 const request = context.pendingQuestions.get(requestId);
1809 context.pendingQuestions.delete(requestId);
17491810 const answers =
17501811 event.type === "question.replied" && request
17511812 ? Object.fromEntries(
......@@ -1755,18 +1816,73 @@ export function makeOpenCodeAdapter(
17551816 ]),
17561817 )
17571818 : {};
1758 yield* emit({
1759 ...(yield* buildEventBase({
1760 threadId: context.session.threadId,
1761 turnId: context.activeTurnId,
1762 requestId,
1763 raw: event,
1764 })),
1819 emitUnsafe({
1820 ...base,
17651821 type: "user-input.resolved",
17661822 payload: { answers },
17671823 });
17681824 });
17691825
1826 const closePendingOpenCodeRequests = Effect.fn("closePendingOpenCodeRequests")(function* (
1827 context: OpenCodeSessionContext,
1828 permissions: ReadonlyArray<PermissionRequest>,
1829 questions: ReadonlyArray<QuestionRequest>,
1830 raw: unknown,
1831 ) {
1832 for (const request of permissions) {
1833 if (!context.pendingPermissions.has(request.id)) continue;
1834 yield* resolvePendingOpenCodeRequest(context, request.id);
1835 const base = yield* buildEventBase({
1836 threadId: context.session.threadId,
1837 turnId: context.activeTurnId,
1838 requestId: request.id,
1839 raw,
1840 });
1841 if (context.emittedTerminalRequestIds.has(request.id)) continue;
1842 context.pendingPermissions.delete(request.id);
1843 context.emittedTerminalRequestIds.add(request.id);
1844 emitUnsafe({
1845 ...base,
1846 type: "request.resolved",
1847 payload: { requestType: mapPermissionToRequestType(request.permission) },
1848 });
1849 }
1850 for (const request of questions) {
1851 if (!context.pendingQuestions.has(request.id)) continue;
1852 yield* resolvePendingOpenCodeRequest(context, request.id);
1853 const base = yield* buildEventBase({
1854 threadId: context.session.threadId,
1855 turnId: context.activeTurnId,
1856 requestId: request.id,
1857 raw,
1858 });
1859 if (context.emittedTerminalRequestIds.has(request.id)) continue;
1860 context.pendingQuestions.delete(request.id);
1861 context.emittedTerminalRequestIds.add(request.id);
1862 emitUnsafe({ ...base, type: "user-input.resolved", payload: { answers: {} } });
1863 }
1864 });
1865
1866 const clearPendingOpenCodeRequests = Effect.fn("clearPendingOpenCodeRequests")(function* (
1867 context: OpenCodeSessionContext,
1868 raw: unknown,
1869 ) {
1870 context.pendingRequestRecovery = undefined;
1871 for (const requestId of context.requestRelationRetries.keys()) {
1872 yield* resolvePendingOpenCodeRequest(context, requestId);
1873 }
1874 for (const requestId of context.autoRepliedRequestIds) {
1875 context.emittedTerminalRequestIds.add(requestId);
1876 }
1877 context.autoRepliedRequestIds.clear();
1878 yield* closePendingOpenCodeRequests(
1879 context,
1880 [...context.pendingPermissions.values()],
1881 [...context.pendingQuestions.values()],
1882 raw,
1883 );
1884 });
1885
17701886 const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* (
17711887 context: OpenCodeSessionContext,
17721888 event: OpenCodeRoutedRequestEvent,
......@@ -1854,10 +1970,21 @@ export function makeOpenCodeAdapter(
18541970 const run = Effect.gen(function* () {
18551971 let retryCount = 0;
18561972 while (context.pendingRequestRecovery === recovery) {
1857 const responses = yield* Effect.all({
1858 permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()),
1859 questions: runOpenCodeSdk("question.list", () => context.client.question.list()),
1860 }).pipe(
1973 // Only requests pending before the snapshot can be closed by it.
1974 const priorPermissions = [...context.pendingPermissions.values()];
1975 const priorQuestions = [...context.pendingQuestions.values()];
1976 const responses = yield* Effect.all(
1977 {
1978 permissions: runOpenCodeSdk("permission.list", (signal) =>
1979 context.client.permission.list(undefined, { signal }),
1980 ),
1981 questions: runOpenCodeSdk("question.list", (signal) =>
1982 context.client.question.list(undefined, { signal }),
1983 ),
1984 },
1985 { concurrency: 2 },
1986 ).pipe(
1987 Effect.timeout("10 seconds"),
18611988 Effect.match({
18621989 onFailure: (cause) => ({ type: "failure" as const, cause }),
18631990 onSuccess: (value) => ({ type: "success" as const, value }),
......@@ -1901,6 +2028,14 @@ export function makeOpenCodeAdapter(
19012028 yield* Effect.sleep(`${delayMs} millis`);
19022029 continue;
19032030 }
2031 const permissionIds = new Set(permissions.map((request) => request.id));
2032 const questionIds = new Set(questions.map((request) => request.id));
2033 yield* closePendingOpenCodeRequests(
2034 context,
2035 priorPermissions.filter((request) => !permissionIds.has(request.id)),
2036 priorQuestions.filter((request) => !questionIds.has(request.id)),
2037 { type: "pending-requests.recovered" },
2038 );
19042039 yield* Effect.forEach(
19052040 permissions,
19062041 (request) =>
......@@ -1970,6 +2105,9 @@ export function makeOpenCodeAdapter(
19702105 yield* schedulePendingRequestRecovery(context);
19712106 if (!isFirstConnection) {
19722107 yield* schedulePromptAdmissionRecovery(context, event);
2108 if (context.activeTurnId !== undefined && context.promptAdmission === undefined) {
2109 yield* scheduleIdleReconciliation(context, context.activeTurnId, event);
2110 }
19732111 }
19742112 return;
19752113 }
......@@ -2046,6 +2184,7 @@ export function makeOpenCodeAdapter(
20462184 context.awaitingBusyAfterInterruption) &&
20472185 (event.type === "message.part.delta" ||
20482186 event.type === "message.part.updated" ||
2187 event.type === "todo.updated" ||
20492188 (event.type === "message.updated" && event.properties.info.role === "assistant"));
20502189 if (suppressInterruptedParentOutput) {
20512190 return;
......@@ -2125,7 +2264,11 @@ export function makeOpenCodeAdapter(
21252264
21262265 case "message.part.delta": {
21272266 const existingPart = context.partById.get(event.properties.partID);
2128 if (!existingPart) {
2267 if (
2268 !existingPart ||
2269 (existingPart.type !== "text" && existingPart.type !== "reasoning") ||
2270 event.properties.field !== "text"
2271 ) {
21292272 break;
21302273 }
21312274 const role = messageRoleForPart(context, existingPart);
......@@ -2180,7 +2323,9 @@ export function makeOpenCodeAdapter(
21802323 if (part.type === "tool") {
21812324 const itemType = toToolLifecycleItemType(part.tool);
21822325 const title =
2183 part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool;
2326 part.state.status === "running" || part.state.status === "completed"
2327 ? (part.state.title ?? part.tool)
2328 : part.tool;
21842329 const detail = detailFromToolPart(part);
21852330 const payload = {
21862331 itemType,
......@@ -2194,6 +2339,14 @@ export function makeOpenCodeAdapter(
21942339 data: {
21952340 tool: part.tool,
21962341 state: part.state,
2342 ...(typeof part.state.input.command === "string"
2343 ? { command: part.state.input.command }
2344 : {}),
2345 ...(itemType === "file_change" ? { input: part.state.input } : {}),
2346 ...(part.state.status === "completed" &&
2347 (itemType === "command_execution" || itemType === "mcp_tool_call")
2348 ? { result: part.state.output }
2349 : {}),
21972350 },
21982351 };
21992352 const runtimeEvent: ProviderRuntimeEvent = {
......@@ -2224,7 +2377,6 @@ export function makeOpenCodeAdapter(
22242377 }
22252378
22262379 case "permission.replied": {
2227 context.pendingPermissions.delete(event.properties.requestID);
22282380 yield* emitTerminalOpenCodeRequest(context, event);
22292381 break;
22302382 }
......@@ -2236,18 +2388,45 @@ export function makeOpenCodeAdapter(
22362388
22372389 case "question.replied": {
22382390 yield* emitTerminalOpenCodeRequest(context, event);
2239 context.pendingQuestions.delete(event.properties.requestID);
22402391 break;
22412392 }
22422393
22432394 case "question.rejected": {
2244 context.pendingQuestions.delete(event.properties.requestID);
22452395 yield* emitTerminalOpenCodeRequest(context, event);
22462396 break;
22472397 }
22482398
2399 case "todo.updated": {
2400 if (turnId === undefined) break;
2401 const base = yield* buildEventBase({
2402 threadId: context.session.threadId,
2403 turnId,
2404 raw: event,
2405 });
2406 // Session-wide task updates must not reopen progress after a turn ends.
2407 if (context.activeTurnId !== turnId) break;
2408 emitUnsafe({
2409 ...base,
2410 type: "turn.plan.updated",
2411 payload: {
2412 plan: event.properties.todos
2413 .filter((todo) => todo.status !== "cancelled")
2414 .map((todo) => ({
2415 step: trimText(todo.content) ?? "Task",
2416 status:
2417 todo.status === "completed"
2418 ? "completed"
2419 : todo.status === "in_progress"
2420 ? "inProgress"
2421 : "pending",
2422 })),
2423 },
2424 });
2425 break;
2426 }
2427
22492428 case "session.status": {
2250 if (event.properties.status.type === "busy") {
2429 if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
22512430 if (turnId === undefined) {
22522431 break;
22532432 }
......@@ -2272,7 +2451,7 @@ export function makeOpenCodeAdapter(
22722451 })),
22732452 type: "runtime.warning",
22742453 payload: {
2275 message: event.properties.status.message,
2454 message: `OpenCode retry ${event.properties.status.attempt}: ${event.properties.status.message}`,
22762455 detail: event.properties.status,
22772456 },
22782457 });
......@@ -2335,6 +2514,7 @@ export function makeOpenCodeAdapter(
23352514 context.activeAgent = undefined;
23362515 context.activeVariant = undefined;
23372516 context.reconcileIdleStatus = false;
2517 yield* schedulePendingRequestRecovery(context);
23382518 yield* updateProviderSession(
23392519 context,
23402520 {
......@@ -2388,9 +2568,29 @@ export function makeOpenCodeAdapter(
23882568 // shutdown) and cancels the in-flight `event.subscribe` fetch so
23892569 // the async iterable unwinds cleanly.
23902570 const eventsAbortController = new AbortController();
2391 yield* Scope.addFinalizer(
2392 context.sessionScope,
2393 Effect.sync(() => eventsAbortController.abort()),
2571 let lastStreamError: unknown;
2572 let warnedAboutDisconnect = false;
2573 const streamErrors = yield* Queue.unbounded<unknown>();
2574 yield* Scope.addFinalizer(context.sessionScope, Queue.shutdown(streamErrors));
2575 yield* Stream.fromQueue(streamErrors).pipe(
2576 Stream.runForEach((cause) =>
2577 Effect.gen(function* () {
2578 if (warnedAboutDisconnect) return;
2579 warnedAboutDisconnect = true;
2580 yield* emit({
2581 ...(yield* buildEventBase({
2582 threadId: context.session.threadId,
2583 turnId: context.activeTurnId,
2584 })),
2585 type: "runtime.warning",
2586 payload: {
2587 message: "OpenCode connection lost. Reconnecting.",
2588 detail: openCodeRuntimeErrorDetail(cause),
2589 },
2590 });
2591 }),
2592 ),
2593 Effect.forkIn(context.sessionScope),
23942594 );
23952595
23962596 // Fibers forked into `context.sessionScope` are interrupted
......@@ -2399,6 +2599,10 @@ export function makeOpenCodeAdapter(
23992599 runOpenCodeSdk("event.subscribe", () =>
24002600 context.client.event.subscribe(undefined, {
24012601 signal: eventsAbortController.signal,
2602 onSseError: (cause) => {
2603 lastStreamError = cause;
2604 Queue.offerUnsafe(streamErrors, cause);
2605 },
24022606 }),
24032607 ),
24042608 (subscription) =>
......@@ -2410,7 +2614,13 @@ export function makeOpenCodeAdapter(
24102614 detail: openCodeRuntimeErrorDetail(cause),
24112615 cause,
24122616 }),
2413 ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))),
2617 ).pipe(
2618 Stream.runForEach((event) => {
2619 if (event.type === "server.connected") lastStreamError = undefined;
2620 if (event.type === "server.connected") warnedAboutDisconnect = false;
2621 return handleSubscribedEvent(context, event);
2622 }),
2623 ),
24142624 ).pipe(
24152625 Effect.exit,
24162626 Effect.flatMap((exit) =>
......@@ -2420,12 +2630,14 @@ export function makeOpenCodeAdapter(
24202630 if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) {
24212631 return;
24222632 }
2423 if (Exit.isFailure(exit)) {
2424 yield* emitUnexpectedExit(
2425 context,
2426 openCodeRuntimeErrorDetail(Cause.squash(exit.cause)),
2427 );
2428 }
2633 yield* emitUnexpectedExit(
2634 context,
2635 Exit.isFailure(exit)
2636 ? openCodeRuntimeErrorDetail(Cause.squash(exit.cause))
2637 : lastStreamError !== undefined
2638 ? `OpenCode event stream disconnected: ${openCodeRuntimeErrorDetail(lastStreamError)}`
2639 : "OpenCode event stream ended unexpectedly. Send another message to reconnect.",
2640 );
24292641 }),
24302642 ),
24312643 Effect.forkIn(context.sessionScope),
......@@ -2444,6 +2656,12 @@ export function makeOpenCodeAdapter(
24442656 Effect.forkIn(context.sessionScope),
24452657 );
24462658 }
2659 // Scope finalizers run in reverse order. Abort the pending read before
2660 // interrupting the pump, whose iterator.return() waits for that read.
2661 yield* Scope.addFinalizer(
2662 context.sessionScope,
2663 Effect.sync(() => eventsAbortController.abort()),
2664 );
24472665 });
24482666
24492667 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(
......@@ -3259,6 +3477,7 @@ export function makeOpenCodeAdapter(
32593477 } else {
32603478 context.cancellation = undefined;
32613479 context.reconcileIdleStatus = true;
3480 yield* clearPendingOpenCodeRequests(context, { type: "session.abort" });
32623481 }
32633482 }
32643483 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
......@@ -3269,20 +3488,52 @@ export function makeOpenCodeAdapter(
32693488 "respondToRequest",
32703489 )(function* (threadId, requestId, decision) {
32713490 const context = yield* ensureSessionContext(sessions, threadId);
3272 if (!context.pendingPermissions.has(requestId)) {
3491 const request = context.pendingPermissions.get(requestId);
3492 if (!request) {
3493 if (context.emittedTerminalRequestIds.has(requestId)) return;
32733494 return yield* new ProviderAdapterRequestError({
32743495 provider: PROVIDER,
32753496 method: "permission.reply",
3276 detail: `Unknown pending permission request: ${requestId}`,
3497 detail:
3498 context.pendingRequestRecovery || context.requestRelationRetries.has(requestId)
3499 ? "OpenCode is still loading this permission request. Try again."
3500 : `Unknown pending permission request: ${requestId}`,
32773501 });
32783502 }
32793503
3280 yield* runOpenCodeSdk("permission.reply", () =>
3281 context.client.permission.reply({
3282 requestID: requestId,
3283 reply: toOpenCodePermissionReply(decision),
3504 const reply = toOpenCodePermissionReply(decision);
3505 yield* runOpenCodeSdk("permission.reply", (signal) =>
3506 context.client.permission.reply(
3507 {
3508 requestID: requestId,
3509 reply,
3510 },
3511 { signal },
3512 ),
3513 ).pipe(
3514 Effect.mapError(toRequestError),
3515 Effect.timeoutOrElse({
3516 duration: "10 seconds",
3517 orElse: () =>
3518 Effect.fail(
3519 new ProviderAdapterRequestError({
3520 provider: PROVIDER,
3521 method: "permission.reply",
3522 detail: "OpenCode permission reply did not complete within 10 seconds.",
3523 }),
3524 ),
32843525 }),
3285 ).pipe(Effect.mapError(toRequestError));
3526 );
3527 yield* resolvePendingOpenCodeRequest(context, requestId);
3528 yield* emitTerminalOpenCodeRequest(
3529 context,
3530 {
3531 id: `reply:${requestId}`,
3532 type: "permission.replied",
3533 properties: { sessionID: request.sessionID, requestID: requestId, reply },
3534 },
3535 { type: "permission.reply", requestID: requestId, reply },
3536 );
32863537 });
32873538
32883539 const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(
......@@ -3291,19 +3542,54 @@ export function makeOpenCodeAdapter(
32913542 const context = yield* ensureSessionContext(sessions, threadId);
32923543 const request = context.pendingQuestions.get(requestId);
32933544 if (!request) {
3545 if (context.emittedTerminalRequestIds.has(requestId)) return;
32943546 return yield* new ProviderAdapterRequestError({
32953547 provider: PROVIDER,
32963548 method: "question.reply",
3297 detail: `Unknown pending user-input request: ${requestId}`,
3549 detail:
3550 context.pendingRequestRecovery || context.requestRelationRetries.has(requestId)
3551 ? "OpenCode is still loading this question. Try again."
3552 : `Unknown pending user-input request: ${requestId}`,
32983553 });
32993554 }
33003555
3301 yield* runOpenCodeSdk("question.reply", () =>
3302 context.client.question.reply({
3303 requestID: requestId,
3304 answers: toOpenCodeQuestionAnswers(request, answers),
3556 const questionAnswers = toOpenCodeQuestionAnswers(request, answers);
3557 yield* runOpenCodeSdk("question.reply", (signal) =>
3558 context.client.question.reply(
3559 {
3560 requestID: requestId,
3561 answers: questionAnswers,
3562 },
3563 { signal },
3564 ),
3565 ).pipe(
3566 Effect.mapError(toRequestError),
3567 Effect.timeoutOrElse({
3568 duration: "10 seconds",
3569 orElse: () =>
3570 Effect.fail(
3571 new ProviderAdapterRequestError({
3572 provider: PROVIDER,
3573 method: "question.reply",
3574 detail: "OpenCode question reply did not complete within 10 seconds.",
3575 }),
3576 ),
33053577 }),
3306 ).pipe(Effect.mapError(toRequestError));
3578 );
3579 yield* resolvePendingOpenCodeRequest(context, requestId);
3580 yield* emitTerminalOpenCodeRequest(
3581 context,
3582 {
3583 id: `reply:${requestId}`,
3584 type: "question.replied",
3585 properties: {
3586 sessionID: request.sessionID,
3587 requestID: requestId,
3588 answers: questionAnswers,
3589 },
3590 },
3591 { type: "question.reply", requestID: requestId },
3592 );
33073593 });
33083594
33093595 const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")(
apps/server/src/provider/opencodeRuntime.environment.test.ts+87
......@@ -1,12 +1,24 @@
11import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
23import { it as effectIt } from "@effect/vitest";
4import {
5 HostProcessEnvironment,
6 HostProcessExecutablePath,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
39import * as Effect from "effect/Effect";
410import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
514import * as TestClock from "effect/testing/TestClock";
15import { FetchHttpClient, HttpClient } from "effect/unstable/http";
616import { describe, expect, it } from "vite-plus/test";
717
818import {
19 OpenCodeRuntime,
920 OpenCodeRuntimeError,
21 OpenCodeRuntimeLive,
1022 resolveOpenCodeConfigContent,
1123 resolveOpenCodeServerPassword,
1224 verifyOpenCodeServerVersion,
......@@ -150,3 +162,78 @@ describe("verifyOpenCodeServerVersion", () => {
150162 }).pipe(Effect.provide(TestClock.layer())),
151163 );
152164});
165
166describe("OpenCode server output", () => {
167 effectIt.live(
168 "drains stdout and stderr after startup so server requests can finish",
169 () =>
170 Effect.gen(function* () {
171 const fs = yield* FileSystem.FileSystem;
172 const path = yield* Path.Path;
173 const environment = yield* HostProcessEnvironment;
174 const executablePath = yield* HostProcessExecutablePath;
175 const platform = yield* HostProcessPlatform;
176 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" });
177 const isWindows = platform === "win32";
178 const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode");
179 const scriptPath = path.join(tempDir, "opencode.mjs");
180
181 yield* fs.writeFileString(
182 scriptPath,
183 `import { createServer } from "node:http";
184const writeOutput = (stream) => new Promise((resolve, reject) => {
185 stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve());
186});
187const server = createServer(async (request, response) => {
188 if (request.url.startsWith("/global/health")) {
189 response.setHeader("Content-Type", "application/json");
190 response.end(JSON.stringify({ healthy: true, version: "1.14.19" }));
191 return;
192 }
193 await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]);
194 response.end("drained");
195});
196server.listen(0, "127.0.0.1", () => {
197 process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n");
198});
199`,
200 );
201 yield* fs.writeFileString(
202 binaryPath,
203 [
204 ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]),
205 isWindows
206 ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*'
207 : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"',
208 "",
209 ].join("\n"),
210 );
211 if (!isWindows) {
212 yield* fs.chmod(binaryPath, 0o755);
213 }
214
215 const runtime = yield* OpenCodeRuntime;
216 const server = yield* runtime.startOpenCodeServerProcess({
217 binaryPath,
218 directory: tempDir,
219 port: 0,
220 environment: {
221 ...environment,
222 T3_TEST_NODE_BINARY: executablePath,
223 T3_TEST_OPENCODE_SCRIPT: scriptPath,
224 },
225 });
226 const response = yield* HttpClient.get(`${server.url}/output`);
227
228 expect(yield* response.text).toBe("drained");
229 expect(yield* server.isRunning).toBe(true);
230 }).pipe(
231 Effect.scoped,
232 Effect.provide([
233 OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)),
234 FetchHttpClient.layer,
235 ]),
236 ),
237 10_000,
238 );
239});
apps/server/src/provider/opencodeRuntime.inventory.test.ts+41-1
......@@ -1,12 +1,14 @@
11import * as NodeAssert from "node:assert/strict";
22
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import type { OpencodeClient } from "@opencode-ai/sdk/v2";
4import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
55import { it } from "@effect/vitest";
66import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
78import * as FileSystem from "effect/FileSystem";
89import * as Layer from "effect/Layer";
910import * as Path from "effect/Path";
11import * as Queue from "effect/Queue";
1012import {
1113 HostProcessEnvironment,
1214 HostProcessExecutablePath,
......@@ -18,6 +20,44 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts";
1820const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
1921
2022it.layer(testLayer)("OpenCodeRuntime inventory", (it) => {
23 it.effect("aborts pending SDK requests when inventory loading is interrupted", () =>
24 Effect.gen(function* () {
25 const runtime = yield* OpenCodeRuntime;
26 const started = yield* Queue.make<void>();
27 const aborted = yield* Queue.make<string>();
28 const client = createOpencodeClient({
29 baseUrl: "http://opencode.test",
30 fetch: Object.assign(
31 (input: string | Request | URL) => {
32 const request = input instanceof Request ? input : new Request(input.toString());
33 return new Promise<Response>((_resolve, reject) => {
34 request.signal.addEventListener(
35 "abort",
36 () => {
37 Queue.offerUnsafe(aborted, new URL(request.url).pathname);
38 reject(request.signal.reason);
39 },
40 { once: true },
41 );
42 Queue.offerUnsafe(started, undefined);
43 });
44 },
45 { preconnect: () => undefined },
46 ),
47 });
48
49 const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild);
50 yield* Queue.takeN(started, 3);
51 yield* Fiber.interrupt(inventoryFiber);
52
53 NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [
54 "/agent",
55 "/provider",
56 "/skill",
57 ]);
58 }),
59 );
60
2161 it.effect("keeps provider inventory when agent discovery fails", () =>
2262 Effect.gen(function* () {
2363 const runtime = yield* OpenCodeRuntime;
apps/server/src/provider/opencodeRuntime.permissions.test.ts+50-6
......@@ -1,15 +1,21 @@
11import * as NodeAssert from "node:assert/strict";
22
3import * as RegExpUtils from "effect/RegExp";
34import { describe, it } from "vite-plus/test";
45
5import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts";
6import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts";
67
78function actionFor(
89 runtimeMode: Parameters<typeof buildOpenCodePermissionRules>[0],
910 permission: string,
11 target = "*",
1012) {
11 return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission)
12 ?.action;
13 // OpenCode uses the last matching rule. Its wildcards match directory separators.
14 return buildOpenCodePermissionRules(runtimeMode).findLast(
15 (rule) =>
16 (rule.permission === "*" || rule.permission === permission) &&
17 new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target),
18 )?.action;
1319}
1420
1521describe("buildOpenCodePermissionRules", () => {
......@@ -27,12 +33,38 @@ describe("buildOpenCodePermissionRules", () => {
2733 NodeAssert.equal(actionFor("auto", "edit"), "ask");
2834 });
2935
30 it("keeps asking for everything else in the auto modes", () => {
31 for (const runtimeMode of ["auto-accept-edits", "auto"] as const) {
36 it("allows workspace reads and task updates without asking in supervised modes", () => {
37 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
38 for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) {
39 NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow");
40 }
41 }
42 });
43
44 it("preserves OpenCode's environment-file approval rules", () => {
45 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
46 for (const target of [
47 ".env",
48 ".env.local",
49 "config/service.env",
50 "config/service.env.local",
51 ]) {
52 NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask");
53 }
54 for (const target of [".env.example", "config/service.env.example"]) {
55 NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow");
56 }
57 }
58 });
59
60 it("still asks before commands, network access, external directories and unknown tools", () => {
61 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
3262 NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask");
3363 NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask");
64 NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask");
3465 NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask");
35 NodeAssert.equal(actionFor(runtimeMode, "*"), "ask");
66 NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask");
67 NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask");
3668 }
3769 });
3870
......@@ -43,3 +75,15 @@ describe("buildOpenCodePermissionRules", () => {
4375 ]);
4476 });
4577});
78
79describe("toOpenCodePermissionReply", () => {
80 it.each([
81 ["accept", "once"],
82 ["acceptForSession", "always"],
83 ["acceptAlways", "always"],
84 ["decline", "reject"],
85 ["cancel", "reject"],
86 ] as const)("maps %s to %s", (decision, reply) => {
87 NodeAssert.equal(toOpenCodePermissionReply(decision), reply);
88 });
89});
apps/server/src/provider/opencodeRuntime.ts+48-21
......@@ -80,6 +80,7 @@ export function resolveOpenCodeServerPassword(
8080const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
8181const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
8282const DEFAULT_HOSTNAME = "127.0.0.1";
83const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;
8384const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
8485export interface OpenCodeServerProcess {
8586 readonly url: string;
......@@ -494,8 +495,19 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi
494495 // reviewer, OpenCode among them, fall back to Supervised for that mode.
495496 const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask";
496497
498 // Session rules override OpenCode's agent defaults. Allow reads and task
499 // updates, but keep its default approval rules for environment files.
497500 return [
498501 { permission: "*", pattern: "*", action: "ask" },
502 { permission: "read", pattern: "*", action: "allow" },
503 { permission: "read", pattern: "*.env", action: "ask" },
504 { permission: "read", pattern: "*.env.*", action: "ask" },
505 { permission: "read", pattern: "*.env.example", action: "allow" },
506 { permission: "glob", pattern: "*", action: "allow" },
507 { permission: "grep", pattern: "*", action: "allow" },
508 { permission: "lsp", pattern: "*", action: "allow" },
509 { permission: "skill", pattern: "*", action: "allow" },
510 { permission: "todowrite", pattern: "*", action: "allow" },
499511 { permission: "bash", pattern: "*", action: "ask" },
500512 { permission: "edit", pattern: "*", action: editAction },
501513 { permission: "webfetch", pattern: "*", action: "ask" },
......@@ -514,6 +526,7 @@ export function toOpenCodePermissionReply(
514526 case "accept":
515527 return "once";
516528 case "acceptForSession":
529 case "acceptAlways":
517530 return "always";
518531 case "decline":
519532 case "cancel":
......@@ -707,18 +720,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
707720 );
708721 yield* Scope.addFinalizer(runtimeScope, terminateChild);
709722
710 const stdoutRef = yield* Ref.make("");
711 const stderrRef = yield* Ref.make("");
723 const stdoutRef = yield* Ref.make<string | null>("");
724 const stderrRef = yield* Ref.make<string | null>("");
712725 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();
713726
714727 const setReadyFromStdoutChunk = (chunk: string) =>
715 Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe(
716 Effect.flatMap((nextStdout) => {
717 const parsed = parseServerUrlFromOutput(nextStdout);
718 return parsed
719 ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore)
720 : Effect.void;
721 }),
728 Ref.modify(stdoutRef, (stdout) => {
729 if (stdout === null) {
730 return [null, null] as const;
731 }
732 const nextStdout = `${stdout}${chunk}`;
733 return [
734 parseServerUrlFromOutput(nextStdout),
735 nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS),
736 ] as const;
737 }).pipe(
738 Effect.flatMap((parsed) =>
739 parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void,
740 ),
722741 );
723742
724743 const stdoutFiber = yield* child.stdout.pipe(
......@@ -729,7 +748,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
729748 );
730749 const stderrFiber = yield* child.stderr.pipe(
731750 Stream.decodeText(),
732 Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)),
751 Stream.runForEach((chunk) =>
752 Ref.update(stderrRef, (stderr) =>
753 stderr === null
754 ? null
755 : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS),
756 ),
757 ),
733758 Effect.ignore,
734759 Effect.forkIn(runtimeScope),
735760 );
......@@ -737,8 +762,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
737762 const exitFiber = yield* child.exitCode.pipe(
738763 Effect.flatMap((code) =>
739764 Effect.gen(function* () {
740 const stdout = yield* Ref.get(stdoutRef);
741 const stderr = yield* Ref.get(stderrRef);
765 const stdout = (yield* Ref.get(stdoutRef)) ?? "";
766 const stderr = (yield* Ref.get(stderrRef)) ?? "";
742767 const exitCode = Number(code);
743768 yield* Deferred.fail(
744769 readyDeferred,
......@@ -764,14 +789,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
764789 Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),
765790 );
766791
767 // Startup-time fibers are no longer needed once ready has resolved (either
768 // way). The exit fiber is only interrupted on failure; on success it keeps
769 // the caller's `exitCode` effect observable until the scope closes.
770 yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore);
771 yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore);
792 if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) {
793 yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore);
794 }
772795
773796 if (Exit.isFailure(readyExit)) {
774 yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
775797 const squashed = Cause.squash(readyExit.cause);
776798 return yield* ensureRuntimeError(
777799 "startOpenCodeServerProcess",
......@@ -782,13 +804,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
782804
783805 const readyOption = readyExit.value;
784806 if (Option.isNone(readyOption)) {
785 yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
786807 return yield* new OpenCodeRuntimeError({
787808 operation: "startOpenCodeServerProcess",
788809 detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`,
789810 });
790811 }
791812
813 // Keep draining both pipes until the process scope closes. Stopping the
814 // readers can block OpenCode when its output buffers fill. Startup output
815 // is no longer needed, so discard later output instead of retaining it.
816 yield* Ref.set(stdoutRef, null);
817 yield* Ref.set(stderrRef, null);
818
792819 const url = readyOption.value;
793820 const version = yield* verifyOpenCodeServerVersion(
794821 createOpenCodeSdkClient({
......@@ -854,7 +881,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
854881 };
855882
856883 const loadProviders = (client: OpencodeClient) =>
857 runOpenCodeSdk("provider.list", () => client.provider.list()).pipe(
884 runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe(
858885 Effect.filterMapOrFail(
859886 (list) =>
860887 list.data
......@@ -870,7 +897,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
870897 );
871898
872899 const loadAgents = (client: OpencodeClient) =>
873 runOpenCodeSdk("app.agents", () => client.app.agents()).pipe(
900 runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe(
874901 Effect.map((result) => result.data ?? []),
875902 Effect.orElseSucceed((): ReadonlyArray<Agent> => []),
876903 );
apps/web/src/session-logic.test.ts+33-2
......@@ -62,6 +62,37 @@ function makeActivity(overrides: {
6262}
6363
6464describe("derivePendingApprovals", () => {
65 it.each([{}, { requestType: "unknown" }])(
66 "exposes legacy OpenCode approvals without a known request kind: %j",
67 (legacyPayload) => {
68 const requested = makeActivity({
69 kind: "approval.requested",
70 payload: { requestId: "per-legacy", detail: "*", ...legacyPayload },
71 });
72
73 expect(derivePendingApprovals([requested])).toEqual([
74 {
75 requestId: "per-legacy",
76 requestKind: "command",
77 createdAt: requested.createdAt,
78 detail: "*",
79 },
80 ]);
81 },
82 );
83
84 it.each(["tool_user_input", "auth_tokens_refresh"])(
85 "does not turn %s into an approval",
86 (requestType) => {
87 const activity = makeActivity({
88 kind: "approval.requested",
89 payload: { requestId: "not-an-approval", requestType },
90 });
91
92 expect(derivePendingApprovals([activity])).toEqual([]);
93 },
94 );
95
6596 it("tracks open approvals and removes resolved ones", () => {
6697 const activities: OrchestrationThreadActivity[] = [
6798 makeActivity({
......@@ -90,7 +121,7 @@ describe("derivePendingApprovals", () => {
90121 kind: "approval.requested",
91122 summary: "File-change approval requested",
92123 tone: "approval",
93 payload: { requestId: "req-2", requestKind: "file-change" },
124 payload: { requestId: "req-2", requestType: "unknown" },
94125 }),
95126 ];
96127
......@@ -199,7 +230,7 @@ describe("derivePendingApprovals", () => {
199230 tone: "approval",
200231 payload: {
201232 requestId: "req-stale-1",
202 requestKind: "command",
233 requestType: "unknown",
203234 },
204235 }),
205236 makeActivity({
apps/web/src/session-logic.ts+8-2
......@@ -464,10 +464,16 @@ export function derivePendingApprovals(
464464 ? payload.options.filter(isProviderApprovalOption)
465465 : undefined;
466466
467 if (activity.kind === "approval.requested" && requestId && requestKind) {
467 if (
468 activity.kind === "approval.requested" &&
469 requestId &&
470 payload?.requestType !== "tool_user_input" &&
471 payload?.requestType !== "auth_tokens_refresh"
472 ) {
468473 openByRequestId.set(requestId, {
469474 requestId,
470 requestKind,
475 // Older OpenCode requests can have no recognized approval kind.
476 requestKind: requestKind ?? "command",
471477 createdAt: activity.createdAt,
472478 ...(detail ? { detail } : {}),
473479 ...(appName ? { appName } : {}),
docs/user/providers-opencode.md+34-3
......@@ -17,14 +17,45 @@ With a server URL, T3 Code connects to that external server and uses only the pa
1717provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.
1818OpenCode uses this password for HTTP Basic authentication.
1919
20## Approvals
21
22In **Supervised** and **Auto** modes, OpenCode can read normal project files, search files, load
23skills, and update its task list without approval. Files such as `.env` and `.env.local` still
24require approval. `.env.example` does not. OpenCode does not have an AI approval reviewer, so
25**Auto** uses the same permission rules as **Supervised**.
26
27OpenCode asks before it runs commands, edits files, accesses the web, or accesses directories
28outside the workspace. **Auto-accept edits** also permits file edits without approval.
29**Full access** permits all these actions. Questions that need your answer can still appear.
30
31An **Approval** badge means OpenCode needs a decision. Open the thread to see the action and
32choose one of these options:
33
34- **Allow once** permits this request.
35- **Allow for workspace** permits matching requests in other OpenCode sessions in the same
36 workspace. It is not limited to the current thread.
37- **Deny** rejects this request. Use **Stop** to stop the whole turn.
38
39If a connection error prevents the reply, the approval stays available so you can try again.
40
41## Progress
42
43T3 Code shows OpenCode's response text and tool results while work runs. The web and desktop apps
44also show its task-list progress. A task-list update does not require approval.
45
46If the OpenCode connection closes unexpectedly, T3 Code shows an error. Send another prompt to
47reconnect to the same OpenCode session.
48
2049## Stop a turn
2150
2251When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions.
2352T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It
24does not stop unrelated OpenCode sessions.
53does not stop unrelated OpenCode sessions. After Stop succeeds, pending approvals and questions
54are cleared.
2555
26Stop reports an error if OpenCode cannot list or stop a child session. When T3 Code closes an
27OpenCode session, it also tries to stop the child sessions, but this teardown is best effort.
56Stop reports an error if OpenCode cannot stop the main session or list or stop a child session.
57When T3 Code closes an OpenCode session, it also tries to stop the child sessions, but this
58teardown is best effort.
2859
2960## Refresh the model list
3061
packages/client-runtime/src/work-log/presentation.test.ts+34
......@@ -15,6 +15,40 @@ import {
1515} from "./presentation.js";
1616
1717describe("summarizeToolGroup", () => {
18 it.each(["command", "file-read", "file-change"])(
19 "keeps %s approvals out of tool execution counts",
20 (requestKind) => {
21 const approvals = [
22 {
23 label: "Approval requested",
24 sourceActivityKind: "approval.requested",
25 tone: "info",
26 requestKind,
27 },
28 {
29 label: "Approval resolved",
30 sourceActivityKind: "approval.resolved",
31 tone: "info",
32 requestKind,
33 },
34 {
35 label: "Provider approval response failed",
36 sourceActivityKind: "provider.approval.respond.failed",
37 tone: "error",
38 },
39 ] satisfies WorkLogPresentationEntry[];
40
41 expect(
42 summarizeToolGroup([
43 ...approvals,
44 { label: "Read", tone: "tool", itemType: "dynamic_tool_call" },
45 ]),
46 ).toBe("Received 3 updates and used 1 tool");
47 expect(summarizeToolGroup(approvals)).toBe("Received 3 updates");
48 expect(toolGroupSummaryKind(approvals)).toBe("update");
49 },
50 );
51
1852 it("deduplicates named sources ahead of ordinary actions", () => {
1953 const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const };
2054 expect(
packages/client-runtime/src/work-log/presentation.ts+7
......@@ -313,6 +313,13 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry):
313313}
314314
315315export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
316 if (
317 entry.sourceActivityKind === "approval.requested" ||
318 entry.sourceActivityKind === "approval.resolved" ||
319 entry.sourceActivityKind === "provider.approval.respond.failed"
320 ) {
321 return "update";
322 }
316323 if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser";
317324 if (
318325 entry.requestKind === "file-read" ||