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", () => {...@@ -263,6 +263,68 @@ describe("pending user input answers", () => {
263});263});
264264
265describe("pending approvals", () => {265describe("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
266 it("keeps app access approvals and persistence choices from remote environments", () => {328 it("keeps app access approvals and persistence choices from remote environments", () => {
267 const options = [329 const options = [
268 { decision: "decline", label: "Decline" },330 { decision: "decline", label: "Decline" },
apps/mobile/src/lib/threadActivity.ts+8-2
...@@ -1754,10 +1754,16 @@ export function derivePendingApprovals(...@@ -1754,10 +1754,16 @@ export function derivePendingApprovals(
1754 ? payload.options.filter(isProviderApprovalOption)1754 ? payload.options.filter(isProviderApprovalOption)
1755 : undefined;1755 : 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 ) {
1758 openByRequestId.set(requestId, {1763 openByRequestId.set(requestId, {
1759 requestId,1764 requestId,
1760 requestKind,1765 // Older OpenCode requests can have no recognized approval kind.
1766 requestKind: requestKind ?? "command",
1761 createdAt: activity.createdAt,1767 createdAt: activity.createdAt,
1762 ...(detail ? { detail } : {}),1768 ...(detail ? { detail } : {}),
1763 ...(appName ? { appName } : {}),1769 ...(appName ? { appName } : {}),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+81-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 ApprovalRequestId,
2 CheckpointRef,3 CheckpointRef,
3 CommandId,4 CommandId,
4 CorrelationId,5 CorrelationId,
...@@ -2744,7 +2745,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -2744,7 +2745,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
2744 }),2745 }),
2745 );2746 );
27462747
2747 it.effect("ignores non-stale provider approval response failures", () =>2748 it.effect("restores pending approvals when a provider reply fails", () =>
2748 Effect.gen(function* () {2749 Effect.gen(function* () {
2749 const projectionPipeline = yield* OrchestrationProjectionPipeline;2750 const projectionPipeline = yield* OrchestrationProjectionPipeline;
2750 const eventStore = yield* OrchestrationEventStore;2751 const eventStore = yield* OrchestrationEventStore;
...@@ -2829,6 +2830,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -2829,6 +2830,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
2829 },2830 },
2830 });2831 });
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
2832 yield* appendAndProject({2851 yield* appendAndProject({
2833 type: "thread.activity-appended",2852 type: "thread.activity-appended",
2834 eventId: EventId.make("evt-nonstale-approval-4"),2853 eventId: EventId.make("evt-nonstale-approval-4"),
...@@ -2921,6 +2940,67 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -2921,6 +2940,67 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
2921 WHERE thread_id = 'thread-nonstale-approval'2940 WHERE thread_id = 'thread-nonstale-approval'
2922 `;2941 `;
2923 assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]);2942 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 }]);
2924 }),3004 }),
2925 );3005 );
29263006
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+38
...@@ -1728,6 +1728,44 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1728,6 +1728,44 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1728 });1728 });
1729 return;1729 return;
1730 }1730 }
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 }
1731 return;1769 return;
1732 }1770 }
1733 // Only approval-requested activities should create pending-approval1771 // Only approval-requested activities should create pending-approval
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+190
...@@ -371,6 +371,196 @@ describe("ProviderRuntimeIngestion", () => {...@@ -371,6 +371,196 @@ describe("ProviderRuntimeIngestion", () => {
371 expect(thread.session?.lastError).toBe("turn failed");371 expect(thread.session?.lastError).toBe("turn failed");
372 });372 });
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
374 it("applies provider session.state.changed transitions directly", async () => {564 it("applies provider session.state.changed transitions directly", async () => {
375 const harness = await createHarness();565 const harness = await createHarness();
376 const waitingAt = "2026-01-01T00:00:00.000Z";566 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* () {...@@ -1578,6 +1578,7 @@ const make = Effect.gen(function* () {
1578 const now = event.createdAt;1578 const now = event.createdAt;
1579 const eventTurnId = toTurnId(event.turnId);1579 const eventTurnId = toTurnId(event.turnId);
1580 const activeTurnId = thread.session?.activeTurnId ?? null;1580 const activeTurnId = thread.session?.activeTurnId ?? null;
1581 const isTerminalTurn = event.type === "turn.completed" || event.type === "turn.aborted";
1581 const isCompactedThreadState =1582 const isCompactedThreadState =
1582 event.type === "thread.state.changed" && event.payload.state === "compacted";1583 event.type === "thread.state.changed" && event.payload.state === "compacted";
1583 const pendingTurnStart =1584 const pendingTurnStart =
...@@ -1586,7 +1587,7 @@ const make = Effect.gen(function* () {...@@ -1586,7 +1587,7 @@ const make = Effect.gen(function* () {
1586 event.type === "session.exited" ||1587 event.type === "session.exited" ||
1587 event.type === "thread.started" ||1588 event.type === "thread.started" ||
1588 event.type === "turn.started" ||1589 event.type === "turn.started" ||
1589 event.type === "turn.completed" ||1590 isTerminalTurn ||
1590 isCompactedThreadState1591 isCompactedThreadState
1591 ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({1592 ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({
1592 threadId: thread.id,1593 threadId: thread.id,
...@@ -1624,6 +1625,7 @@ const make = Effect.gen(function* () {...@@ -1624,6 +1625,7 @@ const make = Effect.gen(function* () {
1624 case "turn.started":1625 case "turn.started":
1625 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;1626 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;
1626 case "turn.completed":1627 case "turn.completed":
1628 case "turn.aborted":
1627 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {1629 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
1628 return false;1630 return false;
1629 }1631 }
...@@ -1631,14 +1633,10 @@ const make = Effect.gen(function* () {...@@ -1631,14 +1633,10 @@ const make = Effect.gen(function* () {
1631 if (activeTurnId !== null && eventTurnId !== undefined) {1633 if (activeTurnId !== null && eventTurnId !== undefined) {
1632 return sameId(activeTurnId, eventTurnId);1634 return sameId(activeTurnId, eventTurnId);
1633 }1635 }
1634 // No active turn tracked: accept only completions that name their1636 // A named completion can recover a lost turn.started event.
1635 // turn (covers a real completion whose turn.started was lost). An1637 // An abort needs an active turn so a delayed stop cannot replace
1636 // untargeted completion cannot prove it belongs to any turn this1638 // a ready session or clear a newer pending start.
1637 // thread ran — the known emitter was the Claude resume handshake1639 return event.type === "turn.completed" && eventTurnId !== undefined;
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;
1642 default:1640 default:
1643 return true;1641 return true;
1644 }1642 }
...@@ -1654,7 +1652,7 @@ const make = Effect.gen(function* () {...@@ -1654,7 +1652,7 @@ const make = Effect.gen(function* () {
1654 event.type === "session.exited" ||1652 event.type === "session.exited" ||
1655 event.type === "thread.started" ||1653 event.type === "thread.started" ||
1656 event.type === "turn.started" ||1654 event.type === "turn.started" ||
1657 event.type === "turn.completed"1655 isTerminalTurn
1658 ) {1656 ) {
1659 const status = (() => {1657 const status = (() => {
1660 switch (event.type) {1658 switch (event.type) {
...@@ -1666,6 +1664,8 @@ const make = Effect.gen(function* () {...@@ -1666,6 +1664,8 @@ const make = Effect.gen(function* () {
1666 return "running";1664 return "running";
1667 case "session.exited":1665 case "session.exited":
1668 return "stopped";1666 return "stopped";
1667 case "turn.aborted":
1668 return "interrupted";
1669 case "turn.completed":1669 case "turn.completed":
1670 return normalizeRuntimeTurnState(event.payload.state) === "failed"1670 return normalizeRuntimeTurnState(event.payload.state) === "failed"
1671 ? "error"1671 ? "error"
...@@ -1680,7 +1680,7 @@ const make = Effect.gen(function* () {...@@ -1680,7 +1680,7 @@ const make = Effect.gen(function* () {
1680 const nextActiveTurnId =1680 const nextActiveTurnId =
1681 event.type === "turn.started"1681 event.type === "turn.started"
1682 ? (eventTurnId ?? null)1682 ? (eventTurnId ?? null)
1683 : event.type === "turn.completed" || event.type === "session.exited"1683 : isTerminalTurn || event.type === "session.exited"
1684 ? null1684 ? null
1685 : event.type === "session.state.changed" &&1685 : event.type === "session.state.changed" &&
1686 !sessionStatusAllowsActiveTurn(1686 !sessionStatusAllowsActiveTurn(
...@@ -1694,7 +1694,7 @@ const make = Effect.gen(function* () {...@@ -1694,7 +1694,7 @@ const make = Effect.gen(function* () {
1694 : event.type === "turn.completed" &&1694 : event.type === "turn.completed" &&
1695 normalizeRuntimeTurnState(event.payload.state) === "failed"1695 normalizeRuntimeTurnState(event.payload.state) === "failed"
1696 ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed")1696 ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed")
1697 : status === "ready"1697 : status === "ready" || status === "interrupted"
1698 ? null1698 ? null
1699 : (thread.session?.lastError ?? null);1699 : (thread.session?.lastError ?? null);
17001700
...@@ -1922,7 +1922,7 @@ const make = Effect.gen(function* () {...@@ -1922,7 +1922,7 @@ const make = Effect.gen(function* () {
1922 });1922 });
1923 }1923 }
19241924
1925 if (event.type === "turn.completed") {1925 if (isTerminalTurn) {
1926 const detailedThread = yield* getLoadedThreadDetail();1926 const detailedThread = yield* getLoadedThreadDetail();
1927 const messages = detailedThread?.messages ?? [];1927 const messages = detailedThread?.messages ?? [];
1928 const proposedPlans = detailedThread?.proposedPlans ?? [];1928 const proposedPlans = detailedThread?.proposedPlans ?? [];
...@@ -2055,7 +2055,7 @@ const make = Effect.gen(function* () {...@@ -2055,7 +2055,7 @@ const make = Effect.gen(function* () {
2055 } else if (!conflictsWithActiveTurn) {2055 } else if (!conflictsWithActiveTurn) {
2056 if (event.type === "turn.plan.updated") {2056 if (event.type === "turn.plan.updated") {
2057 threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan);2057 threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan);
2058 } else if (event.type === "turn.completed" || event.type === "turn.aborted") {2058 } else if (isTerminalTurn && shouldApplyThreadLifecycle) {
2059 threadPlanProgress.clearThreadPlanProgress(thread.id);2059 threadPlanProgress.clearThreadPlanProgress(thread.id);
2060 }2060 }
2061 }2061 }
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+817-69
...@@ -15,7 +15,11 @@ import * as Scope from "effect/Scope";...@@ -15,7 +15,11 @@ import * as Scope from "effect/Scope";
15import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";16import * as TestClock from "effect/testing/TestClock";
17import { beforeEach } from "vite-plus/test";17import { 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
20import {24import {
21 ApprovalRequestId,25 ApprovalRequestId,
...@@ -85,23 +89,30 @@ const runtimeMock = {...@@ -85,23 +89,30 @@ const runtimeMock = {
85 promptAsyncImplementation: null as (() => Promise<void>) | null,89 promptAsyncImplementation: null as (() => Promise<void>) | null,
86 autoPromptEcho: true,90 autoPromptEcho: true,
87 autoConnect: true,91 autoConnect: true,
92 endEventStream: false,
88 promptEchoEvents: [] as Array<unknown>,93 promptEchoEvents: [] as Array<unknown>,
89 closeError: null as Error | null,94 closeError: null as Error | null,
90 messages: [] as MessageEntry[],95 messages: [] as MessageEntry[],
91 subscribedEvents: [] as Array<unknown | Promise<unknown>>,96 subscribedEvents: [] as Array<unknown | Promise<unknown>>,
92 eventSubscribeObserved: null as (() => void) | null,97 eventSubscribeObserved: null as (() => void) | null,
98 eventStreamError: null as ((cause: unknown) => void) | null,
93 permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>,99 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[],
95 questionReplyCalls: [] as Array<{102 questionReplyCalls: [] as Array<{
96 requestID: string;103 requestID: string;
97 answers: ReadonlyArray<ReadonlyArray<string>>;104 answers: ReadonlyArray<ReadonlyArray<string>>;
98 }>,105 }>,
106 questionReplyImplementation: null as ((signal?: AbortSignal) => Promise<void>) | null,
99 sessionStatus: "idle" as "idle" | "busy",107 sessionStatus: "idle" as "idle" | "busy",
100 sessionStatusFailures: 0,108 sessionStatusFailures: 0,
101 sessionStatusCalls: 0,109 sessionStatusCalls: 0,
102 sessionStatusImplementation: null as (() => Promise<unknown>) | null,110 sessionStatusImplementation: null as (() => Promise<unknown>) | null,
103 sessionGetIds: [] as string[],111 sessionGetIds: [] as string[],
104 sessionGetObserved: null as ((sessionID: string) => void) | null,112 sessionGetObserved: null as ((sessionID: string) => void) | null,
113 sessionGetImplementation: null as
114 | ((sessionID: string, signal?: AbortSignal) => Promise<void>)
115 | null,
105 missingSessionIds: new Set<string>(),116 missingSessionIds: new Set<string>(),
106 transientErrorSessionIds: new Set<string>(),117 transientErrorSessionIds: new Set<string>(),
107 sessionDirectoryById: new Map<string, string>(),118 sessionDirectoryById: new Map<string, string>(),
...@@ -137,20 +148,25 @@ const runtimeMock = {...@@ -137,20 +148,25 @@ const runtimeMock = {
137 this.state.promptAsyncImplementation = null;148 this.state.promptAsyncImplementation = null;
138 this.state.autoPromptEcho = true;149 this.state.autoPromptEcho = true;
139 this.state.autoConnect = true;150 this.state.autoConnect = true;
151 this.state.endEventStream = false;
140 this.state.promptEchoEvents.length = 0;152 this.state.promptEchoEvents.length = 0;
141 this.state.closeError = null;153 this.state.closeError = null;
142 this.state.messages = [];154 this.state.messages = [];
143 this.state.subscribedEvents = [];155 this.state.subscribedEvents = [];
144 this.state.eventSubscribeObserved = null;156 this.state.eventSubscribeObserved = null;
157 this.state.eventStreamError = null;
145 this.state.permissionReplyCalls.length = 0;158 this.state.permissionReplyCalls.length = 0;
146 this.state.permissionReplyImplementation = null;159 this.state.permissionReplyImplementation = null;
160 this.state.permissionReplySignals.length = 0;
147 this.state.questionReplyCalls.length = 0;161 this.state.questionReplyCalls.length = 0;
162 this.state.questionReplyImplementation = null;
148 this.state.sessionStatus = "idle";163 this.state.sessionStatus = "idle";
149 this.state.sessionStatusFailures = 0;164 this.state.sessionStatusFailures = 0;
150 this.state.sessionStatusCalls = 0;165 this.state.sessionStatusCalls = 0;
151 this.state.sessionStatusImplementation = null;166 this.state.sessionStatusImplementation = null;
152 this.state.sessionGetIds.length = 0;167 this.state.sessionGetIds.length = 0;
153 this.state.sessionGetObserved = null;168 this.state.sessionGetObserved = null;
169 this.state.sessionGetImplementation = null;
154 this.state.missingSessionIds.clear();170 this.state.missingSessionIds.clear();
155 this.state.transientErrorSessionIds.clear();171 this.state.transientErrorSessionIds.clear();
156 this.state.sessionDirectoryById.clear();172 this.state.sessionDirectoryById.clear();
...@@ -222,9 +238,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -222,9 +238,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
222 data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` },238 data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` },
223 };239 };
224 },240 },
225 get: async ({ sessionID }: { sessionID: string }) => {241 get: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => {
226 runtimeMock.state.sessionGetIds.push(sessionID);242 runtimeMock.state.sessionGetIds.push(sessionID);
227 runtimeMock.state.sessionGetObserved?.(sessionID);243 runtimeMock.state.sessionGetObserved?.(sessionID);
244 if (runtimeMock.state.sessionGetImplementation) {
245 await runtimeMock.state.sessionGetImplementation(sessionID, options?.signal);
246 }
228 // The real client is `throwOnError: true`: non-2xx rejects rather247 // The real client is `throwOnError: true`: non-2xx rejects rather
229 // than resolving, so missing → 404 throw, transient → 500 throw.248 // than resolving, so missing → 404 throw, transient → 500 throw.
230 if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) {249 if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) {
...@@ -264,6 +283,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -264,6 +283,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
264 runtimeMock.state.abortSignals.push(options.signal);283 runtimeMock.state.abortSignals.push(options.signal);
265 }284 }
266 await runtimeMock.state.abortImplementation?.(sessionID, options?.signal);285 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 );
267 },292 },
268 children: async ({ sessionID }: { sessionID: string }) => {293 children: async ({ sessionID }: { sessionID: string }) => {
269 runtimeMock.state.sessionChildrenCalls.push(sessionID);294 runtimeMock.state.sessionChildrenCalls.push(sessionID);
...@@ -357,19 +382,60 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -357,19 +382,60 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
357 },382 },
358 },383 },
359 event: {384 event: {
360 subscribe: async () => {385 subscribe: async (
386 _input: unknown,
387 options?: { signal?: AbortSignal; onSseError?: (cause: unknown) => void },
388 ) => {
361 runtimeMock.state.eventSubscribeObserved?.();389 runtimeMock.state.eventSubscribeObserved?.();
390 runtimeMock.state.eventStreamError = options?.onSseError ?? null;
362 return {391 return {
363 stream: (async function* () {392 stream: (async function* () {
364 if (runtimeMock.state.autoConnect) {393 const aborted = promiseWithResolvers<void>();
365 yield { id: "evt-auto-connected", type: "server.connected", properties: {} };394 const onAbort = () => aborted.resolve(undefined);
366 }395 options?.signal?.addEventListener("abort", onAbort, { once: true });
367 for (const event of runtimeMock.state.subscribedEvents) {396 try {
368 const resolved = await event;397 if (runtimeMock.state.autoConnect) {
369 while (runtimeMock.state.promptEchoEvents.length > 0) {398 yield { id: "evt-auto-connected", type: "server.connected", properties: {} };
370 yield runtimeMock.state.promptEchoEvents.shift();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;
371 }433 }
372 yield resolved;434 if (!runtimeMock.state.endEventStream && !options?.signal?.aborted) {
435 await aborted.promise;
436 }
437 } finally {
438 options?.signal?.removeEventListener("abort", onAbort);
373 }439 }
374 })(),440 })(),
375 };441 };
...@@ -384,11 +450,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -384,11 +450,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
384 : runtimeMock.state.pendingPermissions,450 : runtimeMock.state.pendingPermissions,
385 };451 };
386 },452 },
387 reply: async ({ requestID, reply }: { requestID: string; reply: string }) => {453 reply: async (
454 { requestID, reply }: { requestID: string; reply: string },
455 options?: { signal?: AbortSignal },
456 ) => {
388 runtimeMock.state.permissionReplyCalls.push({ requestID, reply });457 runtimeMock.state.permissionReplyCalls.push({ requestID, reply });
458 if (options?.signal) runtimeMock.state.permissionReplySignals.push(options.signal);
389 if (runtimeMock.state.permissionReplyImplementation) {459 if (runtimeMock.state.permissionReplyImplementation) {
390 await runtimeMock.state.permissionReplyImplementation();460 await runtimeMock.state.permissionReplyImplementation(options?.signal);
391 }461 }
462 runtimeMock.state.pendingPermissions = runtimeMock.state.pendingPermissions.filter(
463 (request) => request.id !== requestID,
464 );
392 },465 },
393 },466 },
394 question: {467 question: {
...@@ -400,14 +473,21 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -400,14 +473,21 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
400 : runtimeMock.state.pendingQuestions,473 : runtimeMock.state.pendingQuestions,
401 };474 };
402 },475 },
403 reply: async ({476 reply: async (
404 requestID,477 {
405 answers,478 requestID,
406 }: {479 answers,
407 requestID: string;480 }: {
408 answers: ReadonlyArray<ReadonlyArray<string>>;481 requestID: string;
409 }) => {482 answers: ReadonlyArray<ReadonlyArray<string>>;
483 },
484 options?: { signal?: AbortSignal },
485 ) => {
410 runtimeMock.state.questionReplyCalls.push({ requestID, answers });486 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 );
411 },491 },
412 },492 },
413 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,493 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
...@@ -2576,6 +2656,411 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -2576,6 +2656,411 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
2576 }),2656 }),
2577 );2657 );
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
2579 it.effect("routes child-session approval requests and replies through the parent thread", () =>3064 it.effect("routes child-session approval requests and replies through the parent thread", () =>
2580 Effect.gen(function* () {3065 Effect.gen(function* () {
2581 const adapter = yield* OpenCodeAdapter;3066 const adapter = yield* OpenCodeAdapter;
...@@ -2691,6 +3176,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -2691,6 +3176,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
2691 Effect.gen(function* () {3176 Effect.gen(function* () {
2692 const adapter = yield* OpenCodeAdapter;3177 const adapter = yield* OpenCodeAdapter;
2693 const threadId = asThreadId(`thread-full-access-${requestId}`);3178 const threadId = asThreadId(`thread-full-access-${requestId}`);
3179 const replyStarted = promiseWithResolvers<void>();
3180 runtimeMock.state.permissionReplyImplementation = async () =>
3181 replyStarted.resolve(undefined);
2694 runtimeMock.state.subscribedEvents = [3182 runtimeMock.state.subscribedEvents = [
2695 {3183 {
2696 id: "evt-child-created",3184 id: "evt-child-created",
...@@ -2709,11 +3197,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -2709,11 +3197,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
2709 type: "permission.asked",3197 type: "permission.asked",
2710 properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always },3198 properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always },
2711 },3199 },
2712 {3200 replyStarted.promise.then(() => ({
2713 id: "evt-permission-replied",3201 id: "evt-permission-replied",
2714 type: "permission.replied",3202 type: "permission.replied",
2715 properties: { sessionID, requestID: requestId, reply: "once" },3203 properties: { sessionID, requestID: requestId, reply: "once" },
2716 },3204 })),
2717 // The suppressed ask emits nothing, so an empty question serves as a3205 // The suppressed ask emits nothing, so an empty question serves as a
2718 // sentinel that closes the collected stream once the pump is past it.3206 // sentinel that closes the collected stream once the pump is past it.
2719 {3207 {
...@@ -3000,53 +3488,68 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -3000,53 +3488,68 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
3000 }),3488 }),
3001 );3489 );
30023490
3003 it.effect("retries ancestry for one live child request after a transient failure", () =>3491 it.effect.each(["failure", "timeout"] as const)(
3004 Effect.gen(function* () {3492 "retries ancestry for a child request after a transient %s",
3005 const adapter = yield* OpenCodeAdapter;3493 (lookupFailure) =>
3006 const threadId = asThreadId("thread-child-request-ancestry-retry");3494 Effect.gen(function* () {
3007 const parentId = "http://127.0.0.1:9999/session";3495 const adapter = yield* OpenCodeAdapter;
3008 const ancestryAttempted = promiseWithResolvers<void>();3496 const threadId = asThreadId(`thread-child-request-ancestry-retry-${lookupFailure}`);
3009 runtimeMock.state.sessionParentById.set("ses_existing_child", parentId);3497 const parentId = "http://127.0.0.1:9999/session";
3010 runtimeMock.state.transientErrorSessionIds.add("ses_existing_child");3498 const ancestryAttempted = promiseWithResolvers<void>();
3011 runtimeMock.state.sessionGetObserved = (sessionID) => {3499 runtimeMock.state.sessionParentById.set("ses_existing_child", parentId);
3012 if (sessionID === "ses_existing_child") {3500 runtimeMock.state.transientErrorSessionIds.add("ses_existing_child");
3013 ancestryAttempted.resolve(undefined);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 };
3014 }3507 }
3015 };3508 runtimeMock.state.sessionGetObserved = (sessionID) => {
3016 runtimeMock.state.subscribedEvents = [3509 if (sessionID === "ses_existing_child") {
3017 {3510 ancestryAttempted.resolve(undefined);
3018 id: "evt-existing-child-permission",3511 }
3019 type: "permission.asked",3512 };
3020 properties: permissionRequest("per_retry", "ses_existing_child"),3513 runtimeMock.state.subscribedEvents = [
3021 },3514 {
3022 ];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(3521 const eventsFiber = yield* adapter.streamEvents.pipe(
3025 Stream.filter(3522 Stream.filter(
3026 (event) =>3523 (event) =>
3027 event.threadId === threadId &&3524 event.threadId === threadId &&
3028 (event.type === "runtime.warning" || event.type === "request.opened"),3525 (event.type === "runtime.warning" || event.type === "request.opened"),
3029 ),3526 ),
3030 Stream.take(2),3527 Stream.take(2),
3031 Stream.runCollect,3528 Stream.runCollect,
3032 Effect.forkChild,3529 Effect.forkChild,
3033 );3530 );
3034 yield* adapter.startSession({3531 yield* adapter.startSession({
3035 provider: ProviderDriverKind.make("opencode"),3532 provider: ProviderDriverKind.make("opencode"),
3036 threadId,3533 threadId,
3037 runtimeMode: "approval-required",3534 runtimeMode: "approval-required",
3038 });3535 });
3039 yield* Effect.promise(() => ancestryAttempted.promise);3536 yield* Effect.promise(() => ancestryAttempted.promise);
3040 runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child");3537 if (lookupFailure === "timeout") {
3041 yield* advanceTestClock(250);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")));3546 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
3044 NodeAssert.deepEqual(3547 NodeAssert.deepEqual(
3045 events.map((event) => event.type),3548 events.map((event) => event.type),
3046 ["runtime.warning", "request.opened"],3549 ["runtime.warning", "request.opened"],
3047 );3550 );
3048 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept");3551 yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept");
3049 }),3552 }),
3050 );3553 );
30513554
3052 it.effect("does not resurrect a recovered child request after its live reply", () =>3555 it.effect("does not resurrect a recovered child request after its live reply", () =>
...@@ -3101,7 +3604,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -3101,7 +3604,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
3101 const response = yield* Effect.exit(3604 const response = yield* Effect.exit(
3102 adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"),3605 adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"),
3103 );3606 );
3104 NodeAssert.equal(Exit.isFailure(response), true);3607 NodeAssert.equal(Exit.isSuccess(response), true);
3608 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []);
3105 }),3609 }),
3106 );3610 );
31073611
...@@ -3153,7 +3657,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -3153,7 +3657,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
3153 const response = yield* Effect.exit(3657 const response = yield* Effect.exit(
3154 adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"),3658 adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"),
3155 );3659 );
3156 NodeAssert.equal(Exit.isFailure(response), true);3660 NodeAssert.equal(Exit.isSuccess(response), true);
3661 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []);
3157 }),3662 }),
3158 );3663 );
31593664
...@@ -5286,6 +5791,249 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -5286,6 +5791,249 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
5286 }),5791 }),
5287 );5792 );
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
5289 it.effect("lets OpenCode own session title generation and emits title metadata updates", () =>6037 it.effect("lets OpenCode own session title generation and emits title metadata updates", () =>
5290 Effect.gen(function* () {6038 Effect.gen(function* () {
5291 const adapter = yield* OpenCodeAdapter;6039 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+383-97
...@@ -417,6 +417,9 @@ type EventBaseInput = {...@@ -417,6 +417,9 @@ type EventBaseInput = {
417417
418function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {418function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {
419 const normalized = toolName.toLowerCase();419 const normalized = toolName.toLowerCase();
420 if (normalized === "todowrite" || normalized === "todoread") {
421 return "dynamic_tool_call";
422 }
420 if (normalized.includes("bash") || normalized.includes("command")) {423 if (normalized.includes("bash") || normalized.includes("command")) {
421 return "command_execution";424 return "command_execution";
422 }425 }
...@@ -449,16 +452,15 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {...@@ -449,16 +452,15 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {
449452
450function mapPermissionToRequestType(453function mapPermissionToRequestType(
451 permission: string,454 permission: string,
452): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" {455): "command_execution_approval" | "file_read_approval" | "file_change_approval" {
453 switch (permission) {456 switch (permission) {
454 case "bash":
455 return "command_execution_approval";
456 case "read":457 case "read":
457 return "file_read_approval";458 return "file_read_approval";
458 case "edit":459 case "edit":
459 return "file_change_approval";460 return "file_change_approval";
460 default:461 default:
461 return "unknown";462 // Every OpenCode permission needs an actionable approval in each client.
463 return "command_execution_approval";
462 }464 }
463}465}
464466
...@@ -1062,6 +1064,10 @@ export function makeOpenCodeAdapter(...@@ -1062,6 +1064,10 @@ export function makeOpenCodeAdapter(
1062 context.interruptedTurnId = undefined;1064 context.interruptedTurnId = undefined;
1063 context.awaitingBusyAfterInterruption = false;1065 context.awaitingBusyAfterInterruption = false;
1064 context.reconcileIdleStatus = false;1066 context.reconcileIdleStatus = false;
1067 for (const requestId of context.autoRepliedRequestIds) {
1068 context.emittedTerminalRequestIds.add(requestId);
1069 }
1070 context.autoRepliedRequestIds.clear();
1065 applyProviderSessionUpdate(1071 applyProviderSessionUpdate(
1066 context,1072 context,
1067 { status: "ready" },1073 { status: "ready" },
...@@ -1071,6 +1077,7 @@ export function makeOpenCodeAdapter(...@@ -1071,6 +1077,7 @@ export function makeOpenCodeAdapter(
1071 if (pendingIdleReconciliation?.fiber) {1077 if (pendingIdleReconciliation?.fiber) {
1072 yield* Fiber.interrupt(pendingIdleReconciliation.fiber);1078 yield* Fiber.interrupt(pendingIdleReconciliation.fiber);
1073 }1079 }
1080 yield* schedulePendingRequestRecovery(context);
1074 yield* emit({1081 yield* emit({
1075 ...(yield* buildEventBase({1082 ...(yield* buildEventBase({
1076 threadId: context.session.threadId,1083 threadId: context.session.threadId,
...@@ -1425,6 +1432,7 @@ export function makeOpenCodeAdapter(...@@ -1425,6 +1432,7 @@ export function makeOpenCodeAdapter(
1425 { clearActiveTurnId: true, clearLastError: true },1432 { clearActiveTurnId: true, clearLastError: true },
1426 );1433 );
1427 }1434 }
1435 yield* clearPendingOpenCodeRequests(context, { type: "session.abort" });
1428 yield* emit({1436 yield* emit({
1429 ...(yield* buildEventBase({1437 ...(yield* buildEventBase({
1430 threadId: context.session.threadId,1438 threadId: context.session.threadId,
...@@ -1578,7 +1586,19 @@ export function makeOpenCodeAdapter(...@@ -1578,7 +1586,19 @@ export function makeOpenCodeAdapter(
15781586
1579 const seen = new Set<string>();1587 const seen = new Set<string>();
1580 const getSession = (sessionID: string) =>1588 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 }),
1582 Effect.catchIf(1602 Effect.catchIf(
1583 (cause) => isOpenCodeNotFound(cause),1603 (cause) => isOpenCodeNotFound(cause),
1584 () => Effect.succeed(undefined),1604 () => Effect.succeed(undefined),
...@@ -1610,6 +1630,52 @@ export function makeOpenCodeAdapter(...@@ -1610,6 +1630,52 @@ export function makeOpenCodeAdapter(
1610 return false;1630 return false;
1611 });1631 });
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
1613 // Full access means the user already granted everything, but two upstream1679 // Full access means the user already granted everything, but two upstream
1614 // paths never consult the session ruleset we send: doom-loop detection1680 // paths never consult the session ruleset we send: doom-loop detection
1615 // (evaluated against the agent ruleset only) and subagent sessions (which1681 // (evaluated against the agent ruleset only) and subagent sessions (which
...@@ -1622,15 +1688,12 @@ export function makeOpenCodeAdapter(...@@ -1622,15 +1688,12 @@ export function makeOpenCodeAdapter(
1622 const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* (1688 const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* (
1623 context: OpenCodeSessionContext,1689 context: OpenCodeSessionContext,
1624 request: PermissionRequest,1690 request: PermissionRequest,
1691 raw: unknown,
1625 ) {1692 ) {
1626 // Mark before awaiting: retry and recovery fibers re-enter the ask path,1693 const replied = yield* runOpenCodeSdk("permission.reply", (signal) =>
1627 // and the matching `permission.replied` can arrive, while the SDK call1694 context.client.permission.reply({ requestID: request.id, reply: "once" }, { signal }),
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" }),
1633 ).pipe(1695 ).pipe(
1696 Effect.timeout("10 seconds"),
1634 Effect.as(true),1697 Effect.as(true),
1635 Effect.orElseSucceed(() => false),1698 Effect.orElseSucceed(() => false),
1636 );1699 );
...@@ -1638,9 +1701,8 @@ export function makeOpenCodeAdapter(...@@ -1638,9 +1701,8 @@ export function makeOpenCodeAdapter(
1638 // Fall back to the dialog. The id stays resolved so a recovered copy1701 // Fall back to the dialog. The id stays resolved so a recovered copy
1639 // of this ask cannot reopen after the user answers;1702 // of this ask cannot reopen after the user answers;
1640 // `pendingPermissions` gates re-asks while the dialog is open.1703 // `pendingPermissions` gates re-asks while the dialog is open.
1641 context.autoRepliedRequestIds.delete(request.id);1704 yield* openPermissionRequest(context, request, raw);
1642 }1705 }
1643 return replied;
1644 });1706 });
16451707
1646 const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* (1708 const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* (
...@@ -1651,39 +1713,26 @@ export function makeOpenCodeAdapter(...@@ -1651,39 +1713,26 @@ export function makeOpenCodeAdapter(
1651 if (context.resolvedRequestIds.has(event.properties.id)) {1713 if (context.resolvedRequestIds.has(event.properties.id)) {
1652 return;1714 return;
1653 }1715 }
1716 if (context.activeTurnId === undefined && context.reconcileIdleStatus) {
1717 context.resolvedRequestIds.add(event.properties.id);
1718 return;
1719 }
1654 if (event.type === "permission.asked") {1720 if (event.type === "permission.asked") {
1655 const request = event.properties;1721 const request = event.properties;
1656 if (context.pendingPermissions.has(request.id)) {1722 if (context.pendingPermissions.has(request.id)) {
1657 return;1723 return;
1658 }1724 }
1659 if (1725 if (context.session.runtimeMode === "full-access") {
1660 context.session.runtimeMode === "full-access" &&1726 // Reply outside the event pump so a slow HTTP response cannot hide
1661 (yield* autoReplyFullAccess(context, request))1727 // progress, terminal replies, or the acknowledgment for Stop.
1662 ) {1728 context.resolvedRequestIds.add(request.id);
1663 return;1729 context.autoRepliedRequestIds.add(request.id);
1664 }1730 yield* autoReplyFullAccess(context, request, raw).pipe(
1665 const base = yield* buildEventBase({1731 Effect.forkIn(context.sessionScope),
1666 threadId: context.session.threadId,1732 );
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)) {
1675 return;1733 return;
1676 }1734 }
1677 context.pendingPermissions.set(request.id, request);1735 yield* openPermissionRequest(context, request, raw);
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 });
1687 return;1736 return;
1688 }1737 }
16891738
...@@ -1691,14 +1740,19 @@ export function makeOpenCodeAdapter(...@@ -1691,14 +1740,19 @@ export function makeOpenCodeAdapter(
1691 if (context.pendingQuestions.has(request.id)) {1740 if (context.pendingQuestions.has(request.id)) {
1692 return;1741 return;
1693 }1742 }
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 }
1694 context.pendingQuestions.set(request.id, request);1753 context.pendingQuestions.set(request.id, request);
1695 yield* emit({1754 emitUnsafe({
1696 ...(yield* buildEventBase({1755 ...base,
1697 threadId: context.session.threadId,
1698 turnId: context.activeTurnId,
1699 requestId: request.id,
1700 raw,
1701 })),
1702 type: "user-input.requested",1756 type: "user-input.requested",
1703 payload: { questions: normalizeQuestionRequest(request) },1757 payload: { questions: normalizeQuestionRequest(request) },
1704 });1758 });
...@@ -1719,26 +1773,32 @@ export function makeOpenCodeAdapter(...@@ -1719,26 +1773,32 @@ export function makeOpenCodeAdapter(
1719 const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* (1773 const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* (
1720 context: OpenCodeSessionContext,1774 context: OpenCodeSessionContext,
1721 event: OpenCodeTerminalRequestEvent,1775 event: OpenCodeTerminalRequestEvent,
1776 raw: unknown = event,
1722 ) {1777 ) {
1723 const requestId = event.properties.requestID;1778 const requestId = event.properties.requestID;
1724 if (context.emittedTerminalRequestIds.has(requestId)) {1779 if (context.emittedTerminalRequestIds.has(requestId)) {
1725 return;1780 return;
1726 }1781 }
1727 context.emittedTerminalRequestIds.add(requestId);
1728 if (context.autoRepliedRequestIds.delete(requestId)) {1782 if (context.autoRepliedRequestIds.delete(requestId)) {
1783 context.emittedTerminalRequestIds.add(requestId);
1729 return;1784 return;
1730 }1785 }
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);
1731 if (event.type === "permission.replied") {1794 if (event.type === "permission.replied") {
1732 yield* emit({1795 const request = context.pendingPermissions.get(requestId);
1733 ...(yield* buildEventBase({1796 context.pendingPermissions.delete(requestId);
1734 threadId: context.session.threadId,1797 emitUnsafe({
1735 turnId: context.activeTurnId,1798 ...base,
1736 requestId,
1737 raw: event,
1738 })),
1739 type: "request.resolved",1799 type: "request.resolved",
1740 payload: {1800 payload: {
1741 requestType: "unknown",1801 requestType: request ? mapPermissionToRequestType(request.permission) : "unknown",
1742 decision: mapPermissionDecision(event.properties.reply),1802 decision: mapPermissionDecision(event.properties.reply),
1743 },1803 },
1744 });1804 });
...@@ -1746,6 +1806,7 @@ export function makeOpenCodeAdapter(...@@ -1746,6 +1806,7 @@ export function makeOpenCodeAdapter(
1746 }1806 }
17471807
1748 const request = context.pendingQuestions.get(requestId);1808 const request = context.pendingQuestions.get(requestId);
1809 context.pendingQuestions.delete(requestId);
1749 const answers =1810 const answers =
1750 event.type === "question.replied" && request1811 event.type === "question.replied" && request
1751 ? Object.fromEntries(1812 ? Object.fromEntries(
...@@ -1755,18 +1816,73 @@ export function makeOpenCodeAdapter(...@@ -1755,18 +1816,73 @@ export function makeOpenCodeAdapter(
1755 ]),1816 ]),
1756 )1817 )
1757 : {};1818 : {};
1758 yield* emit({1819 emitUnsafe({
1759 ...(yield* buildEventBase({1820 ...base,
1760 threadId: context.session.threadId,
1761 turnId: context.activeTurnId,
1762 requestId,
1763 raw: event,
1764 })),
1765 type: "user-input.resolved",1821 type: "user-input.resolved",
1766 payload: { answers },1822 payload: { answers },
1767 });1823 });
1768 });1824 });
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
1770 const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* (1886 const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* (
1771 context: OpenCodeSessionContext,1887 context: OpenCodeSessionContext,
1772 event: OpenCodeRoutedRequestEvent,1888 event: OpenCodeRoutedRequestEvent,
...@@ -1854,10 +1970,21 @@ export function makeOpenCodeAdapter(...@@ -1854,10 +1970,21 @@ export function makeOpenCodeAdapter(
1854 const run = Effect.gen(function* () {1970 const run = Effect.gen(function* () {
1855 let retryCount = 0;1971 let retryCount = 0;
1856 while (context.pendingRequestRecovery === recovery) {1972 while (context.pendingRequestRecovery === recovery) {
1857 const responses = yield* Effect.all({1973 // Only requests pending before the snapshot can be closed by it.
1858 permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()),1974 const priorPermissions = [...context.pendingPermissions.values()];
1859 questions: runOpenCodeSdk("question.list", () => context.client.question.list()),1975 const priorQuestions = [...context.pendingQuestions.values()];
1860 }).pipe(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"),
1861 Effect.match({1988 Effect.match({
1862 onFailure: (cause) => ({ type: "failure" as const, cause }),1989 onFailure: (cause) => ({ type: "failure" as const, cause }),
1863 onSuccess: (value) => ({ type: "success" as const, value }),1990 onSuccess: (value) => ({ type: "success" as const, value }),
...@@ -1901,6 +2028,14 @@ export function makeOpenCodeAdapter(...@@ -1901,6 +2028,14 @@ export function makeOpenCodeAdapter(
1901 yield* Effect.sleep(`${delayMs} millis`);2028 yield* Effect.sleep(`${delayMs} millis`);
1902 continue;2029 continue;
1903 }2030 }
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 );
1904 yield* Effect.forEach(2039 yield* Effect.forEach(
1905 permissions,2040 permissions,
1906 (request) =>2041 (request) =>
...@@ -1970,6 +2105,9 @@ export function makeOpenCodeAdapter(...@@ -1970,6 +2105,9 @@ export function makeOpenCodeAdapter(
1970 yield* schedulePendingRequestRecovery(context);2105 yield* schedulePendingRequestRecovery(context);
1971 if (!isFirstConnection) {2106 if (!isFirstConnection) {
1972 yield* schedulePromptAdmissionRecovery(context, event);2107 yield* schedulePromptAdmissionRecovery(context, event);
2108 if (context.activeTurnId !== undefined && context.promptAdmission === undefined) {
2109 yield* scheduleIdleReconciliation(context, context.activeTurnId, event);
2110 }
1973 }2111 }
1974 return;2112 return;
1975 }2113 }
...@@ -2046,6 +2184,7 @@ export function makeOpenCodeAdapter(...@@ -2046,6 +2184,7 @@ export function makeOpenCodeAdapter(
2046 context.awaitingBusyAfterInterruption) &&2184 context.awaitingBusyAfterInterruption) &&
2047 (event.type === "message.part.delta" ||2185 (event.type === "message.part.delta" ||
2048 event.type === "message.part.updated" ||2186 event.type === "message.part.updated" ||
2187 event.type === "todo.updated" ||
2049 (event.type === "message.updated" && event.properties.info.role === "assistant"));2188 (event.type === "message.updated" && event.properties.info.role === "assistant"));
2050 if (suppressInterruptedParentOutput) {2189 if (suppressInterruptedParentOutput) {
2051 return;2190 return;
...@@ -2125,7 +2264,11 @@ export function makeOpenCodeAdapter(...@@ -2125,7 +2264,11 @@ export function makeOpenCodeAdapter(
21252264
2126 case "message.part.delta": {2265 case "message.part.delta": {
2127 const existingPart = context.partById.get(event.properties.partID);2266 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 ) {
2129 break;2272 break;
2130 }2273 }
2131 const role = messageRoleForPart(context, existingPart);2274 const role = messageRoleForPart(context, existingPart);
...@@ -2180,7 +2323,9 @@ export function makeOpenCodeAdapter(...@@ -2180,7 +2323,9 @@ export function makeOpenCodeAdapter(
2180 if (part.type === "tool") {2323 if (part.type === "tool") {
2181 const itemType = toToolLifecycleItemType(part.tool);2324 const itemType = toToolLifecycleItemType(part.tool);
2182 const title =2325 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;
2184 const detail = detailFromToolPart(part);2329 const detail = detailFromToolPart(part);
2185 const payload = {2330 const payload = {
2186 itemType,2331 itemType,
...@@ -2194,6 +2339,14 @@ export function makeOpenCodeAdapter(...@@ -2194,6 +2339,14 @@ export function makeOpenCodeAdapter(
2194 data: {2339 data: {
2195 tool: part.tool,2340 tool: part.tool,
2196 state: part.state,2341 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 : {}),
2197 },2350 },
2198 };2351 };
2199 const runtimeEvent: ProviderRuntimeEvent = {2352 const runtimeEvent: ProviderRuntimeEvent = {
...@@ -2224,7 +2377,6 @@ export function makeOpenCodeAdapter(...@@ -2224,7 +2377,6 @@ export function makeOpenCodeAdapter(
2224 }2377 }
22252378
2226 case "permission.replied": {2379 case "permission.replied": {
2227 context.pendingPermissions.delete(event.properties.requestID);
2228 yield* emitTerminalOpenCodeRequest(context, event);2380 yield* emitTerminalOpenCodeRequest(context, event);
2229 break;2381 break;
2230 }2382 }
...@@ -2236,18 +2388,45 @@ export function makeOpenCodeAdapter(...@@ -2236,18 +2388,45 @@ export function makeOpenCodeAdapter(
22362388
2237 case "question.replied": {2389 case "question.replied": {
2238 yield* emitTerminalOpenCodeRequest(context, event);2390 yield* emitTerminalOpenCodeRequest(context, event);
2239 context.pendingQuestions.delete(event.properties.requestID);
2240 break;2391 break;
2241 }2392 }
22422393
2243 case "question.rejected": {2394 case "question.rejected": {
2244 context.pendingQuestions.delete(event.properties.requestID);
2245 yield* emitTerminalOpenCodeRequest(context, event);2395 yield* emitTerminalOpenCodeRequest(context, event);
2246 break;2396 break;
2247 }2397 }
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
2249 case "session.status": {2428 case "session.status": {
2250 if (event.properties.status.type === "busy") {2429 if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
2251 if (turnId === undefined) {2430 if (turnId === undefined) {
2252 break;2431 break;
2253 }2432 }
...@@ -2272,7 +2451,7 @@ export function makeOpenCodeAdapter(...@@ -2272,7 +2451,7 @@ export function makeOpenCodeAdapter(
2272 })),2451 })),
2273 type: "runtime.warning",2452 type: "runtime.warning",
2274 payload: {2453 payload: {
2275 message: event.properties.status.message,2454 message: `OpenCode retry ${event.properties.status.attempt}: ${event.properties.status.message}`,
2276 detail: event.properties.status,2455 detail: event.properties.status,
2277 },2456 },
2278 });2457 });
...@@ -2335,6 +2514,7 @@ export function makeOpenCodeAdapter(...@@ -2335,6 +2514,7 @@ export function makeOpenCodeAdapter(
2335 context.activeAgent = undefined;2514 context.activeAgent = undefined;
2336 context.activeVariant = undefined;2515 context.activeVariant = undefined;
2337 context.reconcileIdleStatus = false;2516 context.reconcileIdleStatus = false;
2517 yield* schedulePendingRequestRecovery(context);
2338 yield* updateProviderSession(2518 yield* updateProviderSession(
2339 context,2519 context,
2340 {2520 {
...@@ -2388,9 +2568,29 @@ export function makeOpenCodeAdapter(...@@ -2388,9 +2568,29 @@ export function makeOpenCodeAdapter(
2388 // shutdown) and cancels the in-flight `event.subscribe` fetch so2568 // shutdown) and cancels the in-flight `event.subscribe` fetch so
2389 // the async iterable unwinds cleanly.2569 // the async iterable unwinds cleanly.
2390 const eventsAbortController = new AbortController();2570 const eventsAbortController = new AbortController();
2391 yield* Scope.addFinalizer(2571 let lastStreamError: unknown;
2392 context.sessionScope,2572 let warnedAboutDisconnect = false;
2393 Effect.sync(() => eventsAbortController.abort()),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),
2394 );2594 );
23952595
2396 // Fibers forked into `context.sessionScope` are interrupted2596 // Fibers forked into `context.sessionScope` are interrupted
...@@ -2399,6 +2599,10 @@ export function makeOpenCodeAdapter(...@@ -2399,6 +2599,10 @@ export function makeOpenCodeAdapter(
2399 runOpenCodeSdk("event.subscribe", () =>2599 runOpenCodeSdk("event.subscribe", () =>
2400 context.client.event.subscribe(undefined, {2600 context.client.event.subscribe(undefined, {
2401 signal: eventsAbortController.signal,2601 signal: eventsAbortController.signal,
2602 onSseError: (cause) => {
2603 lastStreamError = cause;
2604 Queue.offerUnsafe(streamErrors, cause);
2605 },
2402 }),2606 }),
2403 ),2607 ),
2404 (subscription) =>2608 (subscription) =>
...@@ -2410,7 +2614,13 @@ export function makeOpenCodeAdapter(...@@ -2410,7 +2614,13 @@ export function makeOpenCodeAdapter(
2410 detail: openCodeRuntimeErrorDetail(cause),2614 detail: openCodeRuntimeErrorDetail(cause),
2411 cause,2615 cause,
2412 }),2616 }),
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 ),
2414 ).pipe(2624 ).pipe(
2415 Effect.exit,2625 Effect.exit,
2416 Effect.flatMap((exit) =>2626 Effect.flatMap((exit) =>
...@@ -2420,12 +2630,14 @@ export function makeOpenCodeAdapter(...@@ -2420,12 +2630,14 @@ export function makeOpenCodeAdapter(
2420 if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) {2630 if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) {
2421 return;2631 return;
2422 }2632 }
2423 if (Exit.isFailure(exit)) {2633 yield* emitUnexpectedExit(
2424 yield* emitUnexpectedExit(2634 context,
2425 context,2635 Exit.isFailure(exit)
2426 openCodeRuntimeErrorDetail(Cause.squash(exit.cause)),2636 ? openCodeRuntimeErrorDetail(Cause.squash(exit.cause))
2427 );2637 : lastStreamError !== undefined
2428 }2638 ? `OpenCode event stream disconnected: ${openCodeRuntimeErrorDetail(lastStreamError)}`
2639 : "OpenCode event stream ended unexpectedly. Send another message to reconnect.",
2640 );
2429 }),2641 }),
2430 ),2642 ),
2431 Effect.forkIn(context.sessionScope),2643 Effect.forkIn(context.sessionScope),
...@@ -2444,6 +2656,12 @@ export function makeOpenCodeAdapter(...@@ -2444,6 +2656,12 @@ export function makeOpenCodeAdapter(
2444 Effect.forkIn(context.sessionScope),2656 Effect.forkIn(context.sessionScope),
2445 );2657 );
2446 }2658 }
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 );
2447 });2665 });
24482666
2449 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(2667 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(
...@@ -3259,6 +3477,7 @@ export function makeOpenCodeAdapter(...@@ -3259,6 +3477,7 @@ export function makeOpenCodeAdapter(
3259 } else {3477 } else {
3260 context.cancellation = undefined;3478 context.cancellation = undefined;
3261 context.reconcileIdleStatus = true;3479 context.reconcileIdleStatus = true;
3480 yield* clearPendingOpenCodeRequests(context, { type: "session.abort" });
3262 }3481 }
3263 }3482 }
3264 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);3483 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
...@@ -3269,20 +3488,52 @@ export function makeOpenCodeAdapter(...@@ -3269,20 +3488,52 @@ export function makeOpenCodeAdapter(
3269 "respondToRequest",3488 "respondToRequest",
3270 )(function* (threadId, requestId, decision) {3489 )(function* (threadId, requestId, decision) {
3271 const context = yield* ensureSessionContext(sessions, threadId);3490 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;
3273 return yield* new ProviderAdapterRequestError({3494 return yield* new ProviderAdapterRequestError({
3274 provider: PROVIDER,3495 provider: PROVIDER,
3275 method: "permission.reply",3496 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}`,
3277 });3501 });
3278 }3502 }
32793503
3280 yield* runOpenCodeSdk("permission.reply", () =>3504 const reply = toOpenCodePermissionReply(decision);
3281 context.client.permission.reply({3505 yield* runOpenCodeSdk("permission.reply", (signal) =>
3282 requestID: requestId,3506 context.client.permission.reply(
3283 reply: toOpenCodePermissionReply(decision),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 ),
3284 }),3525 }),
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 );
3286 });3537 });
32873538
3288 const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(3539 const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(
...@@ -3291,19 +3542,54 @@ export function makeOpenCodeAdapter(...@@ -3291,19 +3542,54 @@ export function makeOpenCodeAdapter(
3291 const context = yield* ensureSessionContext(sessions, threadId);3542 const context = yield* ensureSessionContext(sessions, threadId);
3292 const request = context.pendingQuestions.get(requestId);3543 const request = context.pendingQuestions.get(requestId);
3293 if (!request) {3544 if (!request) {
3545 if (context.emittedTerminalRequestIds.has(requestId)) return;
3294 return yield* new ProviderAdapterRequestError({3546 return yield* new ProviderAdapterRequestError({
3295 provider: PROVIDER,3547 provider: PROVIDER,
3296 method: "question.reply",3548 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}`,
3298 });3553 });
3299 }3554 }
33003555
3301 yield* runOpenCodeSdk("question.reply", () =>3556 const questionAnswers = toOpenCodeQuestionAnswers(request, answers);
3302 context.client.question.reply({3557 yield* runOpenCodeSdk("question.reply", (signal) =>
3303 requestID: requestId,3558 context.client.question.reply(
3304 answers: toOpenCodeQuestionAnswers(request, answers),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 ),
3305 }),3577 }),
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 );
3307 });3593 });
33083594
3309 const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")(3595 const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")(
apps/server/src/provider/opencodeRuntime.environment.test.ts+87
...@@ -1,12 +1,24 @@...@@ -1,12 +1,24 @@
1import type { OpencodeClient } from "@opencode-ai/sdk/v2";1import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";3import { it as effectIt } from "@effect/vitest";
4import {
5 HostProcessEnvironment,
6 HostProcessExecutablePath,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
3import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
5import * as TestClock from "effect/testing/TestClock";14import * as TestClock from "effect/testing/TestClock";
15import { FetchHttpClient, HttpClient } from "effect/unstable/http";
6import { describe, expect, it } from "vite-plus/test";16import { describe, expect, it } from "vite-plus/test";
717
8import {18import {
19 OpenCodeRuntime,
9 OpenCodeRuntimeError,20 OpenCodeRuntimeError,
21 OpenCodeRuntimeLive,
10 resolveOpenCodeConfigContent,22 resolveOpenCodeConfigContent,
11 resolveOpenCodeServerPassword,23 resolveOpenCodeServerPassword,
12 verifyOpenCodeServerVersion,24 verifyOpenCodeServerVersion,
...@@ -150,3 +162,78 @@ describe("verifyOpenCodeServerVersion", () => {...@@ -150,3 +162,78 @@ describe("verifyOpenCodeServerVersion", () => {
150 }).pipe(Effect.provide(TestClock.layer())),162 }).pipe(Effect.provide(TestClock.layer())),
151 );163 );
152});164});
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 @@...@@ -1,12 +1,14 @@
1import * as NodeAssert from "node:assert/strict";1import * as NodeAssert from "node:assert/strict";
22
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import type { OpencodeClient } from "@opencode-ai/sdk/v2";4import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
5import { it } from "@effect/vitest";5import { it } from "@effect/vitest";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import * as Queue from "effect/Queue";
10import {12import {
11 HostProcessEnvironment,13 HostProcessEnvironment,
12 HostProcessExecutablePath,14 HostProcessExecutablePath,
...@@ -18,6 +20,44 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts";...@@ -18,6 +20,44 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts";
18const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));20const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
1921
20it.layer(testLayer)("OpenCodeRuntime inventory", (it) => {22it.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
21 it.effect("keeps provider inventory when agent discovery fails", () =>61 it.effect("keeps provider inventory when agent discovery fails", () =>
22 Effect.gen(function* () {62 Effect.gen(function* () {
23 const runtime = yield* OpenCodeRuntime;63 const runtime = yield* OpenCodeRuntime;
apps/server/src/provider/opencodeRuntime.permissions.test.ts+50-6
...@@ -1,15 +1,21 @@...@@ -1,15 +1,21 @@
1import * as NodeAssert from "node:assert/strict";1import * as NodeAssert from "node:assert/strict";
22
3import * as RegExpUtils from "effect/RegExp";
3import { describe, it } from "vite-plus/test";4import { describe, it } from "vite-plus/test";
45
5import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts";6import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts";
67
7function actionFor(8function actionFor(
8 runtimeMode: Parameters<typeof buildOpenCodePermissionRules>[0],9 runtimeMode: Parameters<typeof buildOpenCodePermissionRules>[0],
9 permission: string,10 permission: string,
11 target = "*",
10) {12) {
11 return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission)13 // OpenCode uses the last matching rule. Its wildcards match directory separators.
12 ?.action;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;
13}19}
1420
15describe("buildOpenCodePermissionRules", () => {21describe("buildOpenCodePermissionRules", () => {
...@@ -27,12 +33,38 @@ describe("buildOpenCodePermissionRules", () => {...@@ -27,12 +33,38 @@ describe("buildOpenCodePermissionRules", () => {
27 NodeAssert.equal(actionFor("auto", "edit"), "ask");33 NodeAssert.equal(actionFor("auto", "edit"), "ask");
28 });34 });
2935
30 it("keeps asking for everything else in the auto modes", () => {36 it("allows workspace reads and task updates without asking in supervised modes", () => {
31 for (const runtimeMode of ["auto-accept-edits", "auto"] as const) {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) {
32 NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask");62 NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask");
33 NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask");63 NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask");
64 NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask");
34 NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask");65 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");
36 }68 }
37 });69 });
3870
...@@ -43,3 +75,15 @@ describe("buildOpenCodePermissionRules", () => {...@@ -43,3 +75,15 @@ describe("buildOpenCodePermissionRules", () => {
43 ]);75 ]);
44 });76 });
45});77});
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(...@@ -80,6 +80,7 @@ export function resolveOpenCodeServerPassword(
80const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";80const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
81const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;81const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
82const DEFAULT_HOSTNAME = "127.0.0.1";82const DEFAULT_HOSTNAME = "127.0.0.1";
83const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;
83const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;84const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
84export interface OpenCodeServerProcess {85export interface OpenCodeServerProcess {
85 readonly url: string;86 readonly url: string;
...@@ -494,8 +495,19 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi...@@ -494,8 +495,19 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi
494 // reviewer, OpenCode among them, fall back to Supervised for that mode.495 // reviewer, OpenCode among them, fall back to Supervised for that mode.
495 const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask";496 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.
497 return [500 return [
498 { permission: "*", pattern: "*", action: "ask" },501 { 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" },
499 { permission: "bash", pattern: "*", action: "ask" },511 { permission: "bash", pattern: "*", action: "ask" },
500 { permission: "edit", pattern: "*", action: editAction },512 { permission: "edit", pattern: "*", action: editAction },
501 { permission: "webfetch", pattern: "*", action: "ask" },513 { permission: "webfetch", pattern: "*", action: "ask" },
...@@ -514,6 +526,7 @@ export function toOpenCodePermissionReply(...@@ -514,6 +526,7 @@ export function toOpenCodePermissionReply(
514 case "accept":526 case "accept":
515 return "once";527 return "once";
516 case "acceptForSession":528 case "acceptForSession":
529 case "acceptAlways":
517 return "always";530 return "always";
518 case "decline":531 case "decline":
519 case "cancel":532 case "cancel":
...@@ -707,18 +720,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -707,18 +720,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
707 );720 );
708 yield* Scope.addFinalizer(runtimeScope, terminateChild);721 yield* Scope.addFinalizer(runtimeScope, terminateChild);
709722
710 const stdoutRef = yield* Ref.make("");723 const stdoutRef = yield* Ref.make<string | null>("");
711 const stderrRef = yield* Ref.make("");724 const stderrRef = yield* Ref.make<string | null>("");
712 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();725 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();
713726
714 const setReadyFromStdoutChunk = (chunk: string) =>727 const setReadyFromStdoutChunk = (chunk: string) =>
715 Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe(728 Ref.modify(stdoutRef, (stdout) => {
716 Effect.flatMap((nextStdout) => {729 if (stdout === null) {
717 const parsed = parseServerUrlFromOutput(nextStdout);730 return [null, null] as const;
718 return parsed731 }
719 ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore)732 const nextStdout = `${stdout}${chunk}`;
720 : Effect.void;733 return [
721 }),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 ),
722 );741 );
723742
724 const stdoutFiber = yield* child.stdout.pipe(743 const stdoutFiber = yield* child.stdout.pipe(
...@@ -729,7 +748,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -729,7 +748,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
729 );748 );
730 const stderrFiber = yield* child.stderr.pipe(749 const stderrFiber = yield* child.stderr.pipe(
731 Stream.decodeText(),750 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 ),
733 Effect.ignore,758 Effect.ignore,
734 Effect.forkIn(runtimeScope),759 Effect.forkIn(runtimeScope),
735 );760 );
...@@ -737,8 +762,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -737,8 +762,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
737 const exitFiber = yield* child.exitCode.pipe(762 const exitFiber = yield* child.exitCode.pipe(
738 Effect.flatMap((code) =>763 Effect.flatMap((code) =>
739 Effect.gen(function* () {764 Effect.gen(function* () {
740 const stdout = yield* Ref.get(stdoutRef);765 const stdout = (yield* Ref.get(stdoutRef)) ?? "";
741 const stderr = yield* Ref.get(stderrRef);766 const stderr = (yield* Ref.get(stderrRef)) ?? "";
742 const exitCode = Number(code);767 const exitCode = Number(code);
743 yield* Deferred.fail(768 yield* Deferred.fail(
744 readyDeferred,769 readyDeferred,
...@@ -764,14 +789,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -764,14 +789,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
764 Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),789 Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),
765 );790 );
766791
767 // Startup-time fibers are no longer needed once ready has resolved (either792 if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) {
768 // way). The exit fiber is only interrupted on failure; on success it keeps793 yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore);
769 // the caller's `exitCode` effect observable until the scope closes.794 }
770 yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore);
771 yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore);
772795
773 if (Exit.isFailure(readyExit)) {796 if (Exit.isFailure(readyExit)) {
774 yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
775 const squashed = Cause.squash(readyExit.cause);797 const squashed = Cause.squash(readyExit.cause);
776 return yield* ensureRuntimeError(798 return yield* ensureRuntimeError(
777 "startOpenCodeServerProcess",799 "startOpenCodeServerProcess",
...@@ -782,13 +804,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -782,13 +804,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
782804
783 const readyOption = readyExit.value;805 const readyOption = readyExit.value;
784 if (Option.isNone(readyOption)) {806 if (Option.isNone(readyOption)) {
785 yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
786 return yield* new OpenCodeRuntimeError({807 return yield* new OpenCodeRuntimeError({
787 operation: "startOpenCodeServerProcess",808 operation: "startOpenCodeServerProcess",
788 detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`,809 detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`,
789 });810 });
790 }811 }
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
792 const url = readyOption.value;819 const url = readyOption.value;
793 const version = yield* verifyOpenCodeServerVersion(820 const version = yield* verifyOpenCodeServerVersion(
794 createOpenCodeSdkClient({821 createOpenCodeSdkClient({
...@@ -854,7 +881,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -854,7 +881,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
854 };881 };
855882
856 const loadProviders = (client: OpencodeClient) =>883 const loadProviders = (client: OpencodeClient) =>
857 runOpenCodeSdk("provider.list", () => client.provider.list()).pipe(884 runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe(
858 Effect.filterMapOrFail(885 Effect.filterMapOrFail(
859 (list) =>886 (list) =>
860 list.data887 list.data
...@@ -870,7 +897,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -870,7 +897,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
870 );897 );
871898
872 const loadAgents = (client: OpencodeClient) =>899 const loadAgents = (client: OpencodeClient) =>
873 runOpenCodeSdk("app.agents", () => client.app.agents()).pipe(900 runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe(
874 Effect.map((result) => result.data ?? []),901 Effect.map((result) => result.data ?? []),
875 Effect.orElseSucceed((): ReadonlyArray<Agent> => []),902 Effect.orElseSucceed((): ReadonlyArray<Agent> => []),
876 );903 );
apps/web/src/session-logic.test.ts+33-2
...@@ -62,6 +62,37 @@ function makeActivity(overrides: {...@@ -62,6 +62,37 @@ function makeActivity(overrides: {
62}62}
6363
64describe("derivePendingApprovals", () => {64describe("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
65 it("tracks open approvals and removes resolved ones", () => {96 it("tracks open approvals and removes resolved ones", () => {
66 const activities: OrchestrationThreadActivity[] = [97 const activities: OrchestrationThreadActivity[] = [
67 makeActivity({98 makeActivity({
...@@ -90,7 +121,7 @@ describe("derivePendingApprovals", () => {...@@ -90,7 +121,7 @@ describe("derivePendingApprovals", () => {
90 kind: "approval.requested",121 kind: "approval.requested",
91 summary: "File-change approval requested",122 summary: "File-change approval requested",
92 tone: "approval",123 tone: "approval",
93 payload: { requestId: "req-2", requestKind: "file-change" },124 payload: { requestId: "req-2", requestType: "unknown" },
94 }),125 }),
95 ];126 ];
96127
...@@ -199,7 +230,7 @@ describe("derivePendingApprovals", () => {...@@ -199,7 +230,7 @@ describe("derivePendingApprovals", () => {
199 tone: "approval",230 tone: "approval",
200 payload: {231 payload: {
201 requestId: "req-stale-1",232 requestId: "req-stale-1",
202 requestKind: "command",233 requestType: "unknown",
203 },234 },
204 }),235 }),
205 makeActivity({236 makeActivity({
apps/web/src/session-logic.ts+8-2
...@@ -464,10 +464,16 @@ export function derivePendingApprovals(...@@ -464,10 +464,16 @@ export function derivePendingApprovals(
464 ? payload.options.filter(isProviderApprovalOption)464 ? payload.options.filter(isProviderApprovalOption)
465 : undefined;465 : 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 ) {
468 openByRequestId.set(requestId, {473 openByRequestId.set(requestId, {
469 requestId,474 requestId,
470 requestKind,475 // Older OpenCode requests can have no recognized approval kind.
476 requestKind: requestKind ?? "command",
471 createdAt: activity.createdAt,477 createdAt: activity.createdAt,
472 ...(detail ? { detail } : {}),478 ...(detail ? { detail } : {}),
473 ...(appName ? { appName } : {}),479 ...(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...@@ -17,14 +17,45 @@ With a server URL, T3 Code connects to that external server and uses only the pa
17provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.17provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.
18OpenCode uses this password for HTTP Basic authentication.18OpenCode 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
20## Stop a turn49## Stop a turn
2150
22When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions.51When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions.
23T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It52T3 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 an56Stop reports an error if OpenCode cannot stop the main session or list or stop a child session.
27OpenCode session, it also tries to stop the child sessions, but this teardown is best effort.57When T3 Code closes an OpenCode session, it also tries to stop the child sessions, but this
58teardown is best effort.
2859
29## Refresh the model list60## Refresh the model list
3061
packages/client-runtime/src/work-log/presentation.test.ts+34
...@@ -15,6 +15,40 @@ import {...@@ -15,6 +15,40 @@ import {
15} from "./presentation.js";15} from "./presentation.js";
1616
17describe("summarizeToolGroup", () => {17describe("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
18 it("deduplicates named sources ahead of ordinary actions", () => {52 it("deduplicates named sources ahead of ordinary actions", () => {
19 const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const };53 const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const };
20 expect(54 expect(
packages/client-runtime/src/work-log/presentation.ts+7
...@@ -313,6 +313,13 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry):...@@ -313,6 +313,13 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry):
313}313}
314314
315export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {315export 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 }
316 if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser";323 if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser";
317 if (324 if (
318 entry.requestKind === "file-read" ||325 entry.requestKind === "file-read" ||