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