| author | |
| committer | |
| log | 01f3e50eca5102ccd881de6f942a98fe6a518ad4 |
| tree | ac5ae9ace2bc0f97029793d703ccf19bc977747b |
| parent | f0347322441f3b8e473a8d13ea7006cbcb4fb761 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 263 | }); |
| 264 | 264 | |
| 265 | 265 | describe("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 | 328 | it("keeps app access approvals and persistence choices from remote environments", () => { |
| 267 | 329 | const options = [ |
| 268 | 330 | { decision: "decline", label: "Decline" }, |
apps/mobile/src/lib/threadActivity.ts+8-2| ... | ... | @@ -1754,10 +1754,16 @@ export function derivePendingApprovals( |
| 1754 | 1754 | ? payload.options.filter(isProviderApprovalOption) |
| 1755 | 1755 | : undefined; |
| 1756 | 1756 | |
| 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 | 1763 | openByRequestId.set(requestId, { |
| 1759 | 1764 | requestId, |
| 1760 | requestKind, | |
| 1765 | // Older OpenCode requests can have no recognized approval kind. | |
| 1766 | requestKind: requestKind ?? "command", | |
| 1761 | 1767 | createdAt: activity.createdAt, |
| 1762 | 1768 | ...(detail ? { detail } : {}), |
| 1763 | 1769 | ...(appName ? { appName } : {}), |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+81-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | ApprovalRequestId, | |
| 2 | 3 | CheckpointRef, |
| 3 | 4 | CommandId, |
| 4 | 5 | CorrelationId, |
| ... | ... | @@ -2744,7 +2745,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2744 | 2745 | }), |
| 2745 | 2746 | ); |
| 2746 | 2747 | |
| 2747 | it.effect("ignores non-stale provider approval response failures", () => | |
| 2748 | it.effect("restores pending approvals when a provider reply fails", () => | |
| 2748 | 2749 | Effect.gen(function* () { |
| 2749 | 2750 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 2750 | 2751 | const eventStore = yield* OrchestrationEventStore; |
| ... | ... | @@ -2829,6 +2830,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2829 | 2830 | }, |
| 2830 | 2831 | }); |
| 2831 | 2832 | |
| 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 | 2851 | yield* appendAndProject({ |
| 2833 | 2852 | type: "thread.activity-appended", |
| 2834 | 2853 | eventId: EventId.make("evt-nonstale-approval-4"), |
| ... | ... | @@ -2921,6 +2940,67 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2921 | 2940 | WHERE thread_id = 'thread-nonstale-approval' |
| 2922 | 2941 | `; |
| 2923 | 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 | ); |
| 2926 | 3006 |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+38| ... | ... | @@ -1728,6 +1728,44 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1728 | 1728 | }); |
| 1729 | 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 | 1769 | return; |
| 1732 | 1770 | } |
| 1733 | 1771 | // Only approval-requested activities should create pending-approval |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+190| ... | ... | @@ -371,6 +371,196 @@ describe("ProviderRuntimeIngestion", () => { |
| 371 | 371 | expect(thread.session?.lastError).toBe("turn failed"); |
| 372 | 372 | }); |
| 373 | 373 | |
| 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 | 564 | it("applies provider session.state.changed transitions directly", async () => { |
| 375 | 565 | const harness = await createHarness(); |
| 376 | 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 | 1578 | const now = event.createdAt; |
| 1579 | 1579 | const eventTurnId = toTurnId(event.turnId); |
| 1580 | 1580 | const activeTurnId = thread.session?.activeTurnId ?? null; |
| 1581 | const isTerminalTurn = event.type === "turn.completed" || event.type === "turn.aborted"; | |
| 1581 | 1582 | const isCompactedThreadState = |
| 1582 | 1583 | event.type === "thread.state.changed" && event.payload.state === "compacted"; |
| 1583 | 1584 | const pendingTurnStart = |
| ... | ... | @@ -1586,7 +1587,7 @@ const make = Effect.gen(function* () { |
| 1586 | 1587 | event.type === "session.exited" || |
| 1587 | 1588 | event.type === "thread.started" || |
| 1588 | 1589 | event.type === "turn.started" || |
| 1589 | event.type === "turn.completed" || | |
| 1590 | isTerminalTurn || | |
| 1590 | 1591 | isCompactedThreadState |
| 1591 | 1592 | ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ |
| 1592 | 1593 | threadId: thread.id, |
| ... | ... | @@ -1624,6 +1625,7 @@ const make = Effect.gen(function* () { |
| 1624 | 1625 | case "turn.started": |
| 1625 | 1626 | return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; |
| 1626 | 1627 | case "turn.completed": |
| 1628 | case "turn.aborted": | |
| 1627 | 1629 | if (conflictsWithActiveTurn || missingTurnForActiveTurn) { |
| 1628 | 1630 | return false; |
| 1629 | 1631 | } |
| ... | ... | @@ -1631,14 +1633,10 @@ const make = Effect.gen(function* () { |
| 1631 | 1633 | if (activeTurnId !== null && eventTurnId !== undefined) { |
| 1632 | 1634 | return sameId(activeTurnId, eventTurnId); |
| 1633 | 1635 | } |
| 1634 | // No active turn tracked: accept only completions that name their | |
| 1635 | // turn (covers a real completion whose turn.started was lost). An | |
| 1636 | // untargeted completion cannot prove it belongs to any turn this | |
| 1637 | // thread ran — the known emitter was the Claude resume handshake | |
| 1638 | // (system/init + result(num_turns: 0)), which is not a turn at | |
| 1639 | // all — and applying it here stomps the "starting" lifecycle | |
| 1640 | // state while a turn start is pending. | |
| 1641 | return eventTurnId !== undefined; | |
| 1636 | // A named completion can recover a lost turn.started event. | |
| 1637 | // An abort needs an active turn so a delayed stop cannot replace | |
| 1638 | // a ready session or clear a newer pending start. | |
| 1639 | return event.type === "turn.completed" && eventTurnId !== undefined; | |
| 1642 | 1640 | default: |
| 1643 | 1641 | return true; |
| 1644 | 1642 | } |
| ... | ... | @@ -1654,7 +1652,7 @@ const make = Effect.gen(function* () { |
| 1654 | 1652 | event.type === "session.exited" || |
| 1655 | 1653 | event.type === "thread.started" || |
| 1656 | 1654 | event.type === "turn.started" || |
| 1657 | event.type === "turn.completed" | |
| 1655 | isTerminalTurn | |
| 1658 | 1656 | ) { |
| 1659 | 1657 | const status = (() => { |
| 1660 | 1658 | switch (event.type) { |
| ... | ... | @@ -1666,6 +1664,8 @@ const make = Effect.gen(function* () { |
| 1666 | 1664 | return "running"; |
| 1667 | 1665 | case "session.exited": |
| 1668 | 1666 | return "stopped"; |
| 1667 | case "turn.aborted": | |
| 1668 | return "interrupted"; | |
| 1669 | 1669 | case "turn.completed": |
| 1670 | 1670 | return normalizeRuntimeTurnState(event.payload.state) === "failed" |
| 1671 | 1671 | ? "error" |
| ... | ... | @@ -1680,7 +1680,7 @@ const make = Effect.gen(function* () { |
| 1680 | 1680 | const nextActiveTurnId = |
| 1681 | 1681 | event.type === "turn.started" |
| 1682 | 1682 | ? (eventTurnId ?? null) |
| 1683 | : event.type === "turn.completed" || event.type === "session.exited" | |
| 1683 | : isTerminalTurn || event.type === "session.exited" | |
| 1684 | 1684 | ? null |
| 1685 | 1685 | : event.type === "session.state.changed" && |
| 1686 | 1686 | !sessionStatusAllowsActiveTurn( |
| ... | ... | @@ -1694,7 +1694,7 @@ const make = Effect.gen(function* () { |
| 1694 | 1694 | : event.type === "turn.completed" && |
| 1695 | 1695 | normalizeRuntimeTurnState(event.payload.state) === "failed" |
| 1696 | 1696 | ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") |
| 1697 | : status === "ready" | |
| 1697 | : status === "ready" || status === "interrupted" | |
| 1698 | 1698 | ? null |
| 1699 | 1699 | : (thread.session?.lastError ?? null); |
| 1700 | 1700 | |
| ... | ... | @@ -1922,7 +1922,7 @@ const make = Effect.gen(function* () { |
| 1922 | 1922 | }); |
| 1923 | 1923 | } |
| 1924 | 1924 | |
| 1925 | if (event.type === "turn.completed") { | |
| 1925 | if (isTerminalTurn) { | |
| 1926 | 1926 | const detailedThread = yield* getLoadedThreadDetail(); |
| 1927 | 1927 | const messages = detailedThread?.messages ?? []; |
| 1928 | 1928 | const proposedPlans = detailedThread?.proposedPlans ?? []; |
| ... | ... | @@ -2055,7 +2055,7 @@ const make = Effect.gen(function* () { |
| 2055 | 2055 | } else if (!conflictsWithActiveTurn) { |
| 2056 | 2056 | if (event.type === "turn.plan.updated") { |
| 2057 | 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 | 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 | 15 | import * as Stream from "effect/Stream"; |
| 16 | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 17 | 17 | import { beforeEach } from "vite-plus/test"; |
| 18 | import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; | |
| 18 | import type { | |
| 19 | Event as OpenCodeEvent, | |
| 20 | PermissionRequest, | |
| 21 | QuestionRequest, | |
| 22 | } from "@opencode-ai/sdk/v2"; | |
| 19 | 23 | |
| 20 | 24 | import { |
| 21 | 25 | ApprovalRequestId, |
| ... | ... | @@ -85,23 +89,30 @@ const runtimeMock = { |
| 85 | 89 | promptAsyncImplementation: null as (() => Promise<void>) | null, |
| 86 | 90 | autoPromptEcho: true, |
| 87 | 91 | autoConnect: true, |
| 92 | endEventStream: false, | |
| 88 | 93 | promptEchoEvents: [] as Array<unknown>, |
| 89 | 94 | closeError: null as Error | null, |
| 90 | 95 | messages: [] as MessageEntry[], |
| 91 | 96 | subscribedEvents: [] as Array<unknown | Promise<unknown>>, |
| 92 | 97 | eventSubscribeObserved: null as (() => void) | null, |
| 98 | eventStreamError: null as ((cause: unknown) => void) | null, | |
| 93 | 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 | 102 | questionReplyCalls: [] as Array<{ |
| 96 | 103 | requestID: string; |
| 97 | 104 | answers: ReadonlyArray<ReadonlyArray<string>>; |
| 98 | 105 | }>, |
| 106 | questionReplyImplementation: null as ((signal?: AbortSignal) => Promise<void>) | null, | |
| 99 | 107 | sessionStatus: "idle" as "idle" | "busy", |
| 100 | 108 | sessionStatusFailures: 0, |
| 101 | 109 | sessionStatusCalls: 0, |
| 102 | 110 | sessionStatusImplementation: null as (() => Promise<unknown>) | null, |
| 103 | 111 | sessionGetIds: [] as string[], |
| 104 | 112 | sessionGetObserved: null as ((sessionID: string) => void) | null, |
| 113 | sessionGetImplementation: null as | |
| 114 | | ((sessionID: string, signal?: AbortSignal) => Promise<void>) | |
| 115 | | null, | |
| 105 | 116 | missingSessionIds: new Set<string>(), |
| 106 | 117 | transientErrorSessionIds: new Set<string>(), |
| 107 | 118 | sessionDirectoryById: new Map<string, string>(), |
| ... | ... | @@ -137,20 +148,25 @@ const runtimeMock = { |
| 137 | 148 | this.state.promptAsyncImplementation = null; |
| 138 | 149 | this.state.autoPromptEcho = true; |
| 139 | 150 | this.state.autoConnect = true; |
| 151 | this.state.endEventStream = false; | |
| 140 | 152 | this.state.promptEchoEvents.length = 0; |
| 141 | 153 | this.state.closeError = null; |
| 142 | 154 | this.state.messages = []; |
| 143 | 155 | this.state.subscribedEvents = []; |
| 144 | 156 | this.state.eventSubscribeObserved = null; |
| 157 | this.state.eventStreamError = null; | |
| 145 | 158 | this.state.permissionReplyCalls.length = 0; |
| 146 | 159 | this.state.permissionReplyImplementation = null; |
| 160 | this.state.permissionReplySignals.length = 0; | |
| 147 | 161 | this.state.questionReplyCalls.length = 0; |
| 162 | this.state.questionReplyImplementation = null; | |
| 148 | 163 | this.state.sessionStatus = "idle"; |
| 149 | 164 | this.state.sessionStatusFailures = 0; |
| 150 | 165 | this.state.sessionStatusCalls = 0; |
| 151 | 166 | this.state.sessionStatusImplementation = null; |
| 152 | 167 | this.state.sessionGetIds.length = 0; |
| 153 | 168 | this.state.sessionGetObserved = null; |
| 169 | this.state.sessionGetImplementation = null; | |
| 154 | 170 | this.state.missingSessionIds.clear(); |
| 155 | 171 | this.state.transientErrorSessionIds.clear(); |
| 156 | 172 | this.state.sessionDirectoryById.clear(); |
| ... | ... | @@ -222,9 +238,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 222 | 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 | 242 | runtimeMock.state.sessionGetIds.push(sessionID); |
| 227 | 243 | runtimeMock.state.sessionGetObserved?.(sessionID); |
| 244 | if (runtimeMock.state.sessionGetImplementation) { | |
| 245 | await runtimeMock.state.sessionGetImplementation(sessionID, options?.signal); | |
| 246 | } | |
| 228 | 247 | // The real client is `throwOnError: true`: non-2xx rejects rather |
| 229 | 248 | // than resolving, so missing → 404 throw, transient → 500 throw. |
| 230 | 249 | if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { |
| ... | ... | @@ -264,6 +283,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 264 | 283 | runtimeMock.state.abortSignals.push(options.signal); |
| 265 | 284 | } |
| 266 | 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 | 293 | children: async ({ sessionID }: { sessionID: string }) => { |
| 269 | 294 | runtimeMock.state.sessionChildrenCalls.push(sessionID); |
| ... | ... | @@ -357,19 +382,60 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 357 | 382 | }, |
| 358 | 383 | }, |
| 359 | 384 | event: { |
| 360 | subscribe: async () => { | |
| 385 | subscribe: async ( | |
| 386 | _input: unknown, | |
| 387 | options?: { signal?: AbortSignal; onSseError?: (cause: unknown) => void }, | |
| 388 | ) => { | |
| 361 | 389 | runtimeMock.state.eventSubscribeObserved?.(); |
| 390 | runtimeMock.state.eventStreamError = options?.onSseError ?? null; | |
| 362 | 391 | return { |
| 363 | 392 | stream: (async function* () { |
| 364 | if (runtimeMock.state.autoConnect) { | |
| 365 | yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; | |
| 366 | } | |
| 367 | for (const event of runtimeMock.state.subscribedEvents) { | |
| 368 | const resolved = await event; | |
| 369 | while (runtimeMock.state.promptEchoEvents.length > 0) { | |
| 370 | yield runtimeMock.state.promptEchoEvents.shift(); | |
| 393 | const aborted = promiseWithResolvers<void>(); | |
| 394 | const onAbort = () => aborted.resolve(undefined); | |
| 395 | options?.signal?.addEventListener("abort", onAbort, { once: true }); | |
| 396 | try { | |
| 397 | if (runtimeMock.state.autoConnect) { | |
| 398 | yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; | |
| 399 | } | |
| 400 | for (const event of runtimeMock.state.subscribedEvents) { | |
| 401 | if (options?.signal?.aborted) return; | |
| 402 | const resolved = await Promise.race([event, aborted.promise]); | |
| 403 | if (options?.signal?.aborted) return; | |
| 404 | while (runtimeMock.state.promptEchoEvents.length > 0) { | |
| 405 | yield runtimeMock.state.promptEchoEvents.shift(); | |
| 406 | } | |
| 407 | const nativeEvent = resolved as OpenCodeEvent; | |
| 408 | if (nativeEvent.type === "permission.asked") { | |
| 409 | runtimeMock.state.pendingPermissions = | |
| 410 | runtimeMock.state.pendingPermissions.filter( | |
| 411 | (request) => request.id !== nativeEvent.properties.id, | |
| 412 | ); | |
| 413 | runtimeMock.state.pendingPermissions.push(nativeEvent.properties); | |
| 414 | } else if (nativeEvent.type === "permission.replied") { | |
| 415 | runtimeMock.state.pendingPermissions = | |
| 416 | runtimeMock.state.pendingPermissions.filter( | |
| 417 | (request) => request.id !== nativeEvent.properties.requestID, | |
| 418 | ); | |
| 419 | } else if (nativeEvent.type === "question.asked") { | |
| 420 | runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( | |
| 421 | (request) => request.id !== nativeEvent.properties.id, | |
| 422 | ); | |
| 423 | runtimeMock.state.pendingQuestions.push(nativeEvent.properties); | |
| 424 | } else if ( | |
| 425 | nativeEvent.type === "question.replied" || | |
| 426 | nativeEvent.type === "question.rejected" | |
| 427 | ) { | |
| 428 | runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( | |
| 429 | (request) => request.id !== nativeEvent.properties.requestID, | |
| 430 | ); | |
| 431 | } | |
| 432 | yield resolved; | |
| 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 | 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 | 457 | runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); |
| 458 | if (options?.signal) runtimeMock.state.permissionReplySignals.push(options.signal); | |
| 389 | 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 | 467 | question: { |
| ... | ... | @@ -400,14 +473,21 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 400 | 473 | : runtimeMock.state.pendingQuestions, |
| 401 | 474 | }; |
| 402 | 475 | }, |
| 403 | reply: async ({ | |
| 404 | requestID, | |
| 405 | answers, | |
| 406 | }: { | |
| 407 | requestID: string; | |
| 408 | answers: ReadonlyArray<ReadonlyArray<string>>; | |
| 409 | }) => { | |
| 476 | reply: async ( | |
| 477 | { | |
| 478 | requestID, | |
| 479 | answers, | |
| 480 | }: { | |
| 481 | requestID: string; | |
| 482 | answers: ReadonlyArray<ReadonlyArray<string>>; | |
| 483 | }, | |
| 484 | options?: { signal?: AbortSignal }, | |
| 485 | ) => { | |
| 410 | 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 | 493 | }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, |
| ... | ... | @@ -2576,6 +2656,411 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 2576 | 2656 | }), |
| 2577 | 2657 | ); |
| 2578 | 2658 | |
| 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 | 3064 | it.effect("routes child-session approval requests and replies through the parent thread", () => |
| 2580 | 3065 | Effect.gen(function* () { |
| 2581 | 3066 | const adapter = yield* OpenCodeAdapter; |
| ... | ... | @@ -2691,6 +3176,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 2691 | 3176 | Effect.gen(function* () { |
| 2692 | 3177 | const adapter = yield* OpenCodeAdapter; |
| 2693 | 3178 | const threadId = asThreadId(`thread-full-access-${requestId}`); |
| 3179 | const replyStarted = promiseWithResolvers<void>(); | |
| 3180 | runtimeMock.state.permissionReplyImplementation = async () => | |
| 3181 | replyStarted.resolve(undefined); | |
| 2694 | 3182 | runtimeMock.state.subscribedEvents = [ |
| 2695 | 3183 | { |
| 2696 | 3184 | id: "evt-child-created", |
| ... | ... | @@ -2709,11 +3197,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 2709 | 3197 | type: "permission.asked", |
| 2710 | 3198 | properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, |
| 2711 | 3199 | }, |
| 2712 | { | |
| 3200 | replyStarted.promise.then(() => ({ | |
| 2713 | 3201 | id: "evt-permission-replied", |
| 2714 | 3202 | type: "permission.replied", |
| 2715 | 3203 | properties: { sessionID, requestID: requestId, reply: "once" }, |
| 2716 | }, | |
| 3204 | })), | |
| 2717 | 3205 | // The suppressed ask emits nothing, so an empty question serves as a |
| 2718 | 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 | 3488 | }), |
| 3001 | 3489 | ); |
| 3002 | 3490 | |
| 3003 | it.effect("retries ancestry for one live child request after a transient failure", () => | |
| 3004 | Effect.gen(function* () { | |
| 3005 | const adapter = yield* OpenCodeAdapter; | |
| 3006 | const threadId = asThreadId("thread-child-request-ancestry-retry"); | |
| 3007 | const parentId = "http://127.0.0.1:9999/session"; | |
| 3008 | const ancestryAttempted = promiseWithResolvers<void>(); | |
| 3009 | runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); | |
| 3010 | runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); | |
| 3011 | runtimeMock.state.sessionGetObserved = (sessionID) => { | |
| 3012 | if (sessionID === "ses_existing_child") { | |
| 3013 | ancestryAttempted.resolve(undefined); | |
| 3491 | it.effect.each(["failure", "timeout"] as const)( | |
| 3492 | "retries ancestry for a child request after a transient %s", | |
| 3493 | (lookupFailure) => | |
| 3494 | Effect.gen(function* () { | |
| 3495 | const adapter = yield* OpenCodeAdapter; | |
| 3496 | const threadId = asThreadId(`thread-child-request-ancestry-retry-${lookupFailure}`); | |
| 3497 | const parentId = "http://127.0.0.1:9999/session"; | |
| 3498 | const ancestryAttempted = promiseWithResolvers<void>(); | |
| 3499 | runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); | |
| 3500 | runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); | |
| 3501 | let lookupSignal: AbortSignal | undefined; | |
| 3502 | if (lookupFailure === "timeout") { | |
| 3503 | runtimeMock.state.sessionGetImplementation = async (_sessionID, signal) => { | |
| 3504 | lookupSignal = signal; | |
| 3505 | await new Promise<void>(() => {}); | |
| 3506 | }; | |
| 3014 | 3507 | } |
| 3015 | }; | |
| 3016 | runtimeMock.state.subscribedEvents = [ | |
| 3017 | { | |
| 3018 | id: "evt-existing-child-permission", | |
| 3019 | type: "permission.asked", | |
| 3020 | properties: permissionRequest("per_retry", "ses_existing_child"), | |
| 3021 | }, | |
| 3022 | ]; | |
| 3508 | runtimeMock.state.sessionGetObserved = (sessionID) => { | |
| 3509 | if (sessionID === "ses_existing_child") { | |
| 3510 | ancestryAttempted.resolve(undefined); | |
| 3511 | } | |
| 3512 | }; | |
| 3513 | runtimeMock.state.subscribedEvents = [ | |
| 3514 | { | |
| 3515 | id: "evt-existing-child-permission", | |
| 3516 | type: "permission.asked", | |
| 3517 | properties: permissionRequest("per_retry", "ses_existing_child"), | |
| 3518 | }, | |
| 3519 | ]; | |
| 3023 | 3520 | |
| 3024 | const eventsFiber = yield* adapter.streamEvents.pipe( | |
| 3025 | Stream.filter( | |
| 3026 | (event) => | |
| 3027 | event.threadId === threadId && | |
| 3028 | (event.type === "runtime.warning" || event.type === "request.opened"), | |
| 3029 | ), | |
| 3030 | Stream.take(2), | |
| 3031 | Stream.runCollect, | |
| 3032 | Effect.forkChild, | |
| 3033 | ); | |
| 3034 | yield* adapter.startSession({ | |
| 3035 | provider: ProviderDriverKind.make("opencode"), | |
| 3036 | threadId, | |
| 3037 | runtimeMode: "approval-required", | |
| 3038 | }); | |
| 3039 | yield* Effect.promise(() => ancestryAttempted.promise); | |
| 3040 | runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); | |
| 3041 | yield* advanceTestClock(250); | |
| 3521 | const eventsFiber = yield* adapter.streamEvents.pipe( | |
| 3522 | Stream.filter( | |
| 3523 | (event) => | |
| 3524 | event.threadId === threadId && | |
| 3525 | (event.type === "runtime.warning" || event.type === "request.opened"), | |
| 3526 | ), | |
| 3527 | Stream.take(2), | |
| 3528 | Stream.runCollect, | |
| 3529 | Effect.forkChild, | |
| 3530 | ); | |
| 3531 | yield* adapter.startSession({ | |
| 3532 | provider: ProviderDriverKind.make("opencode"), | |
| 3533 | threadId, | |
| 3534 | runtimeMode: "approval-required", | |
| 3535 | }); | |
| 3536 | yield* Effect.promise(() => ancestryAttempted.promise); | |
| 3537 | if (lookupFailure === "timeout") { | |
| 3538 | yield* Effect.yieldNow; | |
| 3539 | yield* advanceTestClock(10_000); | |
| 3540 | NodeAssert.equal(lookupSignal?.aborted, true); | |
| 3541 | runtimeMock.state.sessionGetImplementation = null; | |
| 3542 | } | |
| 3543 | runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); | |
| 3544 | yield* advanceTestClock(250); | |
| 3042 | 3545 | |
| 3043 | const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); | |
| 3044 | NodeAssert.deepEqual( | |
| 3045 | events.map((event) => event.type), | |
| 3046 | ["runtime.warning", "request.opened"], | |
| 3047 | ); | |
| 3048 | yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); | |
| 3049 | }), | |
| 3546 | const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); | |
| 3547 | NodeAssert.deepEqual( | |
| 3548 | events.map((event) => event.type), | |
| 3549 | ["runtime.warning", "request.opened"], | |
| 3550 | ); | |
| 3551 | yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); | |
| 3552 | }), | |
| 3050 | 3553 | ); |
| 3051 | 3554 | |
| 3052 | 3555 | it.effect("does not resurrect a recovered child request after its live reply", () => |
| ... | ... | @@ -3101,7 +3604,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 3101 | 3604 | const response = yield* Effect.exit( |
| 3102 | 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 | ); |
| 3107 | 3611 | |
| ... | ... | @@ -3153,7 +3657,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 3153 | 3657 | const response = yield* Effect.exit( |
| 3154 | 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 | ); |
| 3159 | 3664 | |
| ... | ... | @@ -5286,6 +5791,249 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 5286 | 5791 | }), |
| 5287 | 5792 | ); |
| 5288 | 5793 | |
| 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 | 6037 | it.effect("lets OpenCode own session title generation and emits title metadata updates", () => |
| 5290 | 6038 | Effect.gen(function* () { |
| 5291 | 6039 | const adapter = yield* OpenCodeAdapter; |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+383-97| ... | ... | @@ -417,6 +417,9 @@ type EventBaseInput = { |
| 417 | 417 | |
| 418 | 418 | function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { |
| 419 | 419 | const normalized = toolName.toLowerCase(); |
| 420 | if (normalized === "todowrite" || normalized === "todoread") { | |
| 421 | return "dynamic_tool_call"; | |
| 422 | } | |
| 420 | 423 | if (normalized.includes("bash") || normalized.includes("command")) { |
| 421 | 424 | return "command_execution"; |
| 422 | 425 | } |
| ... | ... | @@ -449,16 +452,15 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { |
| 449 | 452 | |
| 450 | 453 | function mapPermissionToRequestType( |
| 451 | 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 | 456 | switch (permission) { |
| 454 | case "bash": | |
| 455 | return "command_execution_approval"; | |
| 456 | 457 | case "read": |
| 457 | 458 | return "file_read_approval"; |
| 458 | 459 | case "edit": |
| 459 | 460 | return "file_change_approval"; |
| 460 | 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 | } |
| 464 | 466 | |
| ... | ... | @@ -1062,6 +1064,10 @@ export function makeOpenCodeAdapter( |
| 1062 | 1064 | context.interruptedTurnId = undefined; |
| 1063 | 1065 | context.awaitingBusyAfterInterruption = false; |
| 1064 | 1066 | context.reconcileIdleStatus = false; |
| 1067 | for (const requestId of context.autoRepliedRequestIds) { | |
| 1068 | context.emittedTerminalRequestIds.add(requestId); | |
| 1069 | } | |
| 1070 | context.autoRepliedRequestIds.clear(); | |
| 1065 | 1071 | applyProviderSessionUpdate( |
| 1066 | 1072 | context, |
| 1067 | 1073 | { status: "ready" }, |
| ... | ... | @@ -1071,6 +1077,7 @@ export function makeOpenCodeAdapter( |
| 1071 | 1077 | if (pendingIdleReconciliation?.fiber) { |
| 1072 | 1078 | yield* Fiber.interrupt(pendingIdleReconciliation.fiber); |
| 1073 | 1079 | } |
| 1080 | yield* schedulePendingRequestRecovery(context); | |
| 1074 | 1081 | yield* emit({ |
| 1075 | 1082 | ...(yield* buildEventBase({ |
| 1076 | 1083 | threadId: context.session.threadId, |
| ... | ... | @@ -1425,6 +1432,7 @@ export function makeOpenCodeAdapter( |
| 1425 | 1432 | { clearActiveTurnId: true, clearLastError: true }, |
| 1426 | 1433 | ); |
| 1427 | 1434 | } |
| 1435 | yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); | |
| 1428 | 1436 | yield* emit({ |
| 1429 | 1437 | ...(yield* buildEventBase({ |
| 1430 | 1438 | threadId: context.session.threadId, |
| ... | ... | @@ -1578,7 +1586,19 @@ export function makeOpenCodeAdapter( |
| 1578 | 1586 | |
| 1579 | 1587 | const seen = new Set<string>(); |
| 1580 | 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 | 1602 | Effect.catchIf( |
| 1583 | 1603 | (cause) => isOpenCodeNotFound(cause), |
| 1584 | 1604 | () => Effect.succeed(undefined), |
| ... | ... | @@ -1610,6 +1630,52 @@ export function makeOpenCodeAdapter( |
| 1610 | 1630 | return false; |
| 1611 | 1631 | }); |
| 1612 | 1632 | |
| 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 | 1679 | // Full access means the user already granted everything, but two upstream |
| 1614 | 1680 | // paths never consult the session ruleset we send: doom-loop detection |
| 1615 | 1681 | // (evaluated against the agent ruleset only) and subagent sessions (which |
| ... | ... | @@ -1622,15 +1688,12 @@ export function makeOpenCodeAdapter( |
| 1622 | 1688 | const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* ( |
| 1623 | 1689 | context: OpenCodeSessionContext, |
| 1624 | 1690 | request: PermissionRequest, |
| 1691 | raw: unknown, | |
| 1625 | 1692 | ) { |
| 1626 | // Mark before awaiting: retry and recovery fibers re-enter the ask path, | |
| 1627 | // and the matching `permission.replied` can arrive, while the SDK call | |
| 1628 | // is in flight. Marked ids skip the ask and swallow the terminal event. | |
| 1629 | context.resolvedRequestIds.add(request.id); | |
| 1630 | context.autoRepliedRequestIds.add(request.id); | |
| 1631 | const replied = yield* runOpenCodeSdk("permission.reply", () => | |
| 1632 | context.client.permission.reply({ requestID: request.id, reply: "once" }), | |
| 1693 | const replied = yield* runOpenCodeSdk("permission.reply", (signal) => | |
| 1694 | context.client.permission.reply({ requestID: request.id, reply: "once" }, { signal }), | |
| 1633 | 1695 | ).pipe( |
| 1696 | Effect.timeout("10 seconds"), | |
| 1634 | 1697 | Effect.as(true), |
| 1635 | 1698 | Effect.orElseSucceed(() => false), |
| 1636 | 1699 | ); |
| ... | ... | @@ -1638,9 +1701,8 @@ export function makeOpenCodeAdapter( |
| 1638 | 1701 | // Fall back to the dialog. The id stays resolved so a recovered copy |
| 1639 | 1702 | // of this ask cannot reopen after the user answers; |
| 1640 | 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 | }); |
| 1645 | 1707 | |
| 1646 | 1708 | const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( |
| ... | ... | @@ -1651,39 +1713,26 @@ export function makeOpenCodeAdapter( |
| 1651 | 1713 | if (context.resolvedRequestIds.has(event.properties.id)) { |
| 1652 | 1714 | return; |
| 1653 | 1715 | } |
| 1716 | if (context.activeTurnId === undefined && context.reconcileIdleStatus) { | |
| 1717 | context.resolvedRequestIds.add(event.properties.id); | |
| 1718 | return; | |
| 1719 | } | |
| 1654 | 1720 | if (event.type === "permission.asked") { |
| 1655 | 1721 | const request = event.properties; |
| 1656 | 1722 | if (context.pendingPermissions.has(request.id)) { |
| 1657 | 1723 | return; |
| 1658 | 1724 | } |
| 1659 | if ( | |
| 1660 | context.session.runtimeMode === "full-access" && | |
| 1661 | (yield* autoReplyFullAccess(context, request)) | |
| 1662 | ) { | |
| 1663 | return; | |
| 1664 | } | |
| 1665 | const base = yield* buildEventBase({ | |
| 1666 | threadId: context.session.threadId, | |
| 1667 | turnId: context.activeTurnId, | |
| 1668 | requestId: request.id, | |
| 1669 | raw, | |
| 1670 | }); | |
| 1671 | // No yield between this check and the publish: a terminal | |
| 1672 | // `permission.replied` delivered on the pump in between would leave a | |
| 1673 | // dialog that can never close. | |
| 1674 | if (context.emittedTerminalRequestIds.has(request.id)) { | |
| 1725 | if (context.session.runtimeMode === "full-access") { | |
| 1726 | // Reply outside the event pump so a slow HTTP response cannot hide | |
| 1727 | // progress, terminal replies, or the acknowledgment for Stop. | |
| 1728 | context.resolvedRequestIds.add(request.id); | |
| 1729 | context.autoRepliedRequestIds.add(request.id); | |
| 1730 | yield* autoReplyFullAccess(context, request, raw).pipe( | |
| 1731 | Effect.forkIn(context.sessionScope), | |
| 1732 | ); | |
| 1675 | 1733 | return; |
| 1676 | 1734 | } |
| 1677 | context.pendingPermissions.set(request.id, request); | |
| 1678 | emitUnsafe({ | |
| 1679 | ...base, | |
| 1680 | type: "request.opened", | |
| 1681 | payload: { | |
| 1682 | requestType: mapPermissionToRequestType(request.permission), | |
| 1683 | detail: request.patterns.length > 0 ? request.patterns.join("\n") : request.permission, | |
| 1684 | args: request.metadata, | |
| 1685 | }, | |
| 1686 | }); | |
| 1735 | yield* openPermissionRequest(context, request, raw); | |
| 1687 | 1736 | return; |
| 1688 | 1737 | } |
| 1689 | 1738 | |
| ... | ... | @@ -1691,14 +1740,19 @@ export function makeOpenCodeAdapter( |
| 1691 | 1740 | if (context.pendingQuestions.has(request.id)) { |
| 1692 | 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 | 1753 | context.pendingQuestions.set(request.id, request); |
| 1695 | yield* emit({ | |
| 1696 | ...(yield* buildEventBase({ | |
| 1697 | threadId: context.session.threadId, | |
| 1698 | turnId: context.activeTurnId, | |
| 1699 | requestId: request.id, | |
| 1700 | raw, | |
| 1701 | })), | |
| 1754 | emitUnsafe({ | |
| 1755 | ...base, | |
| 1702 | 1756 | type: "user-input.requested", |
| 1703 | 1757 | payload: { questions: normalizeQuestionRequest(request) }, |
| 1704 | 1758 | }); |
| ... | ... | @@ -1719,26 +1773,32 @@ export function makeOpenCodeAdapter( |
| 1719 | 1773 | const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* ( |
| 1720 | 1774 | context: OpenCodeSessionContext, |
| 1721 | 1775 | event: OpenCodeTerminalRequestEvent, |
| 1776 | raw: unknown = event, | |
| 1722 | 1777 | ) { |
| 1723 | 1778 | const requestId = event.properties.requestID; |
| 1724 | 1779 | if (context.emittedTerminalRequestIds.has(requestId)) { |
| 1725 | 1780 | return; |
| 1726 | 1781 | } |
| 1727 | context.emittedTerminalRequestIds.add(requestId); | |
| 1728 | 1782 | if (context.autoRepliedRequestIds.delete(requestId)) { |
| 1783 | context.emittedTerminalRequestIds.add(requestId); | |
| 1729 | 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 | 1794 | if (event.type === "permission.replied") { |
| 1732 | yield* emit({ | |
| 1733 | ...(yield* buildEventBase({ | |
| 1734 | threadId: context.session.threadId, | |
| 1735 | turnId: context.activeTurnId, | |
| 1736 | requestId, | |
| 1737 | raw: event, | |
| 1738 | })), | |
| 1795 | const request = context.pendingPermissions.get(requestId); | |
| 1796 | context.pendingPermissions.delete(requestId); | |
| 1797 | emitUnsafe({ | |
| 1798 | ...base, | |
| 1739 | 1799 | type: "request.resolved", |
| 1740 | 1800 | payload: { |
| 1741 | requestType: "unknown", | |
| 1801 | requestType: request ? mapPermissionToRequestType(request.permission) : "unknown", | |
| 1742 | 1802 | decision: mapPermissionDecision(event.properties.reply), |
| 1743 | 1803 | }, |
| 1744 | 1804 | }); |
| ... | ... | @@ -1746,6 +1806,7 @@ export function makeOpenCodeAdapter( |
| 1746 | 1806 | } |
| 1747 | 1807 | |
| 1748 | 1808 | const request = context.pendingQuestions.get(requestId); |
| 1809 | context.pendingQuestions.delete(requestId); | |
| 1749 | 1810 | const answers = |
| 1750 | 1811 | event.type === "question.replied" && request |
| 1751 | 1812 | ? Object.fromEntries( |
| ... | ... | @@ -1755,18 +1816,73 @@ export function makeOpenCodeAdapter( |
| 1755 | 1816 | ]), |
| 1756 | 1817 | ) |
| 1757 | 1818 | : {}; |
| 1758 | yield* emit({ | |
| 1759 | ...(yield* buildEventBase({ | |
| 1760 | threadId: context.session.threadId, | |
| 1761 | turnId: context.activeTurnId, | |
| 1762 | requestId, | |
| 1763 | raw: event, | |
| 1764 | })), | |
| 1819 | emitUnsafe({ | |
| 1820 | ...base, | |
| 1765 | 1821 | type: "user-input.resolved", |
| 1766 | 1822 | payload: { answers }, |
| 1767 | 1823 | }); |
| 1768 | 1824 | }); |
| 1769 | 1825 | |
| 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 | 1886 | const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* ( |
| 1771 | 1887 | context: OpenCodeSessionContext, |
| 1772 | 1888 | event: OpenCodeRoutedRequestEvent, |
| ... | ... | @@ -1854,10 +1970,21 @@ export function makeOpenCodeAdapter( |
| 1854 | 1970 | const run = Effect.gen(function* () { |
| 1855 | 1971 | let retryCount = 0; |
| 1856 | 1972 | while (context.pendingRequestRecovery === recovery) { |
| 1857 | const responses = yield* Effect.all({ | |
| 1858 | permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()), | |
| 1859 | questions: runOpenCodeSdk("question.list", () => context.client.question.list()), | |
| 1860 | }).pipe( | |
| 1973 | // Only requests pending before the snapshot can be closed by it. | |
| 1974 | const priorPermissions = [...context.pendingPermissions.values()]; | |
| 1975 | const priorQuestions = [...context.pendingQuestions.values()]; | |
| 1976 | const responses = yield* Effect.all( | |
| 1977 | { | |
| 1978 | permissions: runOpenCodeSdk("permission.list", (signal) => | |
| 1979 | context.client.permission.list(undefined, { signal }), | |
| 1980 | ), | |
| 1981 | questions: runOpenCodeSdk("question.list", (signal) => | |
| 1982 | context.client.question.list(undefined, { signal }), | |
| 1983 | ), | |
| 1984 | }, | |
| 1985 | { concurrency: 2 }, | |
| 1986 | ).pipe( | |
| 1987 | Effect.timeout("10 seconds"), | |
| 1861 | 1988 | Effect.match({ |
| 1862 | 1989 | onFailure: (cause) => ({ type: "failure" as const, cause }), |
| 1863 | 1990 | onSuccess: (value) => ({ type: "success" as const, value }), |
| ... | ... | @@ -1901,6 +2028,14 @@ export function makeOpenCodeAdapter( |
| 1901 | 2028 | yield* Effect.sleep(`${delayMs} millis`); |
| 1902 | 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 | 2039 | yield* Effect.forEach( |
| 1905 | 2040 | permissions, |
| 1906 | 2041 | (request) => |
| ... | ... | @@ -1970,6 +2105,9 @@ export function makeOpenCodeAdapter( |
| 1970 | 2105 | yield* schedulePendingRequestRecovery(context); |
| 1971 | 2106 | if (!isFirstConnection) { |
| 1972 | 2107 | yield* schedulePromptAdmissionRecovery(context, event); |
| 2108 | if (context.activeTurnId !== undefined && context.promptAdmission === undefined) { | |
| 2109 | yield* scheduleIdleReconciliation(context, context.activeTurnId, event); | |
| 2110 | } | |
| 1973 | 2111 | } |
| 1974 | 2112 | return; |
| 1975 | 2113 | } |
| ... | ... | @@ -2046,6 +2184,7 @@ export function makeOpenCodeAdapter( |
| 2046 | 2184 | context.awaitingBusyAfterInterruption) && |
| 2047 | 2185 | (event.type === "message.part.delta" || |
| 2048 | 2186 | event.type === "message.part.updated" || |
| 2187 | event.type === "todo.updated" || | |
| 2049 | 2188 | (event.type === "message.updated" && event.properties.info.role === "assistant")); |
| 2050 | 2189 | if (suppressInterruptedParentOutput) { |
| 2051 | 2190 | return; |
| ... | ... | @@ -2125,7 +2264,11 @@ export function makeOpenCodeAdapter( |
| 2125 | 2264 | |
| 2126 | 2265 | case "message.part.delta": { |
| 2127 | 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 | 2272 | break; |
| 2130 | 2273 | } |
| 2131 | 2274 | const role = messageRoleForPart(context, existingPart); |
| ... | ... | @@ -2180,7 +2323,9 @@ export function makeOpenCodeAdapter( |
| 2180 | 2323 | if (part.type === "tool") { |
| 2181 | 2324 | const itemType = toToolLifecycleItemType(part.tool); |
| 2182 | 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 | 2329 | const detail = detailFromToolPart(part); |
| 2185 | 2330 | const payload = { |
| 2186 | 2331 | itemType, |
| ... | ... | @@ -2194,6 +2339,14 @@ export function makeOpenCodeAdapter( |
| 2194 | 2339 | data: { |
| 2195 | 2340 | tool: part.tool, |
| 2196 | 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 | 2352 | const runtimeEvent: ProviderRuntimeEvent = { |
| ... | ... | @@ -2224,7 +2377,6 @@ export function makeOpenCodeAdapter( |
| 2224 | 2377 | } |
| 2225 | 2378 | |
| 2226 | 2379 | case "permission.replied": { |
| 2227 | context.pendingPermissions.delete(event.properties.requestID); | |
| 2228 | 2380 | yield* emitTerminalOpenCodeRequest(context, event); |
| 2229 | 2381 | break; |
| 2230 | 2382 | } |
| ... | ... | @@ -2236,18 +2388,45 @@ export function makeOpenCodeAdapter( |
| 2236 | 2388 | |
| 2237 | 2389 | case "question.replied": { |
| 2238 | 2390 | yield* emitTerminalOpenCodeRequest(context, event); |
| 2239 | context.pendingQuestions.delete(event.properties.requestID); | |
| 2240 | 2391 | break; |
| 2241 | 2392 | } |
| 2242 | 2393 | |
| 2243 | 2394 | case "question.rejected": { |
| 2244 | context.pendingQuestions.delete(event.properties.requestID); | |
| 2245 | 2395 | yield* emitTerminalOpenCodeRequest(context, event); |
| 2246 | 2396 | break; |
| 2247 | 2397 | } |
| 2248 | 2398 | |
| 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 | 2428 | case "session.status": { |
| 2250 | if (event.properties.status.type === "busy") { | |
| 2429 | if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { | |
| 2251 | 2430 | if (turnId === undefined) { |
| 2252 | 2431 | break; |
| 2253 | 2432 | } |
| ... | ... | @@ -2272,7 +2451,7 @@ export function makeOpenCodeAdapter( |
| 2272 | 2451 | })), |
| 2273 | 2452 | type: "runtime.warning", |
| 2274 | 2453 | payload: { |
| 2275 | message: event.properties.status.message, | |
| 2454 | message: `OpenCode retry ${event.properties.status.attempt}: ${event.properties.status.message}`, | |
| 2276 | 2455 | detail: event.properties.status, |
| 2277 | 2456 | }, |
| 2278 | 2457 | }); |
| ... | ... | @@ -2335,6 +2514,7 @@ export function makeOpenCodeAdapter( |
| 2335 | 2514 | context.activeAgent = undefined; |
| 2336 | 2515 | context.activeVariant = undefined; |
| 2337 | 2516 | context.reconcileIdleStatus = false; |
| 2517 | yield* schedulePendingRequestRecovery(context); | |
| 2338 | 2518 | yield* updateProviderSession( |
| 2339 | 2519 | context, |
| 2340 | 2520 | { |
| ... | ... | @@ -2388,9 +2568,29 @@ export function makeOpenCodeAdapter( |
| 2388 | 2568 | // shutdown) and cancels the in-flight `event.subscribe` fetch so |
| 2389 | 2569 | // the async iterable unwinds cleanly. |
| 2390 | 2570 | const eventsAbortController = new AbortController(); |
| 2391 | yield* Scope.addFinalizer( | |
| 2392 | context.sessionScope, | |
| 2393 | Effect.sync(() => eventsAbortController.abort()), | |
| 2571 | let lastStreamError: unknown; | |
| 2572 | let warnedAboutDisconnect = false; | |
| 2573 | const streamErrors = yield* Queue.unbounded<unknown>(); | |
| 2574 | yield* Scope.addFinalizer(context.sessionScope, Queue.shutdown(streamErrors)); | |
| 2575 | yield* Stream.fromQueue(streamErrors).pipe( | |
| 2576 | Stream.runForEach((cause) => | |
| 2577 | Effect.gen(function* () { | |
| 2578 | if (warnedAboutDisconnect) return; | |
| 2579 | warnedAboutDisconnect = true; | |
| 2580 | yield* emit({ | |
| 2581 | ...(yield* buildEventBase({ | |
| 2582 | threadId: context.session.threadId, | |
| 2583 | turnId: context.activeTurnId, | |
| 2584 | })), | |
| 2585 | type: "runtime.warning", | |
| 2586 | payload: { | |
| 2587 | message: "OpenCode connection lost. Reconnecting.", | |
| 2588 | detail: openCodeRuntimeErrorDetail(cause), | |
| 2589 | }, | |
| 2590 | }); | |
| 2591 | }), | |
| 2592 | ), | |
| 2593 | Effect.forkIn(context.sessionScope), | |
| 2394 | 2594 | ); |
| 2395 | 2595 | |
| 2396 | 2596 | // Fibers forked into `context.sessionScope` are interrupted |
| ... | ... | @@ -2399,6 +2599,10 @@ export function makeOpenCodeAdapter( |
| 2399 | 2599 | runOpenCodeSdk("event.subscribe", () => |
| 2400 | 2600 | context.client.event.subscribe(undefined, { |
| 2401 | 2601 | signal: eventsAbortController.signal, |
| 2602 | onSseError: (cause) => { | |
| 2603 | lastStreamError = cause; | |
| 2604 | Queue.offerUnsafe(streamErrors, cause); | |
| 2605 | }, | |
| 2402 | 2606 | }), |
| 2403 | 2607 | ), |
| 2404 | 2608 | (subscription) => |
| ... | ... | @@ -2410,7 +2614,13 @@ export function makeOpenCodeAdapter( |
| 2410 | 2614 | detail: openCodeRuntimeErrorDetail(cause), |
| 2411 | 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 | 2624 | ).pipe( |
| 2415 | 2625 | Effect.exit, |
| 2416 | 2626 | Effect.flatMap((exit) => |
| ... | ... | @@ -2420,12 +2630,14 @@ export function makeOpenCodeAdapter( |
| 2420 | 2630 | if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { |
| 2421 | 2631 | return; |
| 2422 | 2632 | } |
| 2423 | if (Exit.isFailure(exit)) { | |
| 2424 | yield* emitUnexpectedExit( | |
| 2425 | context, | |
| 2426 | openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), | |
| 2427 | ); | |
| 2428 | } | |
| 2633 | yield* emitUnexpectedExit( | |
| 2634 | context, | |
| 2635 | Exit.isFailure(exit) | |
| 2636 | ? openCodeRuntimeErrorDetail(Cause.squash(exit.cause)) | |
| 2637 | : lastStreamError !== undefined | |
| 2638 | ? `OpenCode event stream disconnected: ${openCodeRuntimeErrorDetail(lastStreamError)}` | |
| 2639 | : "OpenCode event stream ended unexpectedly. Send another message to reconnect.", | |
| 2640 | ); | |
| 2429 | 2641 | }), |
| 2430 | 2642 | ), |
| 2431 | 2643 | Effect.forkIn(context.sessionScope), |
| ... | ... | @@ -2444,6 +2656,12 @@ export function makeOpenCodeAdapter( |
| 2444 | 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 | }); |
| 2448 | 2666 | |
| 2449 | 2667 | const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( |
| ... | ... | @@ -3259,6 +3477,7 @@ export function makeOpenCodeAdapter( |
| 3259 | 3477 | } else { |
| 3260 | 3478 | context.cancellation = undefined; |
| 3261 | 3479 | context.reconcileIdleStatus = true; |
| 3480 | yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); | |
| 3262 | 3481 | } |
| 3263 | 3482 | } |
| 3264 | 3483 | yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); |
| ... | ... | @@ -3269,20 +3488,52 @@ export function makeOpenCodeAdapter( |
| 3269 | 3488 | "respondToRequest", |
| 3270 | 3489 | )(function* (threadId, requestId, decision) { |
| 3271 | 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 | 3494 | return yield* new ProviderAdapterRequestError({ |
| 3274 | 3495 | provider: PROVIDER, |
| 3275 | 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 | } |
| 3279 | 3503 | |
| 3280 | yield* runOpenCodeSdk("permission.reply", () => | |
| 3281 | context.client.permission.reply({ | |
| 3282 | requestID: requestId, | |
| 3283 | reply: toOpenCodePermissionReply(decision), | |
| 3504 | const reply = toOpenCodePermissionReply(decision); | |
| 3505 | yield* runOpenCodeSdk("permission.reply", (signal) => | |
| 3506 | context.client.permission.reply( | |
| 3507 | { | |
| 3508 | requestID: requestId, | |
| 3509 | reply, | |
| 3510 | }, | |
| 3511 | { signal }, | |
| 3512 | ), | |
| 3513 | ).pipe( | |
| 3514 | Effect.mapError(toRequestError), | |
| 3515 | Effect.timeoutOrElse({ | |
| 3516 | duration: "10 seconds", | |
| 3517 | orElse: () => | |
| 3518 | Effect.fail( | |
| 3519 | new ProviderAdapterRequestError({ | |
| 3520 | provider: PROVIDER, | |
| 3521 | method: "permission.reply", | |
| 3522 | detail: "OpenCode permission reply did not complete within 10 seconds.", | |
| 3523 | }), | |
| 3524 | ), | |
| 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 | }); |
| 3287 | 3538 | |
| 3288 | 3539 | const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( |
| ... | ... | @@ -3291,19 +3542,54 @@ export function makeOpenCodeAdapter( |
| 3291 | 3542 | const context = yield* ensureSessionContext(sessions, threadId); |
| 3292 | 3543 | const request = context.pendingQuestions.get(requestId); |
| 3293 | 3544 | if (!request) { |
| 3545 | if (context.emittedTerminalRequestIds.has(requestId)) return; | |
| 3294 | 3546 | return yield* new ProviderAdapterRequestError({ |
| 3295 | 3547 | provider: PROVIDER, |
| 3296 | 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 | } |
| 3300 | 3555 | |
| 3301 | yield* runOpenCodeSdk("question.reply", () => | |
| 3302 | context.client.question.reply({ | |
| 3303 | requestID: requestId, | |
| 3304 | answers: toOpenCodeQuestionAnswers(request, answers), | |
| 3556 | const questionAnswers = toOpenCodeQuestionAnswers(request, answers); | |
| 3557 | yield* runOpenCodeSdk("question.reply", (signal) => | |
| 3558 | context.client.question.reply( | |
| 3559 | { | |
| 3560 | requestID: requestId, | |
| 3561 | answers: questionAnswers, | |
| 3562 | }, | |
| 3563 | { signal }, | |
| 3564 | ), | |
| 3565 | ).pipe( | |
| 3566 | Effect.mapError(toRequestError), | |
| 3567 | Effect.timeoutOrElse({ | |
| 3568 | duration: "10 seconds", | |
| 3569 | orElse: () => | |
| 3570 | Effect.fail( | |
| 3571 | new ProviderAdapterRequestError({ | |
| 3572 | provider: PROVIDER, | |
| 3573 | method: "question.reply", | |
| 3574 | detail: "OpenCode question reply did not complete within 10 seconds.", | |
| 3575 | }), | |
| 3576 | ), | |
| 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 | }); |
| 3308 | 3594 | |
| 3309 | 3595 | const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( |
apps/server/src/provider/opencodeRuntime.environment.test.ts+87| ... | ... | @@ -1,12 +1,24 @@ |
| 1 | 1 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | import { | |
| 5 | HostProcessEnvironment, | |
| 6 | HostProcessExecutablePath, | |
| 7 | HostProcessPlatform, | |
| 8 | } from "@t3tools/shared/hostProcess"; | |
| 3 | 9 | import * as Effect from "effect/Effect"; |
| 4 | 10 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Path from "effect/Path"; | |
| 5 | 14 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | |
| 6 | 16 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 17 | |
| 8 | 18 | import { |
| 19 | OpenCodeRuntime, | |
| 9 | 20 | OpenCodeRuntimeError, |
| 21 | OpenCodeRuntimeLive, | |
| 10 | 22 | resolveOpenCodeConfigContent, |
| 11 | 23 | resolveOpenCodeServerPassword, |
| 12 | 24 | verifyOpenCodeServerVersion, |
| ... | ... | @@ -150,3 +162,78 @@ describe("verifyOpenCodeServerVersion", () => { |
| 150 | 162 | }).pipe(Effect.provide(TestClock.layer())), |
| 151 | 163 | ); |
| 152 | 164 | }); |
| 165 | ||
| 166 | describe("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"; | |
| 184 | const writeOutput = (stream) => new Promise((resolve, reject) => { | |
| 185 | stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve()); | |
| 186 | }); | |
| 187 | const 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 | }); | |
| 196 | server.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 | 1 | import * as NodeAssert from "node:assert/strict"; |
| 2 | 2 | |
| 3 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 4 | import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 5 | 5 | import { it } from "@effect/vitest"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 7 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 8 | 9 | import * as Layer from "effect/Layer"; |
| 9 | 10 | import * as Path from "effect/Path"; |
| 11 | import * as Queue from "effect/Queue"; | |
| 10 | 12 | import { |
| 11 | 13 | HostProcessEnvironment, |
| 12 | 14 | HostProcessExecutablePath, |
| ... | ... | @@ -18,6 +20,44 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; |
| 18 | 20 | const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); |
| 19 | 21 | |
| 20 | 22 | it.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 | 61 | it.effect("keeps provider inventory when agent discovery fails", () => |
| 22 | 62 | Effect.gen(function* () { |
| 23 | 63 | const runtime = yield* OpenCodeRuntime; |
apps/server/src/provider/opencodeRuntime.permissions.test.ts+50-6| ... | ... | @@ -1,15 +1,21 @@ |
| 1 | 1 | import * as NodeAssert from "node:assert/strict"; |
| 2 | 2 | |
| 3 | import * as RegExpUtils from "effect/RegExp"; | |
| 3 | 4 | import { describe, it } from "vite-plus/test"; |
| 4 | 5 | |
| 5 | import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts"; | |
| 6 | import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts"; | |
| 6 | 7 | |
| 7 | 8 | function actionFor( |
| 8 | 9 | runtimeMode: Parameters<typeof buildOpenCodePermissionRules>[0], |
| 9 | 10 | permission: string, |
| 11 | target = "*", | |
| 10 | 12 | ) { |
| 11 | return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission) | |
| 12 | ?.action; | |
| 13 | // OpenCode uses the last matching rule. Its wildcards match directory separators. | |
| 14 | return buildOpenCodePermissionRules(runtimeMode).findLast( | |
| 15 | (rule) => | |
| 16 | (rule.permission === "*" || rule.permission === permission) && | |
| 17 | new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), | |
| 18 | )?.action; | |
| 13 | 19 | } |
| 14 | 20 | |
| 15 | 21 | describe("buildOpenCodePermissionRules", () => { |
| ... | ... | @@ -27,12 +33,38 @@ describe("buildOpenCodePermissionRules", () => { |
| 27 | 33 | NodeAssert.equal(actionFor("auto", "edit"), "ask"); |
| 28 | 34 | }); |
| 29 | 35 | |
| 30 | it("keeps asking for everything else in the auto modes", () => { | |
| 31 | for (const runtimeMode of ["auto-accept-edits", "auto"] as const) { | |
| 36 | it("allows workspace reads and task updates without asking in supervised modes", () => { | |
| 37 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 38 | for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) { | |
| 39 | NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow"); | |
| 40 | } | |
| 41 | } | |
| 42 | }); | |
| 43 | ||
| 44 | it("preserves OpenCode's environment-file approval rules", () => { | |
| 45 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 46 | for (const target of [ | |
| 47 | ".env", | |
| 48 | ".env.local", | |
| 49 | "config/service.env", | |
| 50 | "config/service.env.local", | |
| 51 | ]) { | |
| 52 | NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask"); | |
| 53 | } | |
| 54 | for (const target of [".env.example", "config/service.env.example"]) { | |
| 55 | NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow"); | |
| 56 | } | |
| 57 | } | |
| 58 | }); | |
| 59 | ||
| 60 | it("still asks before commands, network access, external directories and unknown tools", () => { | |
| 61 | for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { | |
| 32 | 62 | NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); |
| 33 | 63 | NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); |
| 64 | NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask"); | |
| 34 | 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 | }); |
| 38 | 70 | |
| ... | ... | @@ -43,3 +75,15 @@ describe("buildOpenCodePermissionRules", () => { |
| 43 | 75 | ]); |
| 44 | 76 | }); |
| 45 | 77 | }); |
| 78 | ||
| 79 | describe("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 | 80 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; |
| 81 | 81 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; |
| 82 | 82 | const DEFAULT_HOSTNAME = "127.0.0.1"; |
| 83 | const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; | |
| 83 | 84 | const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; |
| 84 | 85 | export interface OpenCodeServerProcess { |
| 85 | 86 | readonly url: string; |
| ... | ... | @@ -494,8 +495,19 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi |
| 494 | 495 | // reviewer, OpenCode among them, fall back to Supervised for that mode. |
| 495 | 496 | const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; |
| 496 | 497 | |
| 498 | // Session rules override OpenCode's agent defaults. Allow reads and task | |
| 499 | // updates, but keep its default approval rules for environment files. | |
| 497 | 500 | return [ |
| 498 | 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 | 511 | { permission: "bash", pattern: "*", action: "ask" }, |
| 500 | 512 | { permission: "edit", pattern: "*", action: editAction }, |
| 501 | 513 | { permission: "webfetch", pattern: "*", action: "ask" }, |
| ... | ... | @@ -514,6 +526,7 @@ export function toOpenCodePermissionReply( |
| 514 | 526 | case "accept": |
| 515 | 527 | return "once"; |
| 516 | 528 | case "acceptForSession": |
| 529 | case "acceptAlways": | |
| 517 | 530 | return "always"; |
| 518 | 531 | case "decline": |
| 519 | 532 | case "cancel": |
| ... | ... | @@ -707,18 +720,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 707 | 720 | ); |
| 708 | 721 | yield* Scope.addFinalizer(runtimeScope, terminateChild); |
| 709 | 722 | |
| 710 | const stdoutRef = yield* Ref.make(""); | |
| 711 | const stderrRef = yield* Ref.make(""); | |
| 723 | const stdoutRef = yield* Ref.make<string | null>(""); | |
| 724 | const stderrRef = yield* Ref.make<string | null>(""); | |
| 712 | 725 | const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>(); |
| 713 | 726 | |
| 714 | 727 | const setReadyFromStdoutChunk = (chunk: string) => |
| 715 | Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( | |
| 716 | Effect.flatMap((nextStdout) => { | |
| 717 | const parsed = parseServerUrlFromOutput(nextStdout); | |
| 718 | return parsed | |
| 719 | ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) | |
| 720 | : Effect.void; | |
| 721 | }), | |
| 728 | Ref.modify(stdoutRef, (stdout) => { | |
| 729 | if (stdout === null) { | |
| 730 | return [null, null] as const; | |
| 731 | } | |
| 732 | const nextStdout = `${stdout}${chunk}`; | |
| 733 | return [ | |
| 734 | parseServerUrlFromOutput(nextStdout), | |
| 735 | nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), | |
| 736 | ] as const; | |
| 737 | }).pipe( | |
| 738 | Effect.flatMap((parsed) => | |
| 739 | parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, | |
| 740 | ), | |
| 722 | 741 | ); |
| 723 | 742 | |
| 724 | 743 | const stdoutFiber = yield* child.stdout.pipe( |
| ... | ... | @@ -729,7 +748,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 729 | 748 | ); |
| 730 | 749 | const stderrFiber = yield* child.stderr.pipe( |
| 731 | 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 | 758 | Effect.ignore, |
| 734 | 759 | Effect.forkIn(runtimeScope), |
| 735 | 760 | ); |
| ... | ... | @@ -737,8 +762,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 737 | 762 | const exitFiber = yield* child.exitCode.pipe( |
| 738 | 763 | Effect.flatMap((code) => |
| 739 | 764 | Effect.gen(function* () { |
| 740 | const stdout = yield* Ref.get(stdoutRef); | |
| 741 | const stderr = yield* Ref.get(stderrRef); | |
| 765 | const stdout = (yield* Ref.get(stdoutRef)) ?? ""; | |
| 766 | const stderr = (yield* Ref.get(stderrRef)) ?? ""; | |
| 742 | 767 | const exitCode = Number(code); |
| 743 | 768 | yield* Deferred.fail( |
| 744 | 769 | readyDeferred, |
| ... | ... | @@ -764,14 +789,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 764 | 789 | Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), |
| 765 | 790 | ); |
| 766 | 791 | |
| 767 | // Startup-time fibers are no longer needed once ready has resolved (either | |
| 768 | // way). The exit fiber is only interrupted on failure; on success it keeps | |
| 769 | // the caller's `exitCode` effect observable until the scope closes. | |
| 770 | yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); | |
| 771 | yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); | |
| 792 | if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { | |
| 793 | yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); | |
| 794 | } | |
| 772 | 795 | |
| 773 | 796 | if (Exit.isFailure(readyExit)) { |
| 774 | yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); | |
| 775 | 797 | const squashed = Cause.squash(readyExit.cause); |
| 776 | 798 | return yield* ensureRuntimeError( |
| 777 | 799 | "startOpenCodeServerProcess", |
| ... | ... | @@ -782,13 +804,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 782 | 804 | |
| 783 | 805 | const readyOption = readyExit.value; |
| 784 | 806 | if (Option.isNone(readyOption)) { |
| 785 | yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); | |
| 786 | 807 | return yield* new OpenCodeRuntimeError({ |
| 787 | 808 | operation: "startOpenCodeServerProcess", |
| 788 | 809 | detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, |
| 789 | 810 | }); |
| 790 | 811 | } |
| 791 | 812 | |
| 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 | 819 | const url = readyOption.value; |
| 793 | 820 | const version = yield* verifyOpenCodeServerVersion( |
| 794 | 821 | createOpenCodeSdkClient({ |
| ... | ... | @@ -854,7 +881,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 854 | 881 | }; |
| 855 | 882 | |
| 856 | 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 | 885 | Effect.filterMapOrFail( |
| 859 | 886 | (list) => |
| 860 | 887 | list.data |
| ... | ... | @@ -870,7 +897,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 870 | 897 | ); |
| 871 | 898 | |
| 872 | 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 | 901 | Effect.map((result) => result.data ?? []), |
| 875 | 902 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), |
| 876 | 903 | ); |
apps/web/src/session-logic.test.ts+33-2| ... | ... | @@ -62,6 +62,37 @@ function makeActivity(overrides: { |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | 64 | describe("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 | 96 | it("tracks open approvals and removes resolved ones", () => { |
| 66 | 97 | const activities: OrchestrationThreadActivity[] = [ |
| 67 | 98 | makeActivity({ |
| ... | ... | @@ -90,7 +121,7 @@ describe("derivePendingApprovals", () => { |
| 90 | 121 | kind: "approval.requested", |
| 91 | 122 | summary: "File-change approval requested", |
| 92 | 123 | tone: "approval", |
| 93 | payload: { requestId: "req-2", requestKind: "file-change" }, | |
| 124 | payload: { requestId: "req-2", requestType: "unknown" }, | |
| 94 | 125 | }), |
| 95 | 126 | ]; |
| 96 | 127 | |
| ... | ... | @@ -199,7 +230,7 @@ describe("derivePendingApprovals", () => { |
| 199 | 230 | tone: "approval", |
| 200 | 231 | payload: { |
| 201 | 232 | requestId: "req-stale-1", |
| 202 | requestKind: "command", | |
| 233 | requestType: "unknown", | |
| 203 | 234 | }, |
| 204 | 235 | }), |
| 205 | 236 | makeActivity({ |
apps/web/src/session-logic.ts+8-2| ... | ... | @@ -464,10 +464,16 @@ export function derivePendingApprovals( |
| 464 | 464 | ? payload.options.filter(isProviderApprovalOption) |
| 465 | 465 | : undefined; |
| 466 | 466 | |
| 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 | 473 | openByRequestId.set(requestId, { |
| 469 | 474 | requestId, |
| 470 | requestKind, | |
| 475 | // Older OpenCode requests can have no recognized approval kind. | |
| 476 | requestKind: requestKind ?? "command", | |
| 471 | 477 | createdAt: activity.createdAt, |
| 472 | 478 | ...(detail ? { detail } : {}), |
| 473 | 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 | 17 | provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server. |
| 18 | 18 | OpenCode uses this password for HTTP Basic authentication. |
| 19 | 19 | |
| 20 | ## Approvals | |
| 21 | ||
| 22 | In **Supervised** and **Auto** modes, OpenCode can read normal project files, search files, load | |
| 23 | skills, and update its task list without approval. Files such as `.env` and `.env.local` still | |
| 24 | require approval. `.env.example` does not. OpenCode does not have an AI approval reviewer, so | |
| 25 | **Auto** uses the same permission rules as **Supervised**. | |
| 26 | ||
| 27 | OpenCode asks before it runs commands, edits files, accesses the web, or accesses directories | |
| 28 | outside 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 | ||
| 31 | An **Approval** badge means OpenCode needs a decision. Open the thread to see the action and | |
| 32 | choose 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 | ||
| 39 | If a connection error prevents the reply, the approval stays available so you can try again. | |
| 40 | ||
| 41 | ## Progress | |
| 42 | ||
| 43 | T3 Code shows OpenCode's response text and tool results while work runs. The web and desktop apps | |
| 44 | also show its task-list progress. A task-list update does not require approval. | |
| 45 | ||
| 46 | If the OpenCode connection closes unexpectedly, T3 Code shows an error. Send another prompt to | |
| 47 | reconnect to the same OpenCode session. | |
| 48 | ||
| 20 | 49 | ## Stop a turn |
| 21 | 50 | |
| 22 | 51 | When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions. |
| 23 | 52 | T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It |
| 24 | does not stop unrelated OpenCode sessions. | |
| 53 | does not stop unrelated OpenCode sessions. After Stop succeeds, pending approvals and questions | |
| 54 | are cleared. | |
| 25 | 55 | |
| 26 | Stop reports an error if OpenCode cannot list or stop a child session. When T3 Code closes an | |
| 27 | OpenCode session, it also tries to stop the child sessions, but this teardown is best effort. | |
| 56 | Stop reports an error if OpenCode cannot stop the main session or list or stop a child session. | |
| 57 | When T3 Code closes an OpenCode session, it also tries to stop the child sessions, but this | |
| 58 | teardown is best effort. | |
| 28 | 59 | |
| 29 | 60 | ## Refresh the model list |
| 30 | 61 |
packages/client-runtime/src/work-log/presentation.test.ts+34| ... | ... | @@ -15,6 +15,40 @@ import { |
| 15 | 15 | } from "./presentation.js"; |
| 16 | 16 | |
| 17 | 17 | describe("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 | 52 | it("deduplicates named sources ahead of ordinary actions", () => { |
| 19 | 53 | const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const }; |
| 20 | 54 | expect( |
packages/client-runtime/src/work-log/presentation.ts+7| ... | ... | @@ -313,6 +313,13 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): |
| 313 | 313 | } |
| 314 | 314 | |
| 315 | 315 | export 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 | 323 | if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; |
| 317 | 324 | if ( |
| 318 | 325 | entry.requestKind === "file-read" || |