- Migrate web and mobile state, commands, and persistence to V2 projections - Render conversations from authoritative visible turn items - Harden runtime policy, launch handling, attachments, and cache invalidation
Unified · Split
| @@ -20,14 +20,15 @@ Execution rules: · 19 unmodified lines | |||
| 20 | - Shape 3 performs the backend hard cut and intentionally breaks the old client protocol. | 20 | - Shape 3 performs the backend hard cut and intentionally breaks the old client protocol. |
| 21 | - Shape 4.0 updates web and mobile to the final protocol while preserving the current product experience and component structure. | 21 | - Shape 4.0 updates web and mobile to the final protocol while preserving the current product experience and component structure. |
| 22 | - Shape 4.5 enriches that working application with V2-native graph, execution, tool, and context information. | 22 | - Shape 4.5 enriches that working application with V2-native graph, execution, tool, and context information. |
| 23 | - Do not create a server-side V2-to-V1 compatibility projection. Client presentation adapters may generalize existing component props, but the source of truth remains the complete V2 projection. | 23 | - Do not create a server-side V2-to-V1 compatibility projection. Shape 4.0 may use temporary parity adapters around existing component props, but Shape 4.5 must not grow those adapters into a second complete thread/projection model. |
| 24 | - Keep React bindings platform-owned. Web and mobile may have intentionally similar binding and presentation modules; shared client-runtime ownership applies to projection state and domain semantics, not to React hooks or native UI policy. | ||
| 24 | - Only the completed Shape 4.5 system is eligible for a fresh-state user-facing release. Shipping an upgrade to an installation with V1 threads additionally requires the separate Stage 5 migration decision. | 25 | - Only the completed Shape 4.5 system is eligible for a fresh-state user-facing release. Shipping an upgrade to an installation with V1 threads additionally requires the separate Stage 5 migration decision. |
| 25 | - Do not drop V1 tables, migrations, provider runtime rows, or attachment files during this plan. | 26 | - Do not drop V1 tables, migrations, provider runtime rows, or attachment files during this plan. |
| 26 | - V1 removal happens inside the shape that replaces it; there is no deferred general cleanup phase. | 27 | - V1 removal happens inside the shape that replaces it; there is no deferred general cleanup phase. |
| 27 | 28 | ||
| 28 | ## Alignment With The Current Production Path | 29 | ## Alignment With The Current Production Path |
| 29 | 30 | ||
| 30 | At the start of this plan, the application path was V1 at the orchestration boundary. After the completed Shape 3, the server is V2-only while the ordinary web and mobile clients still call the removed V1 protocol. Several provider and selection concepts beneath that boundary were already provider-neutral and have been retained. Shapes 4.0 and 4.5 must continue reusing them rather than recreating the existing model, mode, and provider-instance UX. | 31 | At the start of this plan, the application path was V1 at the orchestration boundary. Between completed Shapes 3 and 4.0, the server was V2-only while the ordinary web and mobile clients still called the removed V1 protocol. Shape 4.0 has now closed that temporary gap. Several provider and selection concepts beneath that boundary were already provider-neutral and have been retained. Shape 4.5 must continue reusing them rather than recreating the existing model, mode, and provider-instance UX. |
| 31 | 32 | ||
| 32 | The historical pre-cut flow was: | 33 | The historical pre-cut flow was: |
| 33 | 34 | ||
| @@ -40,21 +41,22 @@ Web/mobile model and mode selection · 6 unmodified lines | |||
| 40 | -> driver-specific legacy adapter | 41 | -> driver-specific legacy adapter |
| 41 | ``` | 42 | ``` |
| 42 | 43 | ||
| 43 | The current branch boundary is: | 44 | The current post-Shape-4.0 boundary is: |
| 44 | 45 | ||
| 45 | ```text | 46 | ```text |
| 46 | ordinary web/mobile state and commands | 47 | ordinary web/mobile state and commands |
| 47 | -> orchestrationV1.* RPCs | 48 | -> shared client-runtime V2 shell and thread projections |
| 48 | -> no server handler after the Shape 3 hard cut | ||
| 49 | |||
| 50 | V2 debug client, CLI, MCP, and backend consumers | ||
| 51 | -> final orchestration.* RPCs / V2 application services | 49 | -> final orchestration.* RPCs / V2 application services |
| 52 | -> shared application event transaction | 50 | -> shared application event transaction |
| 53 | -> Agent Orchestrator V2 | 51 | -> Agent Orchestrator V2 |
| 54 | -> provider execution | 52 | -> provider execution |
| 53 | |||
| 54 | V2 debug client | ||
| 55 | -> direct V2 projection and item inspection | ||
| 56 | -> reference presentation for Shape 4.5 production enrichment | ||
| 55 | ``` | 57 | ``` |
| 56 | 58 | ||
| 57 | Shape 4.0 closes this intentional client/server gap. Shape 4.5 enriches the resulting working frontend. | 59 | Shape 4.0 closed the intentional client/server gap. Shape 4.5 replaces temporary parity-oriented chat shaping with direct, scoped consumption of the complete V2 projection and its server-authoritative visible item sequence. |
| 58 | 60 | ||
| 59 | Current ownership and intended treatment: | 61 | Current ownership and intended treatment: |
| 60 | 62 | ||
| @@ -315,7 +317,7 @@ Add coverage for: · 254 unmodified lines | |||
| 315 | 317 | ||
| 316 | ## Shape 2: Application Services Around V2 | 318 | ## Shape 2: Application Services Around V2 |
| 317 | 319 | ||
| 318 | Implementation status: complete. Shape 3 completed the backend protocol cut; the production web/mobile cutover remains intentionally deferred to Shape 4.0. | 320 | Implementation status: complete. Shape 3 completed the backend protocol cut; the production web/mobile cutover was subsequently completed in Shape 4.0. |
| 319 | 321 | ||
| 320 | ### Goal | 322 | ### Goal |
| 321 | 323 | ||
| @@ -702,7 +704,16 @@ Add backend integration coverage for: · 380 unmodified lines | |||
| 702 | 704 | ||
| 703 | ## Shape 4.0: V2 Frontend Cutover And Product Parity | 705 | ## Shape 4.0: V2 Frontend Cutover And Product Parity |
| 704 | 706 | ||
| 705 | Implementation status: pending. | 707 | Implementation status: complete. |
| 708 | |||
| 709 | Implementation outcome: | ||
| 710 | |||
| 711 | - Web and mobile now consume the unified V2 shell and full V2 thread projections through the shared client runtime, including reconnect/cursor handling and versioned V2-only caches. | ||
| 712 | - Ordinary project, thread, message, run-control, runtime-request, settings, archive, delete, checkpoint, attachment, and plan-implementation commands now use the final event-sourced project and V2 orchestration transports. | ||
| 713 | - Existing root-workspace, newly provisioned worktree, origin-based worktree, and already-prepared worktree launch flows are preserved, including promotion of an empty server thread into a worktree before its first message. | ||
| 714 | - Existing web and mobile product components consume V2-native parity models. The complete projection and structured turn-item payload remain available, while unsupported rich V2 visualization stays deferred to Shape 4.5. | ||
| 715 | - The parity models are an explicit temporary bridge for existing product components. They are not the intended Shape 4.5 thread API and may be removed incrementally as direct item consumers replace them. | ||
| 716 | - Production V1 client reducers, subscriptions, command paths, and persistence writers have been removed. Legacy server state remains untouched for the separate Stage 5 migration decision. | ||
| 706 | 717 | ||
| 707 | ### Goal | 718 | ### Goal |
| 708 | 719 | ||
| @@ -716,6 +727,8 @@ At the end of Shape 4.0, the ordinary application—not only the V2 debug route · 7 unmodified lines | |||
| 716 | - Reuse existing sidebar, chat, composer, approval, archive, diff, settings, and model-picker components where their behavior remains correct. | 727 | - Reuse existing sidebar, chat, composer, approval, archive, diff, settings, and model-picker components where their behavior remains correct. |
| 717 | - Generalize component props around V2-native view models instead of manufacturing V1 commands, events, activities, or thread projections. | 728 | - Generalize component props around V2-native view models instead of manufacturing V1 commands, events, activities, or thread projections. |
| 718 | - Retain the complete V2 projection in client state even when the parity UI initially presents only a subset of it. | 729 | - Retain the complete V2 projection in client state even when the parity UI initially presents only a subset of it. |
| 730 | - Preserve shell and thread-detail state as separately subscribable streams. Shape 4.0 components may compose them locally where required, but the client runtime must not make detail traffic a dependency of shell/sidebar state. | ||
| 731 | - Keep web and mobile React bindings separate. Similar binding code is intentional and is not a Shape 4.0 deduplication target. | ||
| 719 | - Provide a safe generic renderer for V2 turn-item types that do not yet have a rich presentation. Do not drop structured tool or execution data merely because Shape 4.5 owns the final renderer. | 732 | - Provide a safe generic renderer for V2 turn-item types that do not yet have a rich presentation. Do not drop structured tool or execution data merely because Shape 4.5 owns the final renderer. |
| 720 | - Defer graph visualization, rich execution inspection, and provider-native tool presentation to Shape 4.5. | 733 | - Defer graph visualization, rich execution inspection, and provider-native tool presentation to Shape 4.5. |
| 721 | - Do not import V1 server rows or migrate existing V1 threads in this shape. | 734 | - Do not import V1 server rows or migrate existing V1 threads in this shape. |
| @@ -735,7 +748,7 @@ The server's committed application sequence is the shell reconnect boundary. A t · 13 unmodified lines | |||
| 735 | 748 | ||
| 736 | Bump web and mobile orchestration-cache versions. Discard cached V1 shell/thread projections while preserving drafts, credentials, environment configuration, preferences, and unrelated local state. Cache invalidation is not the Stage 5 server-state migration. | 749 | Bump web and mobile orchestration-cache versions. Discard cached V1 shell/thread projections while preserving drafts, credentials, environment configuration, preferences, and unrelated local state. Cache invalidation is not the Stage 5 server-state migration. |
| 737 | 750 | ||
| 738 | ### 2. Add a parity-focused V2 presentation adapter | 751 | ### 2. Add a temporary parity-focused V2 presentation adapter |
| 739 | 752 | ||
| 740 | Create shared, V2-native client view models for the existing application surfaces, including: | 753 | Create shared, V2-native client view models for the existing application surfaces, including: |
| 741 | 754 | ||
| @@ -750,7 +763,7 @@ Create shared, V2-native client view models for the existing application surface · 8 unmodified lines | |||
| 750 | 763 | ||
| 751 | Derive these models from the V2 thread projection, `visibleTurnItems`, runs, attempts, runtime requests, and checkpoints. Existing components may be adapted to consume these models, but the adapter must not recreate an `OrchestrationThread`, `OrchestrationThreadActivity`, V1 `latestTurn`, or a V1 event stream. | 764 | Derive these models from the V2 thread projection, `visibleTurnItems`, runs, attempts, runtime requests, and checkpoints. Existing components may be adapted to consume these models, but the adapter must not recreate an `OrchestrationThread`, `OrchestrationThreadActivity`, V1 `latestTurn`, or a V1 event stream. |
| 752 | 765 | ||
| 753 | Web and mobile share the projection reducer and parity derivation rules. Platform-specific components may render the resulting view models differently. | 766 | Web and mobile share the projection reducer and retain the complete structured V2 data. Platform-specific components may render temporary parity views differently. Shape 4.5 owns replacing full-thread parity shaping with direct, scoped item consumption; Shape 4.0 does not need to pre-design that final API. |
| 754 | 767 | ||
| 755 | ### 3. Replace production client commands | 768 | ### 3. Replace production client commands |
| 756 | 769 | ||
| @@ -847,6 +860,9 @@ Add web and mobile integration coverage for: · 90 unmodified lines | |||
| 847 | - A user can create a project and thread, converse with an agent, reconnect, respond to requests, inspect diffs, archive, and delete. | 860 | - A user can create a project and thread, converse with an agent, reconnect, respond to requests, inspect diffs, archive, and delete. |
| 848 | - Existing V1-era product behavior that remains supported by V2 is present without routing through V1 data shapes. | 861 | - Existing V1-era product behavior that remains supported by V2 is present without routing through V1 data shapes. |
| 849 | - Full V2 projections and structured turn-item payloads are retained even when rendered generically. | 862 | - Full V2 projections and structured turn-item payloads are retained even when rendered generically. |
| 863 | - Server-projected `visibleTurnItems`, including inherited and synthetic rows, remain intact in client state even when Shape 4.0 surfaces do not render them richly. | ||
| 864 | - Shell/sidebar state remains independently subscribable from full thread detail. | ||
| 865 | - Temporary full-thread parity models and separate web/mobile React binding modules are allowed at this boundary; their final cleanup belongs to Shape 4.5. | ||
| 850 | - No production code calls an `orchestrationV1.*` endpoint. | 866 | - No production code calls an `orchestrationV1.*` endpoint. |
| 851 | - No V2-to-V1 server or persistence compatibility projection exists. | 867 | - No V2-to-V1 server or persistence compatibility projection exists. |
| 852 | - The V2 debug route is no longer the only functional V2 client. | 868 | - The V2 debug route is no longer the only functional V2 client. |
| @@ -855,139 +871,189 @@ Add web and mobile integration coverage for: · 2 unmodified lines | |||
| 855 | 871 | ||
| 856 | ## Shape 4.5: V2-Native Frontend Enrichment | 872 | ## Shape 4.5: V2-Native Frontend Enrichment |
| 857 | 873 | ||
| 858 | Implementation status: pending. | 874 | Implementation status: in progress. Sections 1 and 2 are implemented for web; mobile cutover and the deeper supporting-state, graph, workflow, and parity-removal sections remain. |
| 859 | 875 | ||
| 860 | ### Goal | 876 | ### Goal |
| 861 | 877 | ||
| 862 | Enrich the now-working application with information and workflows that V2 models explicitly but the V1 UI could not represent well. This shape builds on the Shape 4.0 state and command path; it must not introduce a second projection model or replace working frontend components without a concrete UX need. | 878 | Enrich the now-working application with information and workflows that V2 models explicitly but the V1 UI could not represent well. The production chat must move from the temporary Shape 4.0 parity reconstruction to the server-authoritative ordered V2 item sequence without introducing a second complete projection or a shared React presentation layer. |
| 863 | 879 | ||
| 864 | ### 1. Build the complete shared V2 presentation model | 880 | The V2 projection remains the client semantic source of truth. Client enrichment adds environment scope and narrowly derived relationships; it does not copy every projection table into another thread-shaped model. |
| 865 | 881 | ||
| 866 | Extend the shared derivation layer to use: | 882 | ### Shape 4.5 implementation order |
| 867 | 883 | ||
| 868 | - `visibleTurnItems` | 884 | Complete the following sections in order. The client-runtime substrate is intentionally small; web proves the first production presentation, mobile follows from the same projection semantics, and only then are demonstrated shared domain selectors extracted. |
| 869 | - runs and attempts | ||
| 870 | - execution nodes | ||
| 871 | - provider sessions, threads, and turns | ||
| 872 | - runtime requests | ||
| 873 | - plans and todo steps | ||
| 874 | - checkpoint scopes and captures | ||
| 875 | - context handoffs and transfers | ||
| 876 | - fork, merge-back, and subagent relationships | ||
| 877 | |||
| 878 | Expose stable UI entries for: | ||
| 879 | |||
| 880 | - user and assistant messages | ||
| 881 | - reasoning | ||
| 882 | - typed tool and command execution | ||
| 883 | - file changes | ||
| 884 | - file, code, and web search | ||
| 885 | - approvals and user-input requests | ||
| 886 | - plans and todo lists | ||
| 887 | - checkpoints and rollback activity | ||
| 888 | - interrupts, retries, and recovered attempts | ||
| 889 | - handoffs, forks, merges, and subagents | ||
| 890 | - errors and diagnostic status | ||
| 891 | |||
| 892 | Preserve provider-native and dynamic tool payloads behind a structured fallback so adding a new tool type never makes information disappear. | ||
| 893 | |||
| 894 | ### 2. Expose thread and execution relationships | ||
| 895 | |||
| 896 | Create a shared graph/lineage model from thread lineage, execution nodes, subagents, forks, and context transfers. It must support: | ||
| 897 | 885 | ||
| 898 | - root, parent, child, fork, and subagent relationships | 886 | ### 1. Establish the scoped projection and item atom boundary |
| 899 | - navigation between related threads | 887 | |
| 900 | - per-node run and terminal status | 888 | Keep shell and detail as independent sources: |
| 901 | - merge-back and context-transfer state | 889 | |
| 902 | - archived or deleted related threads | 890 | - `EnvironmentThreadShell` remains the environment-scoped, list-oriented view of the server shell stream. |
| 903 | - missing-parent, partial-replay, and cycle-safe fallback behavior | 891 | - Introduce a detail value containing `environmentId` and the pristine `OrchestrationV2ThreadProjection`. Use a temporary migration name such as `ScopedThreadProjection` while the Shape 4.0 parity facade still owns `EnvironmentThread`; after that facade is removed, the contextual wrapper may take the unversioned `EnvironmentThread` name. |
| 892 | - Preserve `wrapper.projection === projectionState.data` and the identities of every nested projection collection. Do not spread or translate the complete projection into a second object graph. | ||
| 893 | - Do not attach `environmentId` to every nested message, run, node, or item. Values that leave their scoped thread context or participate in cross-thread relationships must carry an explicit `ScopedThreadRef` or equivalent environment-qualified reference. | ||
| 894 | - Shell consumers read shell atoms. Detail consumers read `projection.thread` and detail atoms. Do not synthesize a shell from detail, globally merge the streams, or make sidebar state depend on detail traffic. A component that truly needs both subscribes to both explicitly. | ||
| 895 | |||
| 896 | The stable client-runtime access surface begins with: | ||
| 897 | |||
| 898 | - the full thread state atom, including cached, synchronizing, live, deleted, and error state | ||
| 899 | - a scoped whole-thread atom for debugger, graph, and other whole-projection consumers | ||
| 900 | - a `visibleTurnItems` atom exposing the server-projected ordered item rows | ||
| 901 | - status/error or an equivalently stable synchronization atom that does not invalidate merely because projection content changed | ||
| 902 | |||
| 903 | `visibleTurnItems` is the production conversation source. It already carries the discriminated union for messages, reasoning, plans, tools, approvals, input requests, checkpoints, interruption request/result items, compaction, handoff, fork, subagent, and dynamic tools. It also carries local, inherited, and synthetic visibility metadata. Production chat must not reconstruct this sequence by merging separate messages, plans, and work-entry arrays. | ||
| 904 | |||
| 905 | Do not publish one atom merely because each projection table exists. Add a derived atom or an entity-by-ID lookup only when a concrete row, control, graph, or inspector needs state absent from its turn item, for example: | ||
| 906 | |||
| 907 | - runtime-request response capability for an approval or user-input row | ||
| 908 | - current checkpoint/ref state for rollback | ||
| 909 | - run, attempt, or node state for execution inspection | ||
| 910 | - context-transfer lifecycle for merge-back or provider handoff | ||
| 911 | |||
| 912 | These lookups must retain the original V2 entity types and identities. A whole-thread atom is a firehose and must not be the default subscription for granular timeline components. | ||
| 913 | |||
| 914 | Keep web and mobile React bindings separate. Do not introduce a shared React package, a shared binding factory, or a `createClientStateGraph` abstraction merely because the two bindings are currently similar. | ||
| 915 | |||
| 916 | #### `visibleTurnItems` structural-sharing prerequisite | ||
| 917 | |||
| 918 | Fix the incremental client reducer before the production rich timeline depends on it: | ||
| 904 | 919 | ||
| 905 | Web should provide an efficient tree or graph surface without replacing the ordinary project/thread sidebar. Mobile should provide the same relationships through a form-factor-appropriate drill-down rather than attempting to copy the desktop visualization exactly. | 920 | - inherited and synthetic rows remain server-authoritative and are never locally reconstructed |
| 921 | - a run or attempt update that does not change visible membership returns the previous `visibleTurnItems` array reference | ||
| 922 | - a turn-item update with unchanged membership replaces only the affected row and preserves every unaffected row object and position | ||
| 923 | - positions are recomputed only when visible membership changes | ||
| 924 | - membership equality alone must not suppress a streaming item-content update | ||
| 906 | 925 | ||
| 907 | ### 3. Enrich tool and work-log presentation | 926 | This is a reducer allocation and correctness requirement, not an atom-level equality workaround. |
| 908 | 927 | ||
| 909 | Reuse the existing chat activity and work-log components, then add V2-aware renderers for: | 928 | ### 2. Move the web conversation to authoritative V2 items |
| 910 | 929 | ||
| 911 | - command execution with command, working directory, lifecycle, output, duration, and exit state | 930 | Implement the first Shape 4.5 production vertical slice in web: |
| 931 | |||
| 932 | - render the conversation directly from `visibleTurnItems` | ||
| 933 | - preserve item order across interruption request, intervening provider activity, and interruption result | ||
| 934 | - preserve inherited items and synthetic fork markers | ||
| 935 | - render user and assistant messages, reasoning, plans, tool activity, requests, checkpoints, compaction, handoffs, forks, subagents, and dynamic tools from the discriminated union | ||
| 936 | - retain complete structured/provider-native input and output behind a safe fallback for every item type | ||
| 937 | - join a row to targeted supporting state by ID only when the item itself is insufficient for interaction or inspection | ||
| 938 | |||
| 939 | Reuse the proven presentation work in the V2 debugger where it fits, especially its direct `visibleTurnItems` path and item renderers. Do not reuse the debugger's local subscriptions, local projection reducer, log reconstruction fallback, or debug-only state management. Production consumes the client-runtime thread state established in section 1. | ||
| 940 | |||
| 941 | Web owns visual row types, icons, labels, folding, adjacent visual grouping, expansion, and copy behavior. Those policies are not client-runtime domain models. | ||
| 942 | |||
| 943 | ### 3. Move the mobile conversation to the same projection semantics | ||
| 944 | |||
| 945 | After the web item path is working, move mobile from its Shape 4.0 parity feed to the same authoritative `visibleTurnItems` sequence and targeted entity lookups. | ||
| 946 | |||
| 947 | Mobile keeps its own React bindings and form-factor-specific presentation, including native list behavior, expansion, composer layout, offline/outbox policy, and navigation. It does not need to share web row interfaces, fold policy, labels, or visual grouping. | ||
| 948 | |||
| 949 | Promote logic into client-runtime only when both platforms need the same nontrivial domain interpretation and the relationship is not already explicit in V2. Examples may include environment-scoped cross-thread references or capability-safe entity linkage. Do not extract presentation logic merely because two current implementations happen to look alike. | ||
| 950 | |||
| 951 | ### 4. Enrich tools, execution, and supporting state | ||
| 952 | |||
| 953 | Add item renderers and targeted inspectors for: | ||
| 954 | |||
| 955 | - command execution with input, working directory where available, lifecycle, output, duration, and exit state | ||
| 912 | - file changes with affected paths and patch/diff navigation | 956 | - file changes with affected paths and patch/diff navigation |
| 913 | - file, code, and web search with query and result provenance | 957 | - file, code, and web search with query and result provenance |
| 914 | - MCP and provider-native tool calls | 958 | - MCP, dynamic, and provider-native tool calls with complete structured fallback |
| 915 | - dynamic tools with complete structured input/output fallback | ||
| 916 | - nested tool work associated with execution nodes or subagents | 959 | - nested tool work associated with execution nodes or subagents |
| 917 | - failed, interrupted, retried, and superseded calls | 960 | - queued, steering, restarting, active, interrupted, failed, retried, superseded, and recovered execution |
| 961 | - attempt history, execution-node progress, and retry reasons | ||
| 962 | - provider session/thread/turn identity where diagnostically useful | ||
| 963 | - native resume versus portable context handoff | ||
| 964 | - context-window and token indicators | ||
| 965 | - plans, todos, approvals, user input, checkpoints, and rollback state | ||
| 918 | 966 | ||
| 919 | Rendering must preserve live updates without duplicating rows as attempts or provider items change. Large output should remain inspectable without overwhelming the conversation timeline. | 967 | The ordered chat remains item-based. Runs, attempts, nodes, provider state, runtime requests, checkpoints, and transfers are supporting relational state; they enrich specific rows, controls, and inspectors rather than becoming additional reconstructed chat feeds. |
| 920 | 968 | ||
| 921 | ### 4. Expose runs, attempts, and recovery | 969 | Keep primary chat status simple. Detailed execution state belongs in targeted or expandable surfaces. Rendering must preserve live updates without duplicating rows as attempts or provider items change, and large output must remain inspectable without overwhelming the timeline. |
| 922 | 970 | ||
| 923 | Add UI affordances for: | 971 | ### 5. Expose relationships and V2-native workflows |
| 924 | 972 | ||
| 925 | - queued, steering, restarting, and active runs | 973 | Build environment-scoped, cycle-safe graph/lineage derivation from shell threads, thread lineage, execution nodes, subagents, forks, and context transfers. It must support: |
| 926 | - attempt history and retry reasons | ||
| 927 | - execution-node progress | ||
| 928 | - provider session/thread identity where diagnostically useful | ||
| 929 | - native resume versus portable context handoff | ||
| 930 | - context-window and token indicators | ||
| 931 | - recoverable versus terminal failure state | ||
| 932 | 974 | ||
| 933 | Keep the primary chat status simple. Detailed runtime state belongs in expandable inspection surfaces rather than permanently crowding the composer or message list. | 975 | - root, parent, child, fork, and subagent relationships |
| 976 | - navigation between related threads using environment-qualified references | ||
| 977 | - per-node run and terminal status | ||
| 978 | - merge-back and context-transfer state | ||
| 979 | - archived or deleted related threads | ||
| 980 | - missing-parent and partial-replay fallback | ||
| 934 | 981 | ||
| 935 | ### 5. Add V2-native workflows | 982 | Web should provide an efficient tree or graph surface without replacing the ordinary project/thread sidebar. Mobile should expose the same domain relationships through a form-factor-appropriate drill-down rather than copying the desktop visualization. |
| 936 | 983 | ||
| 937 | Integrate: | 984 | Integrate capability-gated workflows for: |
| 938 | 985 | ||
| 939 | - thread fork and merge-back | 986 | - thread fork and merge-back |
| 940 | - provider switching and provider-session detach | 987 | - provider switching and provider-session detach |
| 941 | - advanced queue reorder and promotion | 988 | - advanced queue reorder and promotion |
| 942 | - subagent navigation and delegated-task state | 989 | - subagent navigation and delegated-task state |
| 943 | - plan/todo progress | 990 | - plan/todo progress |
| 944 | - checkpoint scopes, diff, and rollback | 991 | - checkpoint scope, diff, and rollback |
| 945 | - context handoff and transfer visibility | 992 | - context handoff and transfer visibility |
| 946 | 993 | ||
| 947 | Each action must be capability-gated from V2 projection/runtime policy rather than provider-name checks in the UI. | 994 | Actions derive from V2 capability and runtime policy. They must not use provider-name conditionals. |
| 995 | |||
| 996 | ### 6. Retire parity state and finish cleanup | ||
| 997 | |||
| 998 | After web and mobile no longer depend on the Shape 4.0 full-thread parity facade: | ||
| 948 | 999 | ||
| 949 | ### 6. Finish web/mobile reuse and cleanup | 1000 | - remove `presentThread`-style whole-projection remapping and the shell/detail reconciliation it required |
| 1001 | - give the environment-scoped projection wrapper the final unversioned client name if doing so improves clarity | ||
| 1002 | - remove message/work/plan feed reconstruction and other obsolete transformations from `session-logic.ts`, mobile `threadActivity.ts`, and equivalent modules while retaining genuinely platform-specific presentation helpers | ||
| 1003 | - remove unused V1 client RPC contracts and compatibility exports | ||
| 1004 | - split still-required project application contracts and frozen legacy thread decoders into clear ownership boundaries instead of deleting reusable application infrastructure | ||
| 1005 | - delete the V2 debug route after its useful inspection and rendering surfaces have production homes | ||
| 950 | 1006 | ||
| 951 | - Use one shared presentation derivation for web and mobile. | 1007 | No production client should end with both a complete `OrchestrationV2ThreadProjection` and a second complete thread presentation object containing copied messages, work entries, runs, plans, requests, and checkpoints. |
| 952 | - Keep platform-specific visual components where appropriate, but do not duplicate event interpretation. | ||
| 953 | - Remove obsolete V1 `session-logic.ts`, mobile `threadActivity.ts`, and equivalent transformations after their consumers are gone. | ||
| 954 | - Remove unused V1 client RPC contracts and compatibility exports. | ||
| 955 | - Split still-required project application contracts and frozen legacy thread decoders into clear ownership boundaries instead of deleting reusable application infrastructure. | ||
| 956 | - Delete the V2 debug route after its inspection tools and useful renderers have production homes. | ||
| 957 | - Rename V2 production-facing client types to unversioned names only if the rename reduces long-term ambiguity without obscuring the retained application event-source contracts. | ||
| 958 | 1008 | ||
| 959 | ### Shape 4.5 tests | 1009 | ### Shape 4.5 tests |
| 960 | 1010 | ||
| 961 | Add shared presentation tests for: | 1011 | Add client-runtime state and reducer coverage for: |
| 962 | 1012 | ||
| 963 | - every known V2 turn-item type and the dynamic fallback | 1013 | - environment-scoped detail identity without copying or mutating the server projection |
| 964 | - graph construction for roots, forks, subagents, merges, archives, and missing nodes | 1014 | - cached, synchronizing, live, deleted, and error state independent of projection content |
| 965 | - incremental updates without duplicate graph nodes or timeline rows | 1015 | - preservation of local, inherited, and synthetic `visibleTurnItems` |
| 966 | - run attempts, retry, recovery, and interruption presentation | 1016 | - unchanged `visibleTurnItems` identity for run/attempt updates that do not change visibility |
| 967 | - provider-session transition and context-handoff presentation | 1017 | - single-row replacement and unaffected-row identity for streaming item updates |
| 968 | - plans, todos, checkpoints, and rollback | 1018 | - membership changes, removal, and position renumbering without losing inherited or synthetic rows |
| 1019 | - any targeted entity lookup or shared domain selector introduced by a concrete consumer | ||
| 1020 | - environment-scoped graph construction for roots, forks, subagents, merges, archives, missing nodes, and cycles | ||
| 969 | - capability-gated action availability | 1021 | - capability-gated action availability |
| 970 | 1022 | ||
| 971 | Add web and mobile integration coverage for: | 1023 | Add web and mobile integration coverage for: |
| 972 | 1024 | ||
| 1025 | - every known V2 turn-item type and the structured dynamic fallback | ||
| 1026 | - interruption request, intervening activity, and interruption result in committed order | ||
| 1027 | - inherited conversation items and synthetic fork markers | ||
| 1028 | - rich tool expansion and live updates without duplicate rows | ||
| 1029 | - request response capability, checkpoint rollback, and other targeted item/entity joins | ||
| 973 | - related-thread navigation | 1030 | - related-thread navigation |
| 974 | - rich tool-call expansion and live updates | ||
| 975 | - fork and merge-back | 1031 | - fork and merge-back |
| 976 | - subagent and delegated-task inspection | 1032 | - subagent and delegated-task inspection |
| 977 | - queue reorder and promotion | 1033 | - queue reorder and promotion |
| 978 | - provider switch and detach | 1034 | - provider switch and detach |
| 979 | - context handoff | 1035 | - context handoff |
| 980 | - reconnect while graph, tool, or attempt state is changing | 1036 | - reconnect while item, graph, tool, request, or attempt state is changing |
| 1037 | - all existing Shape 4.0 product flows | ||
| 981 | 1038 | ||
| 982 | ### Shape 4.5 exit gate | 1039 | ### Shape 4.5 exit gate |
| 983 | 1040 | ||
| 984 | - Web and mobile remain fully usable for every Shape 4.0 flow. | 1041 | - Web and mobile remain fully usable for every Shape 4.0 flow. |
| 985 | - Thread lineage, forks, subagents, and merge relationships are navigable. | 1042 | - Production web and mobile conversations render from server-authoritative `visibleTurnItems`; neither reconstructs the chat by merging separate message, plan, and work-entry collections. |
| 986 | - Typed tool calls and their structured results are visible without losing provider-native fallback data. | 1043 | - Thread detail values carry explicit environment scope while retaining the pristine V2 projection and nested entity identities. |
| 987 | - Runs, attempts, retries, recovery, plans, checkpoints, and handoffs have coherent presentation. | 1044 | - Shell/sidebar and thread-detail state remain independently subscribable; ordinary detail traffic does not invalidate the project/thread shell. |
| 1045 | - Granular timeline components consume the item atom and targeted entity lookups rather than subscribing to the whole projection. | ||
| 1046 | - Run/attempt updates with unchanged visibility preserve `visibleTurnItems` identity, while streaming item updates replace only the affected row and remain visible. | ||
| 1047 | - Local, inherited, and synthetic items survive snapshot, incremental update, cache restore, and reconnect. | ||
| 1048 | - Typed tool calls and structured results are visible without losing provider-native fallback data. | ||
| 1049 | - Interruption request, intervening activity, and interruption result remain independently visible and correctly ordered. | ||
| 1050 | - Runs, attempts, retries, recovery, plans, checkpoints, requests, handoffs, and context transfers have coherent targeted presentation without becoming a second reconstructed chat model. | ||
| 1051 | - Thread lineage, forks, subagents, merge relationships, and environment-qualified navigation are available on web and mobile in platform-appropriate forms. | ||
| 988 | - New V2 capabilities are capability-gated and do not rely on provider-name conditionals. | 1052 | - New V2 capabilities are capability-gated and do not rely on provider-name conditionals. |
| 989 | - Web and mobile share event interpretation and presentation derivation. | 1053 | - Web and mobile retain separate React bindings and presentation policy while consuming the same V2 projection/reducer semantics. |
| 1054 | - No production client maintains a complete parallel thread presentation model beside `OrchestrationV2ThreadProjection`; the Shape 4.0 parity facade is removed from production consumers. | ||
| 990 | - No production TypeScript import references a V1 client orchestration type or endpoint. | 1055 | - No production TypeScript import references a V1 client orchestration type or endpoint. |
| 1056 | - The V2 debug route is removed after its useful production surfaces are integrated. | ||
| 991 | - `vp check`, `vp run typecheck`, and `vp test` pass. | 1057 | - `vp check`, `vp run typecheck`, and `vp test` pass. |
| 992 | - `vp run lint:mobile` passes for mobile changes. | 1058 | - `vp run lint:mobile` passes for mobile changes. |
| 993 | 1059 | ||
| @@ -1011,7 +1077,8 @@ Nothing in Shape 4.0 or 4.5 may destroy, rewrite, or make assumptions that close · 17 unmodified lines | |||
| 1011 | At the end of Shape 4.5: | 1077 | At the end of Shape 4.5: |
| 1012 | 1078 | ||
| 1013 | - V2 is the only live agent orchestration system from provider process through web and mobile UI. | 1079 | - V2 is the only live agent orchestration system from provider process through web and mobile UI. |
| 1014 | - The existing product experience works on V2, and V2-native graph, execution, tool, and context information is available in the application. | 1080 | - The existing product experience works on V2, and production conversations render the authoritative environment-scoped V2 item sequence without a parallel full-thread presentation model. |
| 1081 | - V2-native graph, execution, tool, request, interruption, and context information is available through targeted platform-appropriate surfaces. | ||
| 1015 | - V1 runtime, server endpoints, production client state, and production client operations are removed. | 1082 | - V1 runtime, server endpoints, production client state, and production client operations are removed. |
| 1016 | - Reusable application event-sourcing and platform infrastructure remain. | 1083 | - Reusable application event-sourcing and platform infrastructure remain. |
| 1017 | - Legacy database rows, provider continuation data, attachments, and other migration inputs remain untouched and read-only. | 1084 | - Legacy database rows, provider continuation data, attachments, and other migration inputs remain untouched and read-only. |
| @@ -2,6 +2,11 @@ import { · 1 unmodified line | |||
| 2 | ConnectionPersistenceError, | 2 | ConnectionPersistenceError, |
| 3 | ConnectionRegistrationStore, | 3 | ConnectionRegistrationStore, |
| 4 | ConnectionTargetStore, | 4 | ConnectionTargetStore, |
| 5 | EnvironmentCacheStore, | ||
| 6 | ORCHESTRATION_CACHE_SCHEMA_VERSION, | ||
| 7 | StoredOrchestrationShellSnapshot, | ||
| 8 | StoredOrchestrationThreadSnapshot, | ||
| 9 | decodeOrDiscardOrchestrationCache, | ||
| 5 | registerConnectionInCatalog, | 10 | registerConnectionInCatalog, |
| 6 | removeConnectionFromCatalog, | 11 | removeConnectionFromCatalog, |
| 7 | removeCatalogValue, | 12 | removeCatalogValue, |
| @@ -13,11 +18,88 @@ import { · 5 unmodified lines | |||
| 13 | CredentialStore, | 18 | CredentialStore, |
| 14 | ProfileStore, | 19 | ProfileStore, |
| 15 | } from "@t3tools/client-runtime/connection"; | 20 | } from "@t3tools/client-runtime/connection"; |
| 21 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 16 | import * as Context from "effect/Context"; | 22 | import * as Context from "effect/Context"; |
| 17 | import * as Effect from "effect/Effect"; | 23 | import * as Effect from "effect/Effect"; |
| 18 | import * as Layer from "effect/Layer"; | 24 | import * as Layer from "effect/Layer"; |
| 19 | import * as Option from "effect/Option"; | 25 | import * as Option from "effect/Option"; |
| 20 | import * as CatalogStore from "./catalog-store"; | 26 | import * as Schema from "effect/Schema"; |
| 27 | import * as SecureStore from "expo-secure-store"; | ||
| 28 | |||
| 29 | import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; | ||
| 30 | |||
| 31 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; | ||
| 32 | const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; | ||
| 33 | const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; | ||
| 34 | |||
| 35 | const StoredShellSnapshot = StoredOrchestrationShellSnapshot; | ||
| 36 | const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; | ||
| 37 | const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot); | ||
| 38 | const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot); | ||
| 39 | const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot); | ||
| 40 | const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot); | ||
| 41 | |||
| 42 | function catalogError(operation: string, cause: unknown) { | ||
| 43 | return new ConnectionTransientError({ | ||
| 44 | reason: "remote-unavailable", | ||
| 45 | detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, | ||
| 46 | }); | ||
| 47 | } | ||
| 48 | |||
| 49 | function shellPersistenceError( | ||
| 50 | operation: | ||
| 51 | | "load-shell" | ||
| 52 | | "save-shell" | ||
| 53 | | "load-thread" | ||
| 54 | | "save-thread" | ||
| 55 | | "remove-thread" | ||
| 56 | | "clear-environment", | ||
| 57 | cause: unknown, | ||
| 58 | ) { | ||
| 59 | return new ConnectionPersistenceError({ | ||
| 60 | operation, | ||
| 61 | message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | |||
| 65 | function threadSnapshotFileName(threadId: ThreadId): string { | ||
| 66 | return `${encodeURIComponent(threadId)}.json`; | ||
| 67 | } | ||
| 68 | |||
| 69 | const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( | ||
| 70 | function* ( | ||
| 71 | environmentId: EnvironmentId, | ||
| 72 | operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", | ||
| 73 | ) { | ||
| 74 | return yield* Effect.tryPromise({ | ||
| 75 | try: async () => { | ||
| 76 | const { Directory, Paths } = await import("expo-file-system"); | ||
| 77 | const directory = new Directory( | ||
| 78 | Paths.document, | ||
| 79 | THREAD_SNAPSHOT_CACHE_DIRECTORY, | ||
| 80 | encodeURIComponent(environmentId), | ||
| 81 | ); | ||
| 82 | if (operation !== "clear-environment") { | ||
| 83 | directory.create({ idempotent: true, intermediates: true }); | ||
| 84 | } | ||
| 85 | return directory; | ||
| 86 | }, | ||
| 87 | catch: (cause) => shellPersistenceError(operation, cause), | ||
| 88 | }); | ||
| 89 | }, | ||
| 90 | ); | ||
| 91 | |||
| 92 | const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( | ||
| 93 | environmentId: EnvironmentId, | ||
| 94 | threadId: ThreadId, | ||
| 95 | operation: "load-thread" | "save-thread" | "remove-thread", | ||
| 96 | ) { | ||
| 97 | const { File } = yield* Effect.promise(() => import("expo-file-system")); | ||
| 98 | return new File( | ||
| 99 | yield* threadSnapshotDirectory(environmentId, operation), | ||
| 100 | threadSnapshotFileName(threadId), | ||
| 101 | ); | ||
| 102 | }); | ||
| 21 | 103 | ||
| 22 | function targetPersistenceError( | 104 | function targetPersistenceError( |
| 23 | operation: "list-targets" | "register-connection" | "remove-connection", | 105 | operation: "list-targets" | "register-connection" | "remove-connection", |
| @@ -128,6 +210,172 @@ export const connectionStorageLayer = Layer.effectContext( · 104 unmodified lines | |||
| 128 | ), | 210 | ), |
| 129 | })), | 211 | })), |
| 130 | }); | 212 | }); |
| 213 | const cacheStore = EnvironmentCacheStore.of({ | ||
| 214 | loadShell: (environmentId) => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const file = yield* shellSnapshotFile(environmentId, "load-shell"); | ||
| 217 | if (file.exists) { | ||
| 218 | const raw = yield* Effect.tryPromise({ | ||
| 219 | try: () => file.text(), | ||
| 220 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 221 | }); | ||
| 222 | const parsed = yield* Effect.try({ | ||
| 223 | try: () => JSON.parse(raw) as unknown, | ||
| 224 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 225 | }); | ||
| 226 | const stored = yield* Effect.fromResult(decodeStoredShellSnapshot(parsed)).pipe( | ||
| 227 | Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), | ||
| 228 | ); | ||
| 229 | return stored.environmentId === environmentId | ||
| 230 | ? Option.some(stored.snapshot) | ||
| 231 | : Option.none(); | ||
| 232 | } | ||
| 233 | |||
| 234 | const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); | ||
| 235 | if (legacyFile.exists) { | ||
| 236 | yield* Effect.try({ | ||
| 237 | try: () => legacyFile.delete(), | ||
| 238 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 239 | }); | ||
| 240 | } | ||
| 241 | |||
| 242 | return Option.none(); | ||
| 243 | }).pipe((decode) => | ||
| 244 | decodeOrDiscardOrchestrationCache( | ||
| 245 | decode, | ||
| 246 | shellSnapshotFile(environmentId, "load-shell").pipe( | ||
| 247 | Effect.flatMap((file) => | ||
| 248 | Effect.try({ | ||
| 249 | try: () => { | ||
| 250 | if (file.exists) file.delete(); | ||
| 251 | }, | ||
| 252 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 253 | }), | ||
| 254 | ), | ||
| 255 | Effect.catch(() => Effect.void), | ||
| 256 | ), | ||
| 257 | ), | ||
| 258 | ), | ||
| 259 | saveShell: (environmentId, snapshot) => | ||
| 260 | Effect.gen(function* () { | ||
| 261 | const file = yield* shellSnapshotFile(environmentId, "save-shell"); | ||
| 262 | const stored = { | ||
| 263 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | ||
| 264 | environmentId, | ||
| 265 | snapshot, | ||
| 266 | } as const; | ||
| 267 | const encoded = yield* Effect.fromResult(encodeStoredShellSnapshot(stored)).pipe( | ||
| 268 | Effect.mapError((cause) => shellPersistenceError("save-shell", cause)), | ||
| 269 | ); | ||
| 270 | yield* Effect.try({ | ||
| 271 | try: () => { | ||
| 272 | if (!file.exists) { | ||
| 273 | file.create({ intermediates: true, overwrite: true }); | ||
| 274 | } | ||
| 275 | file.write(JSON.stringify(encoded)); | ||
| 276 | }, | ||
| 277 | catch: (cause) => shellPersistenceError("save-shell", cause), | ||
| 278 | }); | ||
| 279 | }), | ||
| 280 | loadThread: (environmentId, threadId) => | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); | ||
| 283 | if (!file.exists) { | ||
| 284 | return Option.none(); | ||
| 285 | } | ||
| 286 | const raw = yield* Effect.tryPromise({ | ||
| 287 | try: () => file.text(), | ||
| 288 | catch: (cause) => shellPersistenceError("load-thread", cause), | ||
| 289 | }); | ||
| 290 | const parsed = yield* Effect.try({ | ||
| 291 | try: () => JSON.parse(raw) as unknown, | ||
| 292 | catch: (cause) => shellPersistenceError("load-thread", cause), | ||
| 293 | }); | ||
| 294 | const stored = yield* Effect.fromResult(decodeStoredThreadSnapshot(parsed)).pipe( | ||
| 295 | Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), | ||
| 296 | ); | ||
| 297 | return stored.environmentId === environmentId && stored.threadId === threadId | ||
| 298 | ? Option.some(stored.thread) | ||
| 299 | : Option.none(); | ||
| 300 | }).pipe((decode) => | ||
| 301 | decodeOrDiscardOrchestrationCache( | ||
| 302 | decode, | ||
| 303 | threadSnapshotFile(environmentId, threadId, "load-thread").pipe( | ||
| 304 | Effect.flatMap((file) => | ||
| 305 | Effect.try({ | ||
| 306 | try: () => { | ||
| 307 | if (file.exists) file.delete(); | ||
| 308 | }, | ||
| 309 | catch: (cause) => shellPersistenceError("load-thread", cause), | ||
| 310 | }), | ||
| 311 | ), | ||
| 312 | Effect.catch(() => Effect.void), | ||
| 313 | ), | ||
| 314 | ), | ||
| 315 | ), | ||
| 316 | saveThread: (environmentId, thread) => | ||
| 317 | Effect.gen(function* () { | ||
| 318 | const file = yield* threadSnapshotFile(environmentId, thread.thread.id, "save-thread"); | ||
| 319 | const encoded = yield* Effect.fromResult( | ||
| 320 | encodeStoredThreadSnapshot({ | ||
| 321 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | ||
| 322 | environmentId, | ||
| 323 | threadId: thread.thread.id, | ||
| 324 | thread, | ||
| 325 | }), | ||
| 326 | ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); | ||
| 327 | yield* Effect.try({ | ||
| 328 | try: () => { | ||
| 329 | if (!file.exists) { | ||
| 330 | file.create({ intermediates: true, overwrite: true }); | ||
| 331 | } | ||
| 332 | file.write(JSON.stringify(encoded)); | ||
| 333 | }, | ||
| 334 | catch: (cause) => shellPersistenceError("save-thread", cause), | ||
| 335 | }); | ||
| 336 | }), | ||
| 337 | removeThread: (environmentId, threadId) => | ||
| 338 | Effect.gen(function* () { | ||
| 339 | const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); | ||
| 340 | if (file.exists) { | ||
| 341 | file.delete(); | ||
| 342 | } | ||
| 343 | }).pipe( | ||
| 344 | Effect.mapError((cause) => | ||
| 345 | cause._tag === "ConnectionPersistenceError" | ||
| 346 | ? cause | ||
| 347 | : shellPersistenceError("remove-thread", cause), | ||
| 348 | ), | ||
| 349 | ), | ||
| 350 | clear: (environmentId) => | ||
| 351 | Effect.gen(function* () { | ||
| 352 | const file = yield* shellSnapshotFile(environmentId, "clear-environment"); | ||
| 353 | if (file.exists) { | ||
| 354 | yield* Effect.try({ | ||
| 355 | try: () => file.delete(), | ||
| 356 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 357 | }); | ||
| 358 | } | ||
| 359 | const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); | ||
| 360 | if (legacyFile.exists) { | ||
| 361 | yield* Effect.try({ | ||
| 362 | try: () => legacyFile.delete(), | ||
| 363 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 364 | }); | ||
| 365 | } | ||
| 366 | const threadDirectory = yield* threadSnapshotDirectory( | ||
| 367 | environmentId, | ||
| 368 | "clear-environment", | ||
| 369 | ); | ||
| 370 | if (threadDirectory.exists) { | ||
| 371 | yield* Effect.try({ | ||
| 372 | try: () => threadDirectory.delete(), | ||
| 373 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 374 | }); | ||
| 375 | } | ||
| 376 | }), | ||
| 377 | }); | ||
| 378 | |||
| 131 | return Context.make(ConnectionTargetStore, targetStore).pipe( | 379 | return Context.make(ConnectionTargetStore, targetStore).pipe( |
| 132 | Context.add(ConnectionRegistrationStore, registrationStore), | 380 | Context.add(ConnectionRegistrationStore, registrationStore), |
| 133 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), | 381 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), |
| @@ -1,9 +1,11 @@ | |||
| 1 | import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; | 1 | import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; |
| 2 | import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts"; | 2 | import type { OrchestrationProjectShell, OrchestrationV2ThreadShell } from "@t3tools/contracts"; |
| 3 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 3 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 4 | import { describe, expect, it } from "vite-plus/test"; | 4 | import { describe, expect, it } from "vite-plus/test"; |
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 5 | 6 | ||
| 6 | import { buildArchivedThreadGroups } from "./archivedThreadList"; | 7 | import { buildArchivedThreadGroups } from "./archivedThreadList"; |
| 8 | import { makeRawThreadShell } from "../../test-fixtures"; | ||
| 7 | 9 | ||
| 8 | const environmentId = EnvironmentId.make("environment-1"); | 10 | const environmentId = EnvironmentId.make("environment-1"); |
| 9 | 11 | ||
| @@ -22,42 +24,30 @@ function makeProject( · 12 unmodified lines | |||
| 22 | } | 24 | } |
| 23 | 25 | ||
| 24 | function makeThread( | 26 | function makeThread( |
| 25 | input: Partial<OrchestrationThreadShell> & | 27 | input: Pick<OrchestrationV2ThreadShell, "id" | "projectId" | "title"> & { |
| 26 | Pick<OrchestrationThreadShell, "id" | "projectId" | "title">, | 28 | readonly branch?: string | null; |
| 27 | ): OrchestrationThreadShell { | 29 | readonly archivedAt?: string | null; |
| 28 | return { | 30 | }, |
| 29 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 31 | ): OrchestrationV2ThreadShell { |
| 30 | runtimeMode: "full-access", | 32 | const archivedAt = input.archivedAt === undefined ? "2026-06-02T00:00:00.000Z" : input.archivedAt; |
| 31 | interactionMode: "default", | 33 | return makeRawThreadShell({ |
| 32 | branch: null, | ||
| 33 | worktreePath: null, | ||
| 34 | latestTurn: null, | ||
| 35 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 36 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 37 | archivedAt: "2026-06-02T00:00:00.000Z", | ||
| 38 | session: null, | ||
| 39 | latestUserMessageAt: null, | ||
| 40 | hasPendingApprovals: false, | ||
| 41 | hasPendingUserInput: false, | ||
| 42 | hasActionableProposedPlan: false, | ||
| 43 | ...input, | 34 | ...input, |
| 44 | settledOverride: input.settledOverride ?? null, | 35 | archivedAt: archivedAt === null ? null : DateTime.makeUnsafe(archivedAt), |
| 45 | settledAt: input.settledAt ?? null, | 36 | }); |
| 46 | }; | ||
| 47 | } | 37 | } |
| 48 | 38 | ||
| 49 | function makeSnapshot( | 39 | function makeSnapshot( |
| 50 | projects: ReadonlyArray<OrchestrationProjectShell>, | 40 | projects: ReadonlyArray<OrchestrationProjectShell>, |
| 51 | threads: ReadonlyArray<OrchestrationThreadShell>, | 41 | threads: ReadonlyArray<OrchestrationV2ThreadShell>, |
| 52 | targetEnvironmentId = environmentId, | 42 | targetEnvironmentId = environmentId, |
| 53 | ): ArchivedSnapshotEntry { | 43 | ): ArchivedSnapshotEntry { |
| 54 | return { | 44 | return { |
| 55 | environmentId: targetEnvironmentId, | 45 | environmentId: targetEnvironmentId, |
| 56 | snapshot: { | 46 | snapshot: { |
| 47 | schemaVersion: 1, | ||
| 57 | snapshotSequence: 1, | 48 | snapshotSequence: 1, |
| 58 | projects, | 49 | projects, |
| 59 | threads, | 50 | threads, |
| 60 | updatedAt: "2026-06-04T00:00:00.000Z", | ||
| 61 | }, | 51 | }, |
| 62 | }; | 52 | }; |
| 63 | } | 53 | } |
| @@ -118,14 +118,51 @@ interface HomeScreenProps { · 117 unmodified lines | |||
| 118 | 118 | ||
| 119 | /* ─── Layout constants ───────────────────────────────────────────────── */ | 119 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| 120 | 120 | ||
| 121 | const ESTIMATED_THREAD_ROW_HEIGHT = 72; | 121 | function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { |
| 122 | const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; | 122 | switch (thread.runtime?.status) { |
| 123 | /** | 123 | case "running": |
| 124 | * Top spacing between the list and the Android custom header. The Android | 124 | case "waiting": |
| 125 | * header (AndroidHomeHeader) is rendered in-flow above this screen and | 125 | return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; |
| 126 | * already consumes the top safe-area inset, so the list only needs breathing | 126 | case "completed": |
| 127 | * room here. | 127 | return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" }; |
| 128 | */ | 128 | case "queued": |
| 129 | case "starting": | ||
| 130 | return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" }; | ||
| 131 | case "failed": | ||
| 132 | return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" }; | ||
| 133 | default: | ||
| 134 | return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" }; | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | const COLLAPSED_THREAD_LIMIT = 6; | ||
| 139 | const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); | ||
| 140 | |||
| 141 | function threadRowExit(values: ExitAnimationsValues) { | ||
| 142 | "worklet"; | ||
| 143 | |||
| 144 | return { | ||
| 145 | initialValues: { | ||
| 146 | height: values.currentHeight, | ||
| 147 | opacity: 1, | ||
| 148 | originX: values.currentOriginX, | ||
| 149 | }, | ||
| 150 | animations: { | ||
| 151 | height: withDelay( | ||
| 152 | 90, | ||
| 153 | withTiming(0, { | ||
| 154 | duration: 170, | ||
| 155 | easing: Easing.inOut(Easing.cubic), | ||
| 156 | }), | ||
| 157 | ), | ||
| 158 | opacity: withDelay(80, withTiming(0, { duration: 100 })), | ||
| 159 | originX: withTiming(values.currentOriginX - values.windowWidth, { | ||
| 160 | duration: 190, | ||
| 161 | easing: Easing.out(Easing.cubic), | ||
| 162 | }), | ||
| 163 | }, | ||
| 164 | }; | ||
| 165 | } | ||
| 129 | 166 | ||
| 130 | function deriveEmptyState(props: { | 167 | function deriveEmptyState(props: { |
| 131 | readonly catalogState: WorkspaceState; | 168 | readonly catalogState: WorkspaceState; |
| @@ -185,7 +222,7 @@ function deriveEmptyState(props: { · 53 unmodified lines | |||
| 185 | 222 | ||
| 186 | return { | 223 | return { |
| 187 | title: "No threads yet", | 224 | title: "No threads yet", |
| 188 | detail: "Create a task to start a new coding session in one of your connected projects.", | 225 | detail: "Create a task to start a new coding runtime in one of your connected projects.", |
| 189 | loading: false, | 226 | loading: false, |
| 190 | }; | 227 | }; |
| 191 | } | 228 | } |
| @@ -971,25 +1008,17 @@ export function HomeScreen(props: HomeScreenProps) { · 779 unmodified lines | |||
| 971 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | 1008 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} |
| 972 | variant="plain" | 1009 | variant="plain" |
| 973 | /> | 1010 | /> |
| 974 | {emptyState.loading && !shouldShowConnectionStatus ? ( | 1011 | ) : !hasResults ? ( |
| 975 | <View className="mt-4 items-center"> | 1012 | <EmptyState |
| 976 | <ActivityIndicator color={accentColor} /> | 1013 | title="No threads yet" |
| 977 | </View> | 1014 | detail="Create a task to start a new coding runtime." |
| 978 | ) : null} | 1015 | /> |
| 979 | {shouldShowConnectionStatus && Platform.OS === "ios" ? ( | 1016 | ) : ( |
| 980 | <View className="mt-4"> | 1017 | projectGroups.map((group) => { |
| 981 | <WorkspaceConnectionStatus | 1018 | const isExpanded = expandedProjects.has(group.key); |
| 982 | state={props.catalogState} | 1019 | const visibleThreads = isExpanded |
| 983 | onPress={props.onOpenEnvironments} | 1020 | ? group.threads |
| 984 | variant="sidebar" | 1021 | : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); |
| 985 | /> | ||
| 986 | </View> | ||
| 987 | ) : null} | ||
| 988 | </View> | ||
| 989 | {connectionStatus} | ||
| 990 | </View> | ||
| 991 | ); | ||
| 992 | } | ||
| 993 | 1022 | ||
| 994 | const listHeader = ( | 1023 | const listHeader = ( |
| 995 | <> | 1024 | <> |
| @@ -6,11 +6,8 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc · 5 unmodified lines | |||
| 6 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 6 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 8 | ||
| 9 | import { | 9 | import { buildHomeThreadGroups } from "./homeThreadList"; |
| 10 | buildHomeProjectScopes, | 10 | import { makeThreadShellFixture } from "../../test-fixtures"; |
| 11 | buildHomeThreadGroups, | ||
| 12 | sortHomeProjectScopes, | ||
| 13 | } from "./homeThreadList"; | ||
| 14 | 11 | ||
| 15 | function makeProject( | 12 | function makeProject( |
| 16 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, | 13 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| @@ -30,25 +27,21 @@ function makeThread( · 13 unmodified lines | |||
| 30 | input: Partial<EnvironmentThreadShell> & | 27 | input: Partial<EnvironmentThreadShell> & |
| 31 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">, | 28 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">, |
| 32 | ): EnvironmentThreadShell { | 29 | ): EnvironmentThreadShell { |
| 33 | return { | 30 | return makeThreadShellFixture({ |
| 34 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 31 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 35 | runtimeMode: "full-access", | 32 | runtimeMode: "full-access", |
| 36 | interactionMode: "default", | 33 | interactionMode: "default", |
| 37 | branch: null, | 34 | branch: null, |
| 38 | worktreePath: null, | 35 | worktreePath: null, |
| 39 | latestTurn: null, | ||
| 40 | createdAt: "2026-06-01T00:00:00.000Z", | 36 | createdAt: "2026-06-01T00:00:00.000Z", |
| 41 | updatedAt: "2026-06-01T00:00:00.000Z", | 37 | updatedAt: "2026-06-01T00:00:00.000Z", |
| 42 | archivedAt: null, | 38 | archivedAt: null, |
| 43 | session: null, | ||
| 44 | latestUserMessageAt: null, | 39 | latestUserMessageAt: null, |
| 45 | hasPendingApprovals: false, | 40 | hasPendingApprovals: false, |
| 46 | hasPendingUserInput: false, | 41 | hasPendingUserInput: false, |
| 47 | hasActionableProposedPlan: false, | 42 | hasActionableProposedPlan: false, |
| 48 | ...input, | 43 | ...input, |
| 49 | settledOverride: input.settledOverride ?? null, | 44 | }); |
| 50 | settledAt: input.settledAt ?? null, | ||
| 51 | }; | ||
| 52 | } | 45 | } |
| 53 | 46 | ||
| 54 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); | 47 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); |
| @@ -1,11 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contracts"; |
| 4 | MessageId, | 4 | import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell"; |
| 5 | TurnId, | ||
| 6 | type OrchestrationCheckpointSummary, | ||
| 7 | type ReviewDiffPreviewSource, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | 5 | ||
| 10 | import { | 6 | import { |
| 11 | buildReviewListItems, | 7 | buildReviewListItems, |
| @@ -18,9 +14,9 @@ import { · 6 unmodified lines | |||
| 18 | } from "./reviewModel"; | 14 | } from "./reviewModel"; |
| 19 | 15 | ||
| 20 | function makeCheckpoint( | 16 | function makeCheckpoint( |
| 21 | input: Partial<OrchestrationCheckpointSummary> & | 17 | input: Partial<ThreadCheckpointSummary> & |
| 22 | Pick<OrchestrationCheckpointSummary, "turnId" | "checkpointTurnCount" | "completedAt">, | 18 | Pick<ThreadCheckpointSummary, "runId" | "checkpointTurnCount" | "completedAt">, |
| 23 | ): OrchestrationCheckpointSummary { | 19 | ): ThreadCheckpointSummary { |
| 24 | return { | 20 | return { |
| 25 | checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any, | 21 | checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any, |
| 26 | status: "ready", | 22 | status: "ready", |
| @@ -52,12 +48,12 @@ describe("buildReviewSectionItems", () => { · 25 unmodified lines | |||
| 52 | it("keeps one chip per checkpoint and appends git sources", () => { | 48 | it("keeps one chip per checkpoint and appends git sources", () => { |
| 53 | const checkpoints = [ | 49 | const checkpoints = [ |
| 54 | makeCheckpoint({ | 50 | makeCheckpoint({ |
| 55 | turnId: TurnId.make("turn-1"), | 51 | runId: RunId.make("run-1"), |
| 56 | checkpointTurnCount: 1, | 52 | checkpointTurnCount: 1, |
| 57 | completedAt: "2026-04-01T00:00:00.000Z", | 53 | completedAt: "2026-04-01T00:00:00.000Z", |
| 58 | }), | 54 | }), |
| 59 | makeCheckpoint({ | 55 | makeCheckpoint({ |
| 60 | turnId: TurnId.make("turn-2"), | 56 | runId: RunId.make("run-2"), |
| 61 | checkpointTurnCount: 2, | 57 | checkpointTurnCount: 2, |
| 62 | completedAt: "2026-04-02T00:00:00.000Z", | 58 | completedAt: "2026-04-02T00:00:00.000Z", |
| 63 | }), | 59 | }), |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; |
| 2 | import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; | 2 | import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; |
| 3 | import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; | 3 | import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell"; |
| 4 | import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; | ||
| 4 | import * as Arr from "effect/Array"; | 5 | import * as Arr from "effect/Array"; |
| 5 | import { pipe } from "effect/Function"; | 6 | import { pipe } from "effect/Function"; |
| 6 | import * as Order from "effect/Order"; | 7 | import * as Order from "effect/Order"; |
| @@ -128,11 +129,11 @@ export type ReviewParsedDiff = · 121 unmodified lines | |||
| 128 | readonly notice: string | null; | 129 | readonly notice: string | null; |
| 129 | }; | 130 | }; |
| 130 | 131 | ||
| 131 | function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string { | 132 | function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { |
| 132 | return `Turn ${checkpoint.checkpointTurnCount}`; | 133 | return `Turn ${checkpoint.checkpointTurnCount}`; |
| 133 | } | 134 | } |
| 134 | 135 | ||
| 135 | function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string { | 136 | function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { |
| 136 | const fileCount = checkpoint.files.length; | 137 | const fileCount = checkpoint.files.length; |
| 137 | if (checkpoint.status !== "ready") { | 138 | if (checkpoint.status !== "ready") { |
| 138 | return `Diff ${checkpoint.status}`; | 139 | return `Diff ${checkpoint.status}`; |
| @@ -141,8 +142,8 @@ function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string · 2 unmodified lines | |||
| 141 | } | 142 | } |
| 142 | 143 | ||
| 143 | function compareCheckpointTurnCountDescending( | 144 | function compareCheckpointTurnCountDescending( |
| 144 | left: OrchestrationCheckpointSummary, | 145 | left: ThreadCheckpointSummary, |
| 145 | right: OrchestrationCheckpointSummary, | 146 | right: ThreadCheckpointSummary, |
| 146 | ): -1 | 0 | 1 { | 147 | ): -1 | 0 | 1 { |
| 147 | if (left.checkpointTurnCount === right.checkpointTurnCount) { | 148 | if (left.checkpointTurnCount === right.checkpointTurnCount) { |
| 148 | return 0; | 149 | return 0; |
| @@ -151,7 +152,7 @@ function compareCheckpointTurnCountDescending( · 2 unmodified lines | |||
| 151 | return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1; | 152 | return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1; |
| 152 | } | 153 | } |
| 153 | 154 | ||
| 154 | const readyCheckpointOrder = Order.make<OrchestrationCheckpointSummary>( | 155 | const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>( |
| 155 | compareCheckpointTurnCountDescending, | 156 | compareCheckpointTurnCountDescending, |
| 156 | ); | 157 | ); |
| 157 | 158 | ||
| @@ -510,14 +511,14 @@ function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { · 352 unmodified lines | |||
| 510 | } | 511 | } |
| 511 | 512 | ||
| 512 | export function getReviewSectionIdForCheckpoint( | 513 | export function getReviewSectionIdForCheckpoint( |
| 513 | checkpoint: Pick<OrchestrationCheckpointSummary, "checkpointTurnCount">, | 514 | checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount">, |
| 514 | ): string { | 515 | ): string { |
| 515 | return `turn:${checkpoint.checkpointTurnCount}`; | 516 | return `turn:${checkpoint.checkpointTurnCount}`; |
| 516 | } | 517 | } |
| 517 | 518 | ||
| 518 | export function getReadyReviewCheckpoints( | 519 | export function getReadyReviewCheckpoints( |
| 519 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | 520 | checkpoints: ReadonlyArray<ThreadCheckpointSummary>, |
| 520 | ): ReadonlyArray<OrchestrationCheckpointSummary> { | 521 | ): ReadonlyArray<ThreadCheckpointSummary> { |
| 521 | return pipe( | 522 | return pipe( |
| 522 | checkpoints, | 523 | checkpoints, |
| 523 | Arr.filter((checkpoint) => checkpoint.status === "ready"), | 524 | Arr.filter((checkpoint) => checkpoint.status === "ready"), |
| @@ -526,7 +527,7 @@ export function getReadyReviewCheckpoints( · 2 unmodified lines | |||
| 526 | } | 527 | } |
| 527 | 528 | ||
| 528 | export function buildReviewSectionItems(input: { | 529 | export function buildReviewSectionItems(input: { |
| 529 | readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>; | 530 | readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>; |
| 530 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; | 531 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; |
| 531 | readonly turnDiffById: Readonly<Record<string, string | undefined>>; | 532 | readonly turnDiffById: Readonly<Record<string, string | undefined>>; |
| 532 | readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>; | 533 | readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>; |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { useCallback, useEffect, useMemo } from "react"; | 1 | import { useCallback, useEffect, useMemo } from "react"; |
| 2 | 2 | ||
| 3 | import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; | 3 | import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell"; |
| 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | 5 | ||
| 5 | import { useCheckpointDiff } from "../../state/queries"; | 6 | import { useCheckpointDiff } from "../../state/queries"; |
| 6 | import { useEnvironmentQuery } from "../../state/query"; | 7 | import { useEnvironmentQuery } from "../../state/query"; |
| @@ -59,7 +60,7 @@ export function useReviewSections(input: { · 52 unmodified lines | |||
| 59 | getReviewSectionIdForCheckpoint(checkpoint), | 60 | getReviewSectionIdForCheckpoint(checkpoint), |
| 60 | checkpoint, | 61 | checkpoint, |
| 61 | ]), | 62 | ]), |
| 62 | ) as Record<string, OrchestrationCheckpointSummary>, | 63 | ) as Record<string, ThreadCheckpointSummary>, |
| 63 | [readyCheckpoints], | 64 | [readyCheckpoints], |
| 64 | ); | 65 | ); |
| 65 | const reviewSections = useMemo( | 66 | const reviewSections = useMemo( |
| @@ -1,4 +1,4 @@ | |||
| 1 | import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; | 1 | import type { ProviderApprovalDecision, RuntimeRequestId } from "@t3tools/contracts"; |
| 2 | import { Pressable, View } from "react-native"; | 2 | import { Pressable, View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { AppText as Text } from "../../components/AppText"; | 4 | import { AppText as Text } from "../../components/AppText"; |
| @@ -6,14 +6,16 @@ import type { PendingApproval } from "../../lib/threadActivity"; · 1 unmodified line | |||
| 6 | 6 | ||
| 7 | export interface PendingApprovalCardProps { | 7 | export interface PendingApprovalCardProps { |
| 8 | readonly approval: PendingApproval; | 8 | readonly approval: PendingApproval; |
| 9 | readonly respondingApprovalId: ApprovalRequestId | null; | 9 | readonly respondingApprovalId: RuntimeRequestId | null; |
| 10 | readonly onRespond: ( | 10 | readonly onRespond: ( |
| 11 | requestId: ApprovalRequestId, | 11 | requestId: RuntimeRequestId, |
| 12 | decision: ProviderApprovalDecision, | 12 | decision: ProviderApprovalDecision, |
| 13 | ) => Promise<unknown>; | 13 | ) => Promise<unknown>; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | export function PendingApprovalCard(props: PendingApprovalCardProps) { | 16 | export function PendingApprovalCard(props: PendingApprovalCardProps) { |
| 17 | const canRespond = props.approval.responseCapability === "live"; | ||
| 18 | const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId; | ||
| 17 | return ( | 19 | return ( |
| 18 | <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80"> | 20 | <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80"> |
| 19 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | 21 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> |
| @@ -27,17 +29,23 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { · 7 unmodified lines | |||
| 27 | {props.approval.detail} | 29 | {props.approval.detail} |
| 28 | </Text> | 30 | </Text> |
| 29 | ) : null} | 31 | ) : null} |
| 32 | {!canRespond ? ( | ||
| 33 | <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400"> | ||
| 34 | The provider process for this request is no longer available. Interrupt or restart the run | ||
| 35 | to continue. | ||
| 36 | </Text> | ||
| 37 | ) : null} | ||
| 30 | <View className="flex-row flex-wrap gap-2.5"> | 38 | <View className="flex-row flex-wrap gap-2.5"> |
| 31 | <Pressable | 39 | <Pressable |
| 32 | className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3" | 40 | className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3" |
| 33 | disabled={props.respondingApprovalId === props.approval.requestId} | 41 | disabled={disabled} |
| 34 | onPress={() => void props.onRespond(props.approval.requestId, "accept")} | 42 | onPress={() => void props.onRespond(props.approval.requestId, "accept")} |
| 35 | > | 43 | > |
| 36 | <Text className="font-t3-extrabold text-sm text-white">Allow once</Text> | 44 | <Text className="font-t3-extrabold text-sm text-white">Allow once</Text> |
| 37 | </Pressable> | 45 | </Pressable> |
| 38 | <Pressable | 46 | <Pressable |
| 39 | className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800" | 47 | className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800" |
| 40 | disabled={props.respondingApprovalId === props.approval.requestId} | 48 | disabled={disabled} |
| 41 | onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")} | 49 | onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")} |
| 42 | > | 50 | > |
| 43 | <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50"> | 51 | <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50"> |
| @@ -46,7 +54,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { · 2 unmodified lines | |||
| 46 | </Pressable> | 54 | </Pressable> |
| 47 | <Pressable | 55 | <Pressable |
| 48 | className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18" | 56 | className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18" |
| 49 | disabled={props.respondingApprovalId === props.approval.requestId} | 57 | disabled={disabled} |
| 50 | onPress={() => void props.onRespond(props.approval.requestId, "decline")} | 58 | onPress={() => void props.onRespond(props.approval.requestId, "decline")} |
| 51 | > | 59 | > |
| 52 | <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text> | 60 | <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text> |
| @@ -1,4 +1,4 @@ | |||
| 1 | import type { ApprovalRequestId } from "@t3tools/contracts"; | 1 | import type { RuntimeRequestId } from "@t3tools/contracts"; |
| 2 | import { Pressable, View } from "react-native"; | 2 | import { Pressable, View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| @@ -9,14 +9,10 @@ export interface PendingUserInputCardProps { · 4 unmodified lines | |||
| 9 | readonly pendingUserInput: PendingUserInput; | 9 | readonly pendingUserInput: PendingUserInput; |
| 10 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; | 10 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; |
| 11 | readonly answers: Record<string, string> | null; | 11 | readonly answers: Record<string, string> | null; |
| 12 | readonly respondingUserInputId: ApprovalRequestId | null; | 12 | readonly respondingUserInputId: RuntimeRequestId | null; |
| 13 | readonly onSelectOption: ( | 13 | readonly onSelectOption: (requestId: RuntimeRequestId, questionId: string, label: string) => void; |
| 14 | requestId: ApprovalRequestId, | ||
| 15 | questionId: string, | ||
| 16 | label: string, | ||
| 17 | ) => void; | ||
| 18 | readonly onChangeCustomAnswer: ( | 14 | readonly onChangeCustomAnswer: ( |
| 19 | requestId: ApprovalRequestId, | 15 | requestId: RuntimeRequestId, |
| 20 | questionId: string, | 16 | questionId: string, |
| 21 | customAnswer: string, | 17 | customAnswer: string, |
| 22 | ) => void; | 18 | ) => void; |
| @@ -24,6 +20,7 @@ export interface PendingUserInputCardProps { · 1 unmodified line | |||
| 24 | } | 20 | } |
| 25 | 21 | ||
| 26 | export function PendingUserInputCard(props: PendingUserInputCardProps) { | 22 | export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 23 | const canRespond = props.pendingUserInput.responseCapability === "live"; | ||
| 27 | return ( | 24 | return ( |
| 28 | <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80"> | 25 | <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80"> |
| 29 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | 26 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> |
| @@ -32,6 +29,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 2 unmodified lines | |||
| 32 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | 29 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> |
| 33 | Fill in the pending answers | 30 | Fill in the pending answers |
| 34 | </Text> | 31 | </Text> |
| 32 | {!canRespond ? ( | ||
| 33 | <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400"> | ||
| 34 | The provider process for this request is no longer available. Interrupt or restart the run | ||
| 35 | to continue. | ||
| 36 | </Text> | ||
| 37 | ) : null} | ||
| 35 | {props.pendingUserInput.questions.map((question) => { | 38 | {props.pendingUserInput.questions.map((question) => { |
| 36 | const draft = props.drafts[question.id]; | 39 | const draft = props.drafts[question.id]; |
| 37 | return ( | 40 | return ( |
| @@ -49,6 +52,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 11 unmodified lines | |||
| 49 | return ( | 52 | return ( |
| 50 | <Pressable | 53 | <Pressable |
| 51 | key={option.label} | 54 | key={option.label} |
| 55 | disabled={!canRespond} | ||
| 52 | className={cn( | 56 | className={cn( |
| 53 | "rounded-full border px-3 py-2.5 ", | 57 | "rounded-full border px-3 py-2.5 ", |
| 54 | selected | 58 | selected |
| @@ -78,6 +82,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 23 unmodified lines | |||
| 78 | })} | 82 | })} |
| 79 | </View> | 83 | </View> |
| 80 | <TextInput | 84 | <TextInput |
| 85 | editable={canRespond} | ||
| 81 | value={draft?.customAnswer ?? ""} | 86 | value={draft?.customAnswer ?? ""} |
| 82 | onChangeText={(value) => | 87 | onChangeText={(value) => |
| 83 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) | 88 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) |
| @@ -94,7 +99,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 10 unmodified lines | |||
| 94 | props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60", | 99 | props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60", |
| 95 | )} | 100 | )} |
| 96 | disabled={ | 101 | disabled={ |
| 97 | props.answers === null || props.respondingUserInputId === props.pendingUserInput.requestId | 102 | !canRespond || |
| 103 | props.answers === null || | ||
| 104 | props.respondingUserInputId === props.pendingUserInput.requestId | ||
| 98 | } | 105 | } |
| 99 | onPress={() => void props.onSubmit()} | 106 | onPress={() => void props.onSubmit()} |
| 100 | > | 107 | > |
| @@ -1,9 +1,12 @@ | |||
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; |
| 2 | import { | ||
| 3 | threadRuntimeIsActive, | ||
| 4 | type EnvironmentThreadShell, | ||
| 5 | } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import type { | 6 | import type { |
| 3 | EnvironmentId, | 7 | EnvironmentId, |
| 4 | MessageId, | 8 | MessageId, |
| 5 | ModelSelection, | 9 | ModelSelection, |
| 6 | OrchestrationThreadShell, | ||
| 7 | ProviderInteractionMode, | 10 | ProviderInteractionMode, |
| 8 | RuntimeMode, | 11 | RuntimeMode, |
| 9 | ServerConfig as T3ServerConfig, | 12 | ServerConfig as T3ServerConfig, |
| @@ -91,13 +94,7 @@ export interface ThreadComposerProps { · 81 unmodified lines | |||
| 91 | readonly connectionState: RemoteClientConnectionState; | 94 | readonly connectionState: RemoteClientConnectionState; |
| 92 | readonly connectionError: string | null; | 95 | readonly connectionError: string | null; |
| 93 | readonly environmentLabel: string | null; | 96 | readonly environmentLabel: string | null; |
| 94 | /** | 97 | readonly selectedThread: EnvironmentThreadShell; |
| 95 | * Message sync phase for the selected thread (drives the status pill): | ||
| 96 | * "loading" = first fetch, nothing to show yet; "syncing" = cached messages | ||
| 97 | * are on screen while they reconcile with the server. | ||
| 98 | */ | ||
| 99 | readonly threadSyncPhase?: "loading" | "syncing" | null; | ||
| 100 | readonly selectedThread: OrchestrationThreadShell; | ||
| 101 | readonly serverConfig: T3ServerConfig | null; | 98 | readonly serverConfig: T3ServerConfig | null; |
| 102 | readonly queueCount: number; | 99 | readonly queueCount: number; |
| 103 | readonly activeThreadBusy: boolean; | 100 | readonly activeThreadBusy: boolean; |
| @@ -305,9 +302,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 201 unmodified lines | |||
| 305 | setIsFocused(false); | 302 | setIsFocused(false); |
| 306 | onExpandedChange?.(false); | 303 | onExpandedChange?.(false); |
| 307 | }, [onExpandedChange]); | 304 | }, [onExpandedChange]); |
| 308 | const showStopAction = | 305 | const showStopAction = threadRuntimeIsActive(props.selectedThread.runtime); |
| 309 | props.selectedThread.session?.status === "running" || | ||
| 310 | props.selectedThread.session?.status === "starting"; | ||
| 311 | 306 | ||
| 312 | const sendLabel = | 307 | const sendLabel = |
| 313 | props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 | 308 | props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 |
| @@ -1,16 +1,15 @@ | |||
| 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; | 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 4 | import type { LegendListRef } from "@legendapp/list/react-native"; | 4 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 5 | import type { | 5 | import type { |
| 6 | ApprovalRequestId, | ||
| 7 | EnvironmentId, | 6 | EnvironmentId, |
| 8 | MessageId, | 7 | MessageId, |
| 9 | ModelSelection, | 8 | ModelSelection, |
| 10 | OrchestrationThreadShell, | ||
| 11 | ProviderApprovalDecision, | 9 | ProviderApprovalDecision, |
| 12 | ProviderInteractionMode, | 10 | ProviderInteractionMode, |
| 13 | RuntimeMode, | 11 | RuntimeMode, |
| 12 | RuntimeRequestId, | ||
| 14 | ServerConfig as T3ServerConfig, | 13 | ServerConfig as T3ServerConfig, |
| 15 | ThreadId, | 14 | ThreadId, |
| 16 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| @@ -43,7 +42,7 @@ import { ThreadFeed } from "./ThreadFeed"; · 26 unmodified lines | |||
| 43 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 42 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 44 | 43 | ||
| 45 | export interface ThreadDetailScreenProps { | 44 | export interface ThreadDetailScreenProps { |
| 46 | readonly selectedThread: OrchestrationThreadShell; | 45 | readonly selectedThread: EnvironmentThreadShell; |
| 47 | readonly contentPresentation: ThreadContentPresentation; | 46 | readonly contentPresentation: ThreadContentPresentation; |
| 48 | readonly screenTone: StatusTone; | 47 | readonly screenTone: StatusTone; |
| 49 | readonly connectionError: string | null; | 48 | readonly connectionError: string | null; |
| @@ -51,11 +50,11 @@ export interface ThreadDetailScreenProps { · 1 unmodified line | |||
| 51 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; | 50 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; |
| 52 | readonly activeWorkStartedAt: string | null; | 51 | readonly activeWorkStartedAt: string | null; |
| 53 | readonly activePendingApproval: PendingApproval | null; | 52 | readonly activePendingApproval: PendingApproval | null; |
| 54 | readonly respondingApprovalId: ApprovalRequestId | null; | 53 | readonly respondingApprovalId: RuntimeRequestId | null; |
| 55 | readonly activePendingUserInput: PendingUserInput | null; | 54 | readonly activePendingUserInput: PendingUserInput | null; |
| 56 | readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; | 55 | readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; |
| 57 | readonly activePendingUserInputAnswers: Record<string, string> | null; | 56 | readonly activePendingUserInputAnswers: Record<string, string> | null; |
| 58 | readonly respondingUserInputId: ApprovalRequestId | null; | 57 | readonly respondingUserInputId: RuntimeRequestId | null; |
| 59 | readonly draftMessage: string; | 58 | readonly draftMessage: string; |
| 60 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 59 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 61 | readonly connectionStateLabel: EnvironmentConnectionPhase; | 60 | readonly connectionStateLabel: EnvironmentConnectionPhase; |
| @@ -82,16 +81,16 @@ export interface ThreadDetailScreenProps { · 20 unmodified lines | |||
| 82 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; | 81 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| 83 | readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; | 82 | readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| 84 | readonly onRespondToApproval: ( | 83 | readonly onRespondToApproval: ( |
| 85 | requestId: ApprovalRequestId, | 84 | requestId: RuntimeRequestId, |
| 86 | decision: ProviderApprovalDecision, | 85 | decision: ProviderApprovalDecision, |
| 87 | ) => Promise<unknown>; | 86 | ) => Promise<unknown>; |
| 88 | readonly onSelectUserInputOption: ( | 87 | readonly onSelectUserInputOption: ( |
| 89 | requestId: ApprovalRequestId, | 88 | requestId: RuntimeRequestId, |
| 90 | questionId: string, | 89 | questionId: string, |
| 91 | label: string, | 90 | label: string, |
| 92 | ) => void; | 91 | ) => void; |
| 93 | readonly onChangeUserInputCustomAnswer: ( | 92 | readonly onChangeUserInputCustomAnswer: ( |
| 94 | requestId: ApprovalRequestId, | 93 | requestId: RuntimeRequestId, |
| 95 | questionId: string, | 94 | questionId: string, |
| 96 | customAnswer: string, | 95 | customAnswer: string, |
| 97 | ) => void; | 96 | ) => void; |
| @@ -341,41 +340,49 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 243 unmodified lines | |||
| 341 | }, []); | 340 | }, []); |
| 342 | 341 | ||
| 343 | return ( | 342 | return ( |
| 344 | <View className="flex-1"> | 343 | <GestureDetector gesture={headerDrawerGesture}> |
| 345 | {showContent ? ( | 344 | <View className="flex-1"> |
| 346 | <View | 345 | {showContent ? ( |
| 347 | className="flex-1" | 346 | <View |
| 348 | onTouchStart={handleFeedTouchStart} | 347 | style={{ flex: 1 }} |
| 349 | onTouchMove={handleFeedTouchMove} | 348 | onTouchStart={handleFeedTouchStart} |
| 350 | onTouchEnd={handleFeedTouchEnd} | 349 | onTouchMove={handleFeedTouchMove} |
| 351 | onTouchCancel={handleFeedTouchCancel} | 350 | onTouchEnd={handleFeedTouchEnd} |
| 352 | > | 351 | onTouchCancel={handleFeedTouchCancel} |
| 353 | <ThreadFeed | 352 | > |
| 354 | key={props.selectedThread.id} | 353 | <ThreadFeed |
| 355 | environmentId={props.environmentId} | 354 | key={props.selectedThread.id} |
| 356 | threadId={props.selectedThread.id} | 355 | environmentId={props.environmentId} |
| 357 | workspaceRoot={props.threadCwd} | 356 | threadId={props.selectedThread.id} |
| 358 | feed={props.selectedThreadFeed} | 357 | workspaceRoot={props.threadCwd} |
| 359 | contentPresentation={props.contentPresentation} | 358 | feed={props.selectedThreadFeed} |
| 360 | agentLabel={agentLabel} | 359 | contentPresentation={props.contentPresentation} |
| 361 | latestTurn={props.selectedThread.latestTurn} | 360 | agentLabel={agentLabel} |
| 362 | activeWorkStartedAt={props.activeWorkStartedAt} | 361 | latestRun={props.selectedThread.latestRun} |
| 363 | listRef={listRef} | 362 | listRef={listRef} |
| 364 | freeze={freeze} | 363 | freeze={freeze} |
| 365 | anchorMessageId={anchorMessageId} | 364 | anchorMessageId={anchorMessageId} |
| 366 | contentInsetEndAdjustment={contentInsetEndAdjustment} | 365 | contentInsetEndAdjustment={contentInsetEndAdjustment} |
| 367 | contentTopInset={0} | 366 | contentTopInset={headerHeight} |
| 368 | contentBottomInset={estimatedOverlayHeight} | 367 | contentBottomInset={estimatedOverlayHeight} |
| 369 | contentMaxWidth={contentMaxWidth} | 368 | layoutVariant={layoutVariant} |
| 370 | layoutVariant={layoutVariant} | 369 | skills={selectedProviderSkills} |
| 371 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} | 370 | /> |
| 372 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | 371 | </View> |
| 373 | skills={selectedProviderSkills} | 372 | ) : ( |
| 374 | /> | 373 | <View style={{ flex: 1 }} /> |
| 375 | </View> | 374 | )} |
| 376 | ) : ( | 375 | |
| 377 | <View className="flex-1" /> | 376 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} |
| 378 | )} | 377 | {showContent ? ( |
| 378 | <KeyboardStickyView | ||
| 379 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | ||
| 380 | offset={{ closed: 0, opened: 0 }} | ||
| 381 | > | ||
| 382 | <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}> | ||
| 383 | {props.activeWorkStartedAt ? ( | ||
| 384 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | ||
| 385 | ) : null} | ||
| 379 | 386 | ||
| 380 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | 387 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} |
| 381 | {showContent ? ( | 388 | {showContent ? ( |
| @@ -1,7 +1,7 @@ | |||
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts"; |
| 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 6 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 7 | import { SymbolView } from "../../components/AppSymbol"; | 7 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -92,7 +92,7 @@ import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/l · 84 unmodified lines | |||
| 92 | import { | 92 | import { |
| 93 | deriveThreadFeedPresentation, | 93 | deriveThreadFeedPresentation, |
| 94 | type ThreadFeedEntry, | 94 | type ThreadFeedEntry, |
| 95 | type ThreadFeedLatestTurn, | 95 | type ThreadFeedLatestRun, |
| 96 | } from "../../lib/threadActivity"; | 96 | } from "../../lib/threadActivity"; |
| 97 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 97 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 98 | import { | 98 | import { |
| @@ -151,8 +151,7 @@ export interface ThreadFeedProps { · 52 unmodified lines | |||
| 151 | readonly feed: ReadonlyArray<ThreadFeedEntry>; | 151 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 152 | readonly contentPresentation: ThreadContentPresentation; | 152 | readonly contentPresentation: ThreadContentPresentation; |
| 153 | readonly agentLabel: string; | 153 | readonly agentLabel: string; |
| 154 | readonly latestTurn: ThreadFeedLatestTurn | null; | 154 | readonly latestRun: ThreadFeedLatestRun | null; |
| 155 | readonly activeWorkStartedAt: string | null; | ||
| 156 | readonly listRef: RefObject<LegendListRef | null>; | 155 | readonly listRef: RefObject<LegendListRef | null>; |
| 157 | readonly freeze: SharedValue<boolean>; | 156 | readonly freeze: SharedValue<boolean>; |
| 158 | readonly anchorMessageId: MessageId | null; | 157 | readonly anchorMessageId: MessageId | null; |
| @@ -823,11 +822,11 @@ function renderFeedEntry( · 664 unmodified lines | |||
| 823 | readonly copiedRowId: string | null; | 822 | readonly copiedRowId: string | null; |
| 824 | readonly expandedWorkRows: Record<string, boolean>; | 823 | readonly expandedWorkRows: Record<string, boolean>; |
| 825 | readonly terminalAssistantMessageIds: ReadonlySet<string>; | 824 | readonly terminalAssistantMessageIds: ReadonlySet<string>; |
| 826 | readonly unsettledTurnId: TurnId | null; | 825 | readonly unsettledTurnId: RunId | null; |
| 827 | readonly onCopyWorkRow: (rowId: string, value: string) => void; | 826 | readonly onCopyWorkRow: (rowId: string, value: string) => void; |
| 828 | readonly onToggleWorkGroup: (groupId: string) => void; | 827 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 829 | readonly onToggleWorkRow: (rowId: string) => void; | 828 | readonly onToggleWorkRow: (rowId: string) => void; |
| 830 | readonly onToggleTurnFold: (turnId: TurnId) => void; | 829 | readonly onToggleTurnFold: (runId: RunId) => void; |
| 831 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | 830 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 832 | readonly onMarkdownLinkPress: (href: string) => void; | 831 | readonly onMarkdownLinkPress: (href: string) => void; |
| 833 | readonly iconSubtleColor: string | import("react-native").ColorValue; | 832 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| @@ -841,16 +840,12 @@ function renderFeedEntry( · 7 unmodified lines | |||
| 841 | const entry = info.item; | 840 | const entry = info.item; |
| 842 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; | 841 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; |
| 843 | 842 | ||
| 844 | if (entry.type === "working") { | 843 | if (entry.type === "run-fold") { |
| 845 | return <WorkingTimelineRow startedAt={entry.createdAt} />; | ||
| 846 | } | ||
| 847 | |||
| 848 | if (entry.type === "turn-fold") { | ||
| 849 | return ( | 844 | return ( |
| 850 | <Pressable | 845 | <Pressable |
| 851 | accessibilityRole="button" | 846 | accessibilityRole="button" |
| 852 | accessibilityState={{ expanded: entry.expanded }} | 847 | accessibilityState={{ expanded: entry.expanded }} |
| 853 | onPress={() => props.onToggleTurnFold(entry.turnId)} | 848 | onPress={() => props.onToggleTurnFold(entry.runId)} |
| 854 | hitSlop={4} | 849 | hitSlop={4} |
| 855 | className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" | 850 | className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" |
| 856 | > | 851 | > |
| @@ -889,7 +884,7 @@ function renderFeedEntry( · 32 unmodified lines | |||
| 889 | const assistantTurnStillInProgress = | 884 | const assistantTurnStillInProgress = |
| 890 | message.role === "assistant" && | 885 | message.role === "assistant" && |
| 891 | props.unsettledTurnId !== null && | 886 | props.unsettledTurnId !== null && |
| 892 | message.turnId === props.unsettledTurnId; | 887 | message.runId === props.unsettledTurnId; |
| 893 | const showAssistantMeta = | 888 | const showAssistantMeta = |
| 894 | message.role === "assistant" && | 889 | message.role === "assistant" && |
| 895 | props.terminalAssistantMessageIds.has(message.id) && | 890 | props.terminalAssistantMessageIds.has(message.id) && |
| @@ -1320,21 +1315,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 424 unmodified lines | |||
| 1320 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 1315 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1321 | const foldSettleFrameRef = useRef<number | null>(null); | 1316 | const foldSettleFrameRef = useRef<number | null>(null); |
| 1322 | const foldSettleSecondFrameRef = useRef<number | null>(null); | 1317 | const foldSettleSecondFrameRef = useRef<number | null>(null); |
| 1323 | const disclosureAnchorKeyRef = useRef<string | null>(null); | 1318 | const previousLatestTurnRef = useRef(props.latestRun); |
| 1324 | const headerMaterialVisibleRef = useRef(false); | 1319 | const { width: viewportWidth } = useWindowDimensions(); |
| 1325 | const previousLatestTurnRef = useRef(props.latestTurn); | 1320 | const [foldToggleSettling, setFoldToggleSettling] = useState(false); |
| 1326 | const { width: windowWidth } = useWindowDimensions(); | ||
| 1327 | const { appearance } = useAppearancePreferences(); | ||
| 1328 | const [viewportWidth, setViewportWidth] = useState(() => | ||
| 1329 | props.layoutVariant === "split" ? 0 : windowWidth, | ||
| 1330 | ); | ||
| 1331 | const [viewportHeight, setViewportHeight] = useState(0); | ||
| 1332 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); | ||
| 1333 | const [interactionState, setInteractionState] = useState<{ | 1321 | const [interactionState, setInteractionState] = useState<{ |
| 1334 | readonly copiedRowId: string | null; | 1322 | readonly copiedRowId: string | null; |
| 1335 | readonly expandedWorkGroups: Record<string, boolean>; | 1323 | readonly expandedWorkGroups: Record<string, boolean>; |
| 1336 | readonly expandedWorkRows: Record<string, boolean>; | 1324 | readonly expandedWorkRows: Record<string, boolean>; |
| 1337 | readonly expandedTurnIds: ReadonlySet<TurnId>; | 1325 | readonly expandedTurnIds: ReadonlySet<RunId>; |
| 1338 | }>({ | 1326 | }>({ |
| 1339 | copiedRowId: null, | 1327 | copiedRowId: null, |
| 1340 | expandedWorkGroups: {}, | 1328 | expandedWorkGroups: {}, |
| @@ -1424,15 +1412,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 83 unmodified lines | |||
| 1424 | viewportWidth, | 1412 | viewportWidth, |
| 1425 | ], | 1413 | ], |
| 1426 | ); | 1414 | ); |
| 1427 | const reportHeaderMaterialVisibility = useCallback( | 1415 | const presentedFeed = useMemo( |
| 1428 | (visible: boolean) => { | 1416 | () => deriveThreadFeedPresentation(props.feed, props.latestRun, expandedTurnIds), |
| 1429 | if (headerMaterialVisibleRef.current === visible) { | 1417 | [expandedTurnIds, props.feed, props.latestRun], |
| 1430 | return; | ||
| 1431 | } | ||
| 1432 | headerMaterialVisibleRef.current = visible; | ||
| 1433 | props.onHeaderMaterialVisibilityChange?.(visible); | ||
| 1434 | }, | ||
| 1435 | [props.onHeaderMaterialVisibilityChange], | ||
| 1436 | ); | 1418 | ); |
| 1437 | // True while the viewport sits within ~one screen of the list end — the | 1419 | // True while the viewport sits within ~one screen of the list end — the |
| 1438 | // only region where layout shifts should animate. Starts true because the | 1420 | // only region where layout shifts should animate. Starts true because the |
| @@ -1544,29 +1526,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 105 unmodified lines | |||
| 1544 | [presentedFeed, props.anchorMessageId, anchorTopInset], | 1526 | [presentedFeed, props.anchorMessageId, anchorTopInset], |
| 1545 | ); | 1527 | ); |
| 1546 | const terminalAssistantMessageIds = useMemo(() => { | 1528 | const terminalAssistantMessageIds = useMemo(() => { |
| 1547 | const terminalIdsByTurn = new Map<TurnId, string>(); | 1529 | const terminalIdsByTurn = new Map<RunId, string>(); |
| 1548 | for (const entry of props.feed) { | 1530 | for (const entry of props.feed) { |
| 1549 | if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { | 1531 | if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) { |
| 1550 | terminalIdsByTurn.set(entry.message.turnId, entry.message.id); | 1532 | terminalIdsByTurn.set(entry.message.runId, entry.message.id); |
| 1551 | } | 1533 | } |
| 1552 | } | 1534 | } |
| 1553 | return new Set(terminalIdsByTurn.values()); | 1535 | return new Set(terminalIdsByTurn.values()); |
| 1554 | }, [props.feed]); | 1536 | }, [props.feed]); |
| 1555 | const unsettledTurnId = | 1537 | const unsettledTurnId = |
| 1556 | props.latestTurn && | 1538 | props.latestRun && |
| 1557 | (props.latestTurn.completedAt === null || props.latestTurn.state === "running") | 1539 | (props.latestRun.completedAt === null || props.latestRun.status === "running") |
| 1558 | ? props.latestTurn.turnId | 1540 | ? props.latestRun.runId |
| 1559 | : null; | 1541 | : null; |
| 1560 | 1542 | ||
| 1561 | useEffect(() => { | 1543 | useEffect(() => { |
| 1562 | const previous = previousLatestTurnRef.current; | 1544 | const previous = previousLatestTurnRef.current; |
| 1563 | previousLatestTurnRef.current = props.latestTurn; | 1545 | previousLatestTurnRef.current = props.latestRun; |
| 1564 | if (!props.latestTurn || !previous) { | 1546 | if (!props.latestRun || !previous) { |
| 1565 | return; | 1547 | return; |
| 1566 | } | 1548 | } |
| 1567 | if (props.latestTurn.turnId === previous.turnId) { | 1549 | if (props.latestRun.runId === previous.runId) { |
| 1568 | if (previous.state === "running" && props.latestTurn.state === "interrupted") { | 1550 | if (previous.status === "running" && props.latestRun.status === "interrupted") { |
| 1569 | const interruptedTurnId = props.latestTurn.turnId; | 1551 | const interruptedTurnId = props.latestRun.runId; |
| 1570 | setInteractionState((current) => ({ | 1552 | setInteractionState((current) => ({ |
| 1571 | ...current, | 1553 | ...current, |
| 1572 | expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), | 1554 | expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), |
| @@ -1575,14 +1557,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines | |||
| 1575 | return; | 1557 | return; |
| 1576 | } | 1558 | } |
| 1577 | setInteractionState((current) => { | 1559 | setInteractionState((current) => { |
| 1578 | if (!current.expandedTurnIds.has(previous.turnId)) { | 1560 | if (!current.expandedTurnIds.has(previous.runId)) { |
| 1579 | return current; | 1561 | return current; |
| 1580 | } | 1562 | } |
| 1581 | const next = new Set(current.expandedTurnIds); | 1563 | const next = new Set(current.expandedTurnIds); |
| 1582 | next.delete(previous.turnId); | 1564 | next.delete(previous.runId); |
| 1583 | return { ...current, expandedTurnIds: next }; | 1565 | return { ...current, expandedTurnIds: next }; |
| 1584 | }); | 1566 | }); |
| 1585 | }, [props.latestTurn]); | 1567 | }, [props.latestRun]); |
| 1586 | 1568 | ||
| 1587 | useEffect(() => { | 1569 | useEffect(() => { |
| 1588 | return () => { | 1570 | return () => { |
| @@ -1676,17 +1658,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 87 unmodified lines | |||
| 1676 | [suspendEndScrollMaintenanceForDisclosure], | 1658 | [suspendEndScrollMaintenanceForDisclosure], |
| 1677 | ); | 1659 | ); |
| 1678 | 1660 | ||
| 1679 | const onToggleTurnFold = useCallback( | 1661 | const onToggleTurnFold = useCallback((runId: RunId) => { |
| 1680 | (turnId: TurnId) => { | 1662 | setFoldToggleSettling(true); |
| 1681 | suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`); | 1663 | if (foldSettleFrameRef.current !== null) { |
| 1682 | setInteractionState((current) => { | 1664 | cancelAnimationFrame(foldSettleFrameRef.current); |
| 1683 | const next = new Set(current.expandedTurnIds); | 1665 | } |
| 1684 | if (next.has(turnId)) { | 1666 | if (foldSettleSecondFrameRef.current !== null) { |
| 1685 | next.delete(turnId); | 1667 | cancelAnimationFrame(foldSettleSecondFrameRef.current); |
| 1686 | } else { | 1668 | } |
| 1687 | next.add(turnId); | 1669 | setInteractionState((current) => { |
| 1688 | } | 1670 | const next = new Set(current.expandedTurnIds); |
| 1689 | return { ...current, expandedTurnIds: next }; | 1671 | if (next.has(runId)) { |
| 1672 | next.delete(runId); | ||
| 1673 | } else { | ||
| 1674 | next.add(runId); | ||
| 1675 | } | ||
| 1676 | return { ...current, expandedTurnIds: next }; | ||
| 1677 | }); | ||
| 1678 | foldSettleFrameRef.current = requestAnimationFrame(() => { | ||
| 1679 | foldSettleSecondFrameRef.current = requestAnimationFrame(() => { | ||
| 1680 | setFoldToggleSettling(false); | ||
| 1681 | foldSettleFrameRef.current = null; | ||
| 1682 | foldSettleSecondFrameRef.current = null; | ||
| 1690 | }); | 1683 | }); |
| 1691 | }, | 1684 | }, |
| 1692 | [suspendEndScrollMaintenanceForDisclosure], | 1685 | [suspendEndScrollMaintenanceForDisclosure], |
| @@ -1,11 +1,6 @@ | |||
| 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | import { | 2 | import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; |
| 3 | StackActions, | 3 | import { useCallback, useMemo, useState } from "react"; |
| 4 | useFocusEffect, | ||
| 5 | useNavigation, | ||
| 6 | type StaticScreenProps, | ||
| 7 | } from "@react-navigation/native"; | ||
| 8 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | ||
| 9 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 10 | import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; | 5 | import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; |
| 11 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 6 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| @@ -461,20 +456,15 @@ function ThreadRouteContent( · 449 unmodified lines | |||
| 461 | void navigation.navigate("Connections"); | 456 | void navigation.navigate("Connections"); |
| 462 | }, [navigation]); | 457 | }, [navigation]); |
| 463 | const handleStopThread = useCallback(() => { | 458 | const handleStopThread = useCallback(() => { |
| 464 | if ( | 459 | const runtime = selectedThread?.runtime; |
| 465 | !selectedThread || | 460 | if (!selectedThread || !runtime || !threadRuntimeIsActive(runtime)) { |
| 466 | (selectedThread.session?.status !== "running" && | ||
| 467 | selectedThread.session?.status !== "starting") | ||
| 468 | ) { | ||
| 469 | return; | 461 | return; |
| 470 | } | 462 | } |
| 471 | return interruptThreadTurn({ | 463 | return interruptThreadTurn({ |
| 472 | environmentId: selectedThread.environmentId, | 464 | environmentId: selectedThread.environmentId, |
| 473 | input: { | 465 | input: { |
| 474 | threadId: selectedThread.id, | 466 | threadId: selectedThread.id, |
| 475 | ...(selectedThread.session.activeTurnId | 467 | ...(runtime.activeRunId ? { runId: runtime.activeRunId } : {}), |
| 476 | ? { turnId: selectedThread.session.activeTurnId } | ||
| 477 | : {}), | ||
| 478 | }, | 468 | }, |
| 479 | }); | 469 | }); |
| 480 | }, [interruptThreadTurn, selectedThread]); | 470 | }, [interruptThreadTurn, selectedThread]); |
| @@ -504,7 +494,7 @@ function ThreadRouteContent( · 23 unmodified lines | |||
| 504 | terminalDebugLog("terminal-menu:open-new", { | 494 | terminalDebugLog("terminal-menu:open-new", { |
| 505 | hasThread: Boolean(selectedThread), | 495 | hasThread: Boolean(selectedThread), |
| 506 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | 496 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), |
| 507 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | 497 | listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId), |
| 508 | }); | 498 | }); |
| 509 | 499 | ||
| 510 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | 500 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| @@ -512,7 +502,7 @@ function ThreadRouteContent( · 1 unmodified line | |||
| 512 | } | 502 | } |
| 513 | 503 | ||
| 514 | const nextId = nextOpenTerminalId({ | 504 | const nextId = nextOpenTerminalId({ |
| 515 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | 505 | listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId), |
| 516 | }); | 506 | }); |
| 517 | void navigation.navigate("ThreadTerminal", { | 507 | void navigation.navigate("ThreadTerminal", { |
| 518 | environmentId: String(selectedThread.environmentId), | 508 | environmentId: String(selectedThread.environmentId), |
| @@ -539,9 +529,9 @@ function ThreadRouteContent( · 20 unmodified lines | |||
| 539 | } | 529 | } |
| 540 | 530 | ||
| 541 | const targetTerminalId = resolveProjectScriptTerminalId({ | 531 | const targetTerminalId = resolveProjectScriptTerminalId({ |
| 542 | existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | 532 | existingTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId), |
| 543 | hasRunningTerminal: terminalMenuSessions.some( | 533 | hasRunningTerminal: terminalMenuSessions.some( |
| 544 | (session) => session.status === "running" || session.status === "starting", | 534 | (runtime) => runtime.status === "running" || runtime.status === "starting", |
| 545 | ), | 535 | ), |
| 546 | }); | 536 | }); |
| 547 | const preferredWorktreePath = resolvePreferredThreadWorktreePath({ | 537 | const preferredWorktreePath = resolvePreferredThreadWorktreePath({ |
| @@ -7,49 +7,9 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { · 6 unmodified lines | |||
| 7 | return Number.isNaN(candidate) ? 0 : candidate; | 7 | return Number.isNaN(candidate) ? 0 : candidate; |
| 8 | } | 8 | } |
| 9 | 9 | ||
| 10 | export type ThreadStatusKind = | 10 | export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone { |
| 11 | | "pending-approval" | 11 | const status = thread.runtime?.status; |
| 12 | | "awaiting-input" | 12 | if (status === "running" || status === "waiting") { |
| 13 | | "working" | ||
| 14 | | "connecting" | ||
| 15 | | "error" | ||
| 16 | | "plan-ready"; | ||
| 17 | |||
| 18 | export interface ThreadStatusPresentation extends StatusTone { | ||
| 19 | readonly kind: ThreadStatusKind; | ||
| 20 | /** Foreground color for the leading status icon. */ | ||
| 21 | readonly iconColor: string; | ||
| 22 | /** Background color for the leading status icon circle. */ | ||
| 23 | readonly iconBackground: string; | ||
| 24 | /** Whether the indicator represents in-flight activity. */ | ||
| 25 | readonly pulse: boolean; | ||
| 26 | } | ||
| 27 | |||
| 28 | /** Neutral icon colors for threads with no actionable status. */ | ||
| 29 | export const THREAD_STATUS_NEUTRAL_ICON = { | ||
| 30 | iconColor: "#8e8e93", | ||
| 31 | iconBackground: "rgba(142,142,147,0.22)", | ||
| 32 | } as const; | ||
| 33 | |||
| 34 | function isLatestTurnSettled( | ||
| 35 | latestTurn: OrchestrationLatestTurn | null, | ||
| 36 | session: OrchestrationSession | null, | ||
| 37 | ): boolean { | ||
| 38 | if (!latestTurn?.startedAt) return false; | ||
| 39 | if (!latestTurn.completedAt) return false; | ||
| 40 | if (!session) return true; | ||
| 41 | return session.status !== "running"; | ||
| 42 | } | ||
| 43 | |||
| 44 | /** | ||
| 45 | * Resolves the user-facing status of a thread, in priority order. Returns | ||
| 46 | * `null` for quiescent threads so rows stay free of "Idle"-style noise. | ||
| 47 | * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts. | ||
| 48 | */ | ||
| 49 | export function resolveThreadStatus( | ||
| 50 | thread: EnvironmentThreadShell, | ||
| 51 | ): ThreadStatusPresentation | null { | ||
| 52 | if (thread.hasPendingApprovals) { | ||
| 53 | return { | 13 | return { |
| 54 | kind: "pending-approval", | 14 | kind: "pending-approval", |
| 55 | label: "Needs Approval", | 15 | label: "Needs Approval", |
| @@ -60,8 +20,7 @@ export function resolveThreadStatus( · 4 unmodified lines | |||
| 60 | pulse: false, | 20 | pulse: false, |
| 61 | }; | 21 | }; |
| 62 | } | 22 | } |
| 63 | 23 | if (status === "completed") { | |
| 64 | if (thread.hasPendingUserInput) { | ||
| 65 | return { | 24 | return { |
| 66 | kind: "awaiting-input", | 25 | kind: "awaiting-input", |
| 67 | label: "Awaiting Input", | 26 | label: "Awaiting Input", |
| @@ -72,8 +31,7 @@ export function resolveThreadStatus( · 4 unmodified lines | |||
| 72 | pulse: false, | 31 | pulse: false, |
| 73 | }; | 32 | }; |
| 74 | } | 33 | } |
| 75 | 34 | if (status === "queued" || status === "starting") { | |
| 76 | if (thread.session?.status === "running") { | ||
| 77 | return { | 35 | return { |
| 78 | kind: "working", | 36 | kind: "working", |
| 79 | label: "Working", | 37 | label: "Working", |
| @@ -84,8 +42,7 @@ export function resolveThreadStatus( · 4 unmodified lines | |||
| 84 | pulse: true, | 42 | pulse: true, |
| 85 | }; | 43 | }; |
| 86 | } | 44 | } |
| 87 | 45 | if (status === "failed") { | |
| 88 | if (thread.session?.status === "starting") { | ||
| 89 | return { | 46 | return { |
| 90 | kind: "connecting", | 47 | kind: "connecting", |
| 91 | label: "Connecting", | 48 | label: "Connecting", |
| @@ -58,15 +58,16 @@ export function useCreateProjectThread() { · 57 unmodified lines | |||
| 58 | 58 | ||
| 59 | const result = await startTurn({ | 59 | const result = await startTurn({ |
| 60 | environmentId: input.project.environmentId, | 60 | environmentId: input.project.environmentId, |
| 61 | input: buildProjectThreadStartTurnInput({ | 61 | input: { |
| 62 | projectId: input.project.id, | 62 | commandId: CommandId.make(metadata.commandId), |
| 63 | projectCwd: input.project.workspaceRoot, | 63 | creationSource: "mobile", |
| 64 | threadId: metadata.threadId, | 64 | threadId, |
| 65 | commandId: metadata.commandId, | 65 | message: { |
| 66 | messageId: metadata.messageId, | 66 | messageId: MessageId.make(metadata.messageId), |
| 67 | createdAt: metadata.createdAt, | 67 | role: "user", |
| 68 | text: initialMessageText, | 68 | text: initialMessageText, |
| 69 | attachments: input.initialAttachments, | 69 | attachments: input.initialAttachments, |
| 70 | }, | ||
| 70 | modelSelection: input.modelSelection, | 71 | modelSelection: input.modelSelection, |
| 71 | runtimeMode: input.runtimeMode, | 72 | runtimeMode: input.runtimeMode, |
| 72 | interactionMode: input.interactionMode, | 73 | interactionMode: input.interactionMode, |
| @@ -4,6 +4,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { groupProjectsByRepository } from "./repositoryGroups"; | 5 | import { groupProjectsByRepository } from "./repositoryGroups"; |
| 6 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 6 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 7 | import { makeThreadShellFixture } from "../test-fixtures"; | ||
| 7 | 8 | ||
| 8 | function makeProject( | 9 | function makeProject( |
| 9 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, | 10 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| @@ -23,24 +24,20 @@ function makeThread( · 13 unmodified lines | |||
| 23 | input: Partial<EnvironmentThreadShell> & | 24 | input: Partial<EnvironmentThreadShell> & |
| 24 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">, | 25 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">, |
| 25 | ): EnvironmentThreadShell { | 26 | ): EnvironmentThreadShell { |
| 26 | return { | 27 | return makeThreadShellFixture({ |
| 27 | runtimeMode: "full-access", | 28 | runtimeMode: "full-access", |
| 28 | interactionMode: "default", | 29 | interactionMode: "default", |
| 29 | branch: null, | 30 | branch: null, |
| 30 | worktreePath: null, | 31 | worktreePath: null, |
| 31 | latestTurn: null, | ||
| 32 | createdAt: "2026-04-01T00:00:00.000Z", | 32 | createdAt: "2026-04-01T00:00:00.000Z", |
| 33 | updatedAt: "2026-04-01T00:00:00.000Z", | 33 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 34 | archivedAt: null, | 34 | archivedAt: null, |
| 35 | session: null, | ||
| 36 | latestUserMessageAt: null, | 35 | latestUserMessageAt: null, |
| 37 | hasPendingApprovals: false, | 36 | hasPendingApprovals: false, |
| 38 | hasPendingUserInput: false, | 37 | hasPendingUserInput: false, |
| 39 | hasActionableProposedPlan: false, | 38 | hasActionableProposedPlan: false, |
| 40 | ...input, | 39 | ...input, |
| 41 | settledOverride: input.settledOverride ?? null, | 40 | }); |
| 42 | settledAt: input.settledAt ?? null, | ||
| 43 | }; | ||
| 44 | } | 41 | } |
| 45 | 42 | ||
| 46 | describe("groupProjectsByRepository", () => { | 43 | describe("groupProjectsByRepository", () => { |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string { | 3 | export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string { |
| 4 | return `${environmentId}:${projectId}`; | 4 | return `${environmentId}:${projectId}`; |
| @@ -10,7 +10,7 @@ export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId · 5 unmodified lines | |||
| 10 | 10 | ||
| 11 | export function scopedRequestKey( | 11 | export function scopedRequestKey( |
| 12 | environmentId: EnvironmentId, | 12 | environmentId: EnvironmentId, |
| 13 | requestId: ApprovalRequestId, | 13 | requestId: RuntimeRequestId, |
| 14 | ): string { | 14 | ): string { |
| 15 | return `${environmentId}:${requestId}`; | 15 | return `${environmentId}:${requestId}`; |
| 16 | } | 16 | } |
| @@ -1,466 +1,112 @@ | |||
| 1 | import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { MessageId, RunId } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 4 | ||
| 3 | import { | 5 | import { makeThreadFixture } from "../test-fixtures"; |
| 4 | EventId, | 6 | import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; |
| 5 | MessageId, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | TurnId, | ||
| 10 | type OrchestrationThread, | ||
| 11 | type OrchestrationThreadActivity, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | 7 | ||
| 14 | import { | 8 | const runId = RunId.make("run-1"); |
| 15 | buildThreadFeed, | ||
| 16 | deriveThreadFeedPresentation, | ||
| 17 | type ThreadFeedActivity, | ||
| 18 | type ThreadFeedEntry, | ||
| 19 | } from "./threadActivity"; | ||
| 20 | 9 | ||
| 21 | function makeActivity( | 10 | function message(role: "user" | "assistant", text: string, createdAt: string, id: string) { |
| 22 | input: Partial<OrchestrationThreadActivity> & | ||
| 23 | Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, | ||
| 24 | ): OrchestrationThreadActivity { | ||
| 25 | return { | 11 | return { |
| 26 | tone: "info", | 12 | id: MessageId.make(id), |
| 27 | payload: {}, | 13 | role, |
| 28 | turnId: null, | 14 | text, |
| 29 | ...input, | 15 | attachments: [], |
| 30 | }; | 16 | runId: role === "assistant" ? runId : null, |
| 17 | streaming: false, | ||
| 18 | createdAt, | ||
| 19 | updatedAt: createdAt, | ||
| 20 | } as const; | ||
| 31 | } | 21 | } |
| 32 | 22 | ||
| 33 | function makeThread( | 23 | function commandEntry(overrides: Partial<ThreadWorkEntry> = {}): ThreadWorkEntry { |
| 34 | input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">, | 24 | const structuredPayload = { |
| 35 | ): OrchestrationThread { | 25 | type: "command_execution", |
| 26 | input: "vp check", | ||
| 27 | output: "ok", | ||
| 28 | } as ThreadWorkEntry["structuredPayload"]; | ||
| 36 | return { | 29 | return { |
| 37 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 30 | id: "item-1", |
| 38 | runtimeMode: "full-access", | 31 | createdAt: "2026-06-20T00:00:02.000Z", |
| 39 | interactionMode: "default", | 32 | runId, |
| 40 | branch: null, | 33 | label: "Ran command", |
| 41 | worktreePath: null, | 34 | command: "vp check", |
| 42 | latestTurn: null, | 35 | detail: "ok", |
| 43 | createdAt: "2026-04-01T00:00:00.000Z", | 36 | tone: "tool", |
| 44 | updatedAt: "2026-04-01T00:00:00.000Z", | 37 | itemType: "command_execution", |
| 45 | archivedAt: null, | 38 | toolLifecycleStatus: "completed", |
| 46 | deletedAt: null, | 39 | structuredPayload, |
| 47 | messages: [], | 40 | ...overrides, |
| 48 | proposedPlans: [], | ||
| 49 | activities: [], | ||
| 50 | checkpoints: [], | ||
| 51 | session: null, | ||
| 52 | ...input, | ||
| 53 | settledOverride: input.settledOverride ?? null, | ||
| 54 | settledAt: input.settledAt ?? null, | ||
| 55 | }; | 41 | }; |
| 56 | } | 42 | } |
| 57 | 43 | ||
| 58 | describe("buildThreadFeed", () => { | 44 | describe("buildThreadFeed", () => { |
| 59 | it("keeps historic work entries attributed to their turns", () => { | 45 | it("orders V2 messages and work entries while retaining structured tool data", () => { |
| 60 | const thread = makeThread({ | 46 | const thread = makeThreadFixture({ |
| 61 | id: ThreadId.make("thread-1"), | 47 | messages: [ |
| 62 | projectId: ProjectId.make("project-1"), | 48 | message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"), |
| 63 | title: "Runtime warning thread", | 49 | message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"), |
| 64 | latestTurn: { | ||
| 65 | turnId: TurnId.make("turn-latest"), | ||
| 66 | state: "running", | ||
| 67 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 68 | startedAt: "2026-04-01T00:00:01.000Z", | ||
| 69 | completedAt: null, | ||
| 70 | assistantMessageId: null, | ||
| 71 | }, | ||
| 72 | activities: [ | ||
| 73 | makeActivity({ | ||
| 74 | id: EventId.make("activity-old"), | ||
| 75 | kind: "runtime.warning", | ||
| 76 | summary: "Runtime warning", | ||
| 77 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 78 | turnId: TurnId.make("turn-old"), | ||
| 79 | payload: { | ||
| 80 | message: "Old warning", | ||
| 81 | }, | ||
| 82 | }), | ||
| 83 | makeActivity({ | ||
| 84 | id: EventId.make("activity-latest"), | ||
| 85 | kind: "runtime.warning", | ||
| 86 | summary: "Runtime warning", | ||
| 87 | createdAt: "2026-04-01T00:00:03.000Z", | ||
| 88 | turnId: TurnId.make("turn-latest"), | ||
| 89 | payload: { | ||
| 90 | message: "Latest warning", | ||
| 91 | }, | ||
| 92 | }), | ||
| 93 | ], | ||
| 94 | }); | ||
| 95 | |||
| 96 | const feed = buildThreadFeed(thread); | ||
| 97 | expect(feed).toMatchObject([ | ||
| 98 | { | ||
| 99 | type: "activity-group", | ||
| 100 | turnId: "turn-old", | ||
| 101 | activities: [{ id: "activity-old", turnId: "turn-old" }], | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | type: "activity-group", | ||
| 105 | turnId: "turn-latest", | ||
| 106 | activities: [{ id: "activity-latest", turnId: "turn-latest" }], | ||
| 107 | }, | ||
| 108 | ]); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("collapses matching tool lifecycle rows like desktop", () => { | ||
| 112 | const thread = makeThread({ | ||
| 113 | id: ThreadId.make("thread-2"), | ||
| 114 | projectId: ProjectId.make("project-1"), | ||
| 115 | title: "Collapsed tools", | ||
| 116 | latestTurn: { | ||
| 117 | turnId: TurnId.make("turn-1"), | ||
| 118 | state: "completed", | ||
| 119 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 120 | startedAt: "2026-04-01T00:00:01.000Z", | ||
| 121 | completedAt: "2026-04-01T00:00:03.000Z", | ||
| 122 | assistantMessageId: null, | ||
| 123 | }, | ||
| 124 | activities: [ | ||
| 125 | makeActivity({ | ||
| 126 | id: EventId.make("tool-updated"), | ||
| 127 | kind: "tool.updated", | ||
| 128 | tone: "tool", | ||
| 129 | summary: "Run tests", | ||
| 130 | createdAt: "2026-04-01T00:00:01.000Z", | ||
| 131 | turnId: TurnId.make("turn-1"), | ||
| 132 | payload: { | ||
| 133 | title: "Run tests", | ||
| 134 | itemType: "command_execution", | ||
| 135 | detail: "/bin/zsh -lc 'bun run test'", | ||
| 136 | }, | ||
| 137 | }), | ||
| 138 | makeActivity({ | ||
| 139 | id: EventId.make("tool-completed"), | ||
| 140 | kind: "tool.completed", | ||
| 141 | tone: "tool", | ||
| 142 | summary: "Run tests completed", | ||
| 143 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 144 | turnId: TurnId.make("turn-1"), | ||
| 145 | payload: { | ||
| 146 | title: "Run tests", | ||
| 147 | itemType: "command_execution", | ||
| 148 | detail: "/bin/zsh -lc 'bun run test'", | ||
| 149 | }, | ||
| 150 | }), | ||
| 151 | ], | 50 | ], |
| 51 | workEntries: [commandEntry()], | ||
| 152 | }); | 52 | }); |
| 153 | 53 | ||
| 154 | const feed = buildThreadFeed(thread); | 54 | const feed = buildThreadFeed(thread); |
| 155 | const group = feed[0]; | 55 | expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]); |
| 156 | 56 | const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0]; | |
| 157 | expect(group).toMatchObject({ | 57 | expect(activity?.runId).toBe(runId); |
| 158 | type: "activity-group", | 58 | expect(activity?.fullDetail).toContain('"input": "vp check"'); |
| 159 | }); | ||
| 160 | if (!group || group.type !== "activity-group") { | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | expect(group.activities).toHaveLength(1); | ||
| 165 | expect(group.activities[0]).toMatchObject({ | ||
| 166 | id: "tool-completed", | ||
| 167 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 168 | turnId: "turn-1", | ||
| 169 | summary: "Run tests", | ||
| 170 | detail: "bun run test", | ||
| 171 | canExpand: true, | ||
| 172 | icon: "command", | ||
| 173 | toolLike: true, | ||
| 174 | status: "success", | ||
| 175 | }); | ||
| 176 | expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'"); | ||
| 177 | expect(group.activities[0]?.getCopyText()).toBe( | ||
| 178 | "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", | ||
| 179 | ); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("keeps MCP inputs available to expanded mobile work rows", () => { | ||
| 183 | const turnId = TurnId.make("turn-mcp"); | ||
| 184 | const thread = makeThread({ | ||
| 185 | id: ThreadId.make("thread-mcp"), | ||
| 186 | projectId: ProjectId.make("project-1"), | ||
| 187 | title: "Expandable MCP call", | ||
| 188 | latestTurn: { | ||
| 189 | turnId, | ||
| 190 | state: "completed", | ||
| 191 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 192 | startedAt: "2026-04-01T00:00:01.000Z", | ||
| 193 | completedAt: "2026-04-01T00:00:03.000Z", | ||
| 194 | assistantMessageId: null, | ||
| 195 | }, | ||
| 196 | activities: [ | ||
| 197 | makeActivity({ | ||
| 198 | id: EventId.make("mcp-completed"), | ||
| 199 | kind: "tool.completed", | ||
| 200 | tone: "tool", | ||
| 201 | summary: "Call repository tool", | ||
| 202 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 203 | turnId, | ||
| 204 | payload: { | ||
| 205 | title: "Call repository tool", | ||
| 206 | itemType: "mcp_tool_call", | ||
| 207 | detail: "repository.search", | ||
| 208 | status: "completed", | ||
| 209 | data: { | ||
| 210 | item: { | ||
| 211 | server: "repository", | ||
| 212 | tool: "search", | ||
| 213 | arguments: { query: "work log" }, | ||
| 214 | }, | ||
| 215 | }, | ||
| 216 | }, | ||
| 217 | }), | ||
| 218 | ], | ||
| 219 | }); | ||
| 220 | |||
| 221 | const group = buildThreadFeed(thread)[0]; | ||
| 222 | expect(group).toMatchObject({ type: "activity-group" }); | ||
| 223 | if (!group || group.type !== "activity-group") { | ||
| 224 | return; | ||
| 225 | } | ||
| 226 | |||
| 227 | expect(group.activities[0]?.icon).toBe("wrench"); | ||
| 228 | expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); | ||
| 229 | expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); | ||
| 230 | }); | 59 | }); |
| 231 | 60 | ||
| 232 | it("defers large tool output expansion until a work row is opened or copied", () => { | 61 | it("folds settled V2 run work while keeping the terminal assistant message visible", () => { |
| 233 | let serializedToolOutputs = 0; | 62 | const thread = makeThreadFixture({ |
| 234 | const activities = Array.from({ length: 5_000 }, (_, index) => | ||
| 235 | makeActivity({ | ||
| 236 | id: EventId.make(`large-tool-${index}`), | ||
| 237 | kind: "tool.completed", | ||
| 238 | tone: "tool", | ||
| 239 | summary: `Tool ${index}`, | ||
| 240 | createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(), | ||
| 241 | payload: { | ||
| 242 | title: `Tool ${index}`, | ||
| 243 | itemType: "mcp_tool_call", | ||
| 244 | status: "completed", | ||
| 245 | data: { | ||
| 246 | item: { | ||
| 247 | toJSON: () => { | ||
| 248 | serializedToolOutputs += 1; | ||
| 249 | return { output: "x".repeat(32_768) }; | ||
| 250 | }, | ||
| 251 | }, | ||
| 252 | }, | ||
| 253 | }, | ||
| 254 | }), | ||
| 255 | ); | ||
| 256 | const thread = makeThread({ | ||
| 257 | id: ThreadId.make("thread-large-tools"), | ||
| 258 | projectId: ProjectId.make("project-1"), | ||
| 259 | title: "Large tools", | ||
| 260 | activities, | ||
| 261 | }); | ||
| 262 | |||
| 263 | const feed = buildThreadFeed(thread); | ||
| 264 | expect(serializedToolOutputs).toBe(0); | ||
| 265 | |||
| 266 | const group = feed[0]; | ||
| 267 | expect(group).toMatchObject({ type: "activity-group" }); | ||
| 268 | if (!group || group.type !== "activity-group") { | ||
| 269 | return; | ||
| 270 | } | ||
| 271 | |||
| 272 | expect(group.activities).toHaveLength(5_000); | ||
| 273 | expect(group.activities[0]?.getFullDetail()).toContain('"output"'); | ||
| 274 | expect(serializedToolOutputs).toBe(1); | ||
| 275 | expect(group.activities[0]?.getCopyText()).toContain('"output"'); | ||
| 276 | expect(serializedToolOutputs).toBe(1); | ||
| 277 | }); | ||
| 278 | |||
| 279 | it("folds settled turn work while leaving the terminal answer visible", () => { | ||
| 280 | const turnId = TurnId.make("turn-1"); | ||
| 281 | const thread = makeThread({ | ||
| 282 | id: ThreadId.make("thread-3"), | ||
| 283 | projectId: ProjectId.make("project-1"), | ||
| 284 | title: "Folded work", | ||
| 285 | latestTurn: { | ||
| 286 | turnId, | ||
| 287 | state: "completed", | ||
| 288 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 289 | startedAt: "2026-04-01T00:00:01.000Z", | ||
| 290 | completedAt: "2026-04-01T00:00:18.000Z", | ||
| 291 | assistantMessageId: MessageId.make("assistant-final"), | ||
| 292 | }, | ||
| 293 | messages: [ | 63 | messages: [ |
| 294 | { | 64 | message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"), |
| 295 | id: MessageId.make("assistant-commentary"), | 65 | message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"), |
| 296 | role: "assistant", | ||
| 297 | text: "I am checking.", | ||
| 298 | turnId, | ||
| 299 | streaming: false, | ||
| 300 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 301 | updatedAt: "2026-04-01T00:00:03.000Z", | ||
| 302 | }, | ||
| 303 | { | ||
| 304 | id: MessageId.make("assistant-final"), | ||
| 305 | role: "assistant", | ||
| 306 | text: "Done.", | ||
| 307 | turnId, | ||
| 308 | streaming: false, | ||
| 309 | createdAt: "2026-04-01T00:00:17.000Z", | ||
| 310 | updatedAt: "2026-04-01T00:00:18.000Z", | ||
| 311 | }, | ||
| 312 | ], | ||
| 313 | activities: [ | ||
| 314 | makeActivity({ | ||
| 315 | id: EventId.make("tool-completed"), | ||
| 316 | kind: "tool.completed", | ||
| 317 | tone: "tool", | ||
| 318 | summary: "Read files", | ||
| 319 | createdAt: "2026-04-01T00:00:05.000Z", | ||
| 320 | turnId, | ||
| 321 | payload: { | ||
| 322 | title: "Read files", | ||
| 323 | itemType: "file_read", | ||
| 324 | status: "completed", | ||
| 325 | }, | ||
| 326 | }), | ||
| 327 | ], | 66 | ], |
| 67 | workEntries: [commandEntry()], | ||
| 328 | }); | 68 | }); |
| 329 | |||
| 330 | const feed = buildThreadFeed(thread); | 69 | const feed = buildThreadFeed(thread); |
| 331 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | 70 | const latestRun = { |
| 332 | expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); | 71 | runId, |
| 333 | expect(collapsed[0]).toMatchObject({ | 72 | status: "completed" as const, |
| 334 | type: "turn-fold", | 73 | startedAt: "2026-06-20T00:00:01.000Z", |
| 335 | label: "Worked for 17s", | 74 | completedAt: "2026-06-20T00:00:03.000Z", |
| 336 | expanded: false, | 75 | }; |
| 337 | }); | 76 | |
| 338 | 77 | const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); | |
| 339 | const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); | 78 | expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); |
| 340 | expect(expanded.map((entry) => entry.id)).toEqual([ | 79 | |
| 341 | "turn-fold:turn-1", | 80 | const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); |
| 342 | "assistant-commentary", | 81 | expect(expanded.map((entry) => entry.type)).toEqual([ |
| 343 | "tool-completed", | 82 | "message", |
| 344 | "assistant-final", | 83 | "run-fold", |
| 84 | "activity-group", | ||
| 85 | "message", | ||
| 345 | ]); | 86 | ]); |
| 346 | }); | 87 | }); |
| 347 | 88 | ||
| 348 | it("measures a steer-superseded turn from its user boundary through trailing work", () => { | 89 | it("keeps an active run expanded and marks failed tools as failures", () => { |
| 349 | const firstTurnId = TurnId.make("turn-1"); | 90 | const thread = makeThreadFixture({ |
| 350 | const secondTurnId = TurnId.make("turn-2"); | 91 | messages: [message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user")], |
| 351 | const thread = makeThread({ | 92 | workEntries: [commandEntry({ tone: "error", toolLifecycleStatus: "failed" })], |
| 352 | id: ThreadId.make("thread-steered"), | ||
| 353 | projectId: ProjectId.make("project-1"), | ||
| 354 | title: "Steered work", | ||
| 355 | latestTurn: { | ||
| 356 | turnId: secondTurnId, | ||
| 357 | state: "running", | ||
| 358 | requestedAt: "2026-04-01T00:00:14.000Z", | ||
| 359 | startedAt: "2026-04-01T00:00:14.000Z", | ||
| 360 | completedAt: null, | ||
| 361 | assistantMessageId: MessageId.make("assistant-next"), | ||
| 362 | }, | ||
| 363 | messages: [ | ||
| 364 | { | ||
| 365 | id: MessageId.make("user-1"), | ||
| 366 | role: "user", | ||
| 367 | text: "Do it once more.", | ||
| 368 | turnId: null, | ||
| 369 | streaming: false, | ||
| 370 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 371 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 372 | }, | ||
| 373 | { | ||
| 374 | id: MessageId.make("assistant-commentary"), | ||
| 375 | role: "assistant", | ||
| 376 | text: "Kicking off call 1.", | ||
| 377 | turnId: firstTurnId, | ||
| 378 | streaming: false, | ||
| 379 | createdAt: "2026-04-01T00:00:09.000Z", | ||
| 380 | updatedAt: "2026-04-01T00:00:09.000Z", | ||
| 381 | }, | ||
| 382 | { | ||
| 383 | id: MessageId.make("user-2"), | ||
| 384 | role: "user", | ||
| 385 | text: "Actually do 15.", | ||
| 386 | turnId: null, | ||
| 387 | streaming: false, | ||
| 388 | createdAt: "2026-04-01T00:00:14.000Z", | ||
| 389 | updatedAt: "2026-04-01T00:00:14.000Z", | ||
| 390 | }, | ||
| 391 | { | ||
| 392 | id: MessageId.make("assistant-next"), | ||
| 393 | role: "assistant", | ||
| 394 | text: "One down - adjusting.", | ||
| 395 | turnId: secondTurnId, | ||
| 396 | streaming: true, | ||
| 397 | createdAt: "2026-04-01T00:00:17.000Z", | ||
| 398 | updatedAt: "2026-04-01T00:00:17.000Z", | ||
| 399 | }, | ||
| 400 | ], | ||
| 401 | activities: [ | ||
| 402 | makeActivity({ | ||
| 403 | id: EventId.make("work-1"), | ||
| 404 | kind: "tool.completed", | ||
| 405 | tone: "tool", | ||
| 406 | summary: "Ran command", | ||
| 407 | createdAt: "2026-04-01T00:00:12.000Z", | ||
| 408 | turnId: firstTurnId, | ||
| 409 | payload: { | ||
| 410 | title: "Ran command", | ||
| 411 | itemType: "command_execution", | ||
| 412 | status: "completed", | ||
| 413 | }, | ||
| 414 | }), | ||
| 415 | ], | ||
| 416 | }); | 93 | }); |
| 417 | |||
| 418 | const feed = buildThreadFeed(thread); | 94 | const feed = buildThreadFeed(thread); |
| 419 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | 95 | const presented = deriveThreadFeedPresentation( |
| 420 | expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ | 96 | feed, |
| 421 | turnId: firstTurnId, | 97 | { |
| 422 | label: "Worked for 12s", | 98 | runId, |
| 423 | }); | 99 | status: "running", |
| 424 | }); | 100 | startedAt: "2026-06-20T00:00:01.000Z", |
| 425 | |||
| 426 | it("keeps an active turn expanded and classifies error-shaped tool output", () => { | ||
| 427 | const turnId = TurnId.make("turn-running"); | ||
| 428 | const thread = makeThread({ | ||
| 429 | id: ThreadId.make("thread-4"), | ||
| 430 | projectId: ProjectId.make("project-1"), | ||
| 431 | title: "Running work", | ||
| 432 | latestTurn: { | ||
| 433 | turnId, | ||
| 434 | state: "running", | ||
| 435 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 436 | startedAt: "2026-04-01T00:00:01.000Z", | ||
| 437 | completedAt: null, | 101 | completedAt: null, |
| 438 | assistantMessageId: null, | ||
| 439 | }, | 102 | }, |
| 440 | activities: [ | 103 | new Set(), |
| 441 | makeActivity({ | 104 | ); |
| 442 | id: EventId.make("tool-failed"), | ||
| 443 | kind: "tool.completed", | ||
| 444 | tone: "tool", | ||
| 445 | summary: "Run command", | ||
| 446 | createdAt: "2026-04-01T00:00:05.000Z", | ||
| 447 | turnId, | ||
| 448 | payload: { | ||
| 449 | title: "Run command", | ||
| 450 | itemType: "command_execution", | ||
| 451 | detail: "zsh: command not found: nope", | ||
| 452 | status: "completed", | ||
| 453 | }, | ||
| 454 | }), | ||
| 455 | ], | ||
| 456 | }); | ||
| 457 | 105 | ||
| 458 | const feed = buildThreadFeed(thread); | 106 | expect(presented.some((entry) => entry.type === "run-fold")).toBe(false); |
| 459 | expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); | 107 | expect(presented.find((entry) => entry.type === "activity-group")?.activities[0]?.status).toBe( |
| 460 | expect(feed[0]).toMatchObject({ | 108 | "failure", |
| 461 | type: "activity-group", | 109 | ); |
| 462 | activities: [{ status: "failure" }], | ||
| 463 | }); | ||
| 464 | }); | 110 | }); |
| 465 | 111 | ||
| 466 | it("appends active work as a normal timeline row", () => { | 112 | it("appends active work as a normal timeline row", () => { |
| @@ -1,29 +1,17 @@ | |||
| 1 | import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; | ||
| 2 | import type { | 1 | import type { |
| 3 | OrchestrationLatestTurn, | 2 | EnvironmentThread, |
| 4 | OrchestrationThread, | 3 | ThreadConversationMessage, |
| 5 | OrchestrationThreadActivity, | 4 | ThreadPendingApproval, |
| 6 | ToolLifecycleItemType, | 5 | ThreadPendingUserInput, |
| 7 | TurnId, | 6 | ThreadRunSummary, |
| 8 | UserInputQuestion, | 7 | ThreadUserInputQuestion, |
| 9 | } from "@t3tools/contracts"; | 8 | ThreadWorkEntry, |
| 9 | } from "@t3tools/client-runtime/state/shell"; | ||
| 10 | import type { RunId } from "@t3tools/contracts"; | ||
| 10 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 11 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 11 | 12 | ||
| 12 | import * as Arr from "effect/Array"; | 13 | export type PendingApproval = ThreadPendingApproval; |
| 13 | import * as Order from "effect/Order"; | 14 | export type PendingUserInput = ThreadPendingUserInput; |
| 14 | |||
| 15 | export interface PendingApproval { | ||
| 16 | readonly requestId: ApprovalRequestId; | ||
| 17 | readonly requestKind: "command" | "file-read" | "file-change"; | ||
| 18 | readonly createdAt: string; | ||
| 19 | readonly detail?: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface PendingUserInput { | ||
| 23 | readonly requestId: ApprovalRequestId; | ||
| 24 | readonly createdAt: string; | ||
| 25 | readonly questions: ReadonlyArray<UserInputQuestion>; | ||
| 26 | } | ||
| 27 | 15 | ||
| 28 | export interface PendingUserInputDraftAnswer { | 16 | export interface PendingUserInputDraftAnswer { |
| 29 | readonly selectedOptionLabel?: string; | 17 | readonly selectedOptionLabel?: string; |
| @@ -33,7 +21,7 @@ export interface PendingUserInputDraftAnswer { · 3 unmodified lines | |||
| 33 | export interface ThreadFeedActivity { | 21 | export interface ThreadFeedActivity { |
| 34 | readonly id: string; | 22 | readonly id: string; |
| 35 | readonly createdAt: string; | 23 | readonly createdAt: string; |
| 36 | readonly turnId: TurnId | null; | 24 | readonly runId: RunId | null; |
| 37 | readonly summary: string; | 25 | readonly summary: string; |
| 38 | readonly detail: string | null; | 26 | readonly detail: string | null; |
| 39 | readonly canExpand: boolean; | 27 | readonly canExpand: boolean; |
| @@ -56,44 +44,18 @@ export interface ThreadFeedActivity { · 16 unmodified lines | |||
| 56 | readonly status: "success" | "failure" | "neutral" | null; | 44 | readonly status: "success" | "failure" | "neutral" | null; |
| 57 | } | 45 | } |
| 58 | 46 | ||
| 59 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | ||
| 60 | |||
| 61 | type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; | ||
| 62 | |||
| 63 | interface WorkLogEntry { | ||
| 64 | id: string; | ||
| 65 | createdAt: string; | ||
| 66 | turnId: TurnId | null; | ||
| 67 | label: string; | ||
| 68 | detail?: string; | ||
| 69 | command?: string; | ||
| 70 | rawCommand?: string; | ||
| 71 | changedFiles?: ReadonlyArray<string>; | ||
| 72 | tone: "thinking" | "tool" | "info" | "error"; | ||
| 73 | toolTitle?: string; | ||
| 74 | itemType?: ToolLifecycleItemType; | ||
| 75 | requestKind?: PendingApproval["requestKind"]; | ||
| 76 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; | ||
| 77 | toolData?: unknown; | ||
| 78 | } | ||
| 79 | |||
| 80 | interface DerivedWorkLogEntry extends WorkLogEntry { | ||
| 81 | activityKind: OrchestrationThreadActivity["kind"]; | ||
| 82 | collapseKey?: string; | ||
| 83 | } | ||
| 84 | |||
| 85 | type RawThreadFeedEntry = | 47 | type RawThreadFeedEntry = |
| 86 | | { | 48 | | { |
| 87 | readonly type: "message"; | 49 | readonly type: "message"; |
| 88 | readonly id: string; | 50 | readonly id: string; |
| 89 | readonly createdAt: string; | 51 | readonly createdAt: string; |
| 90 | readonly message: OrchestrationThread["messages"][number]; | 52 | readonly message: ThreadConversationMessage; |
| 91 | } | 53 | } |
| 92 | | { | 54 | | { |
| 93 | readonly type: "activity"; | 55 | readonly type: "activity"; |
| 94 | readonly id: string; | 56 | readonly id: string; |
| 95 | readonly createdAt: string; | 57 | readonly createdAt: string; |
| 96 | readonly turnId: TurnId | null; | 58 | readonly runId: RunId | null; |
| 97 | readonly activity: ThreadFeedActivity; | 59 | readonly activity: ThreadFeedActivity; |
| 98 | }; | 60 | }; |
| 99 | 61 | ||
| @@ -108,119 +70,25 @@ export type ThreadFeedEntry = · 8 unmodified lines | |||
| 108 | readonly type: "activity-group"; | 70 | readonly type: "activity-group"; |
| 109 | readonly id: string; | 71 | readonly id: string; |
| 110 | readonly createdAt: string; | 72 | readonly createdAt: string; |
| 111 | readonly turnId: TurnId | null; | 73 | readonly runId: RunId | null; |
| 112 | readonly activities: ReadonlyArray<ThreadFeedActivity>; | 74 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 113 | } | 75 | } |
| 114 | | { | 76 | | { |
| 115 | readonly type: "work-toggle"; | 77 | readonly type: "run-fold"; |
| 116 | readonly id: string; | ||
| 117 | readonly createdAt: string; | ||
| 118 | readonly turnId: TurnId | null; | ||
| 119 | readonly groupId: string; | ||
| 120 | readonly hiddenCount: number; | ||
| 121 | readonly expanded: boolean; | ||
| 122 | readonly onlyToolActivities: boolean; | ||
| 123 | } | ||
| 124 | | { | ||
| 125 | readonly type: "turn-fold"; | ||
| 126 | readonly id: string; | 78 | readonly id: string; |
| 127 | readonly createdAt: string; | 79 | readonly createdAt: string; |
| 128 | readonly turnId: TurnId; | 80 | readonly runId: RunId; |
| 129 | readonly label: string; | 81 | readonly label: string; |
| 130 | readonly expanded: boolean; | 82 | readonly expanded: boolean; |
| 131 | }; | 83 | }; |
| 132 | 84 | ||
| 133 | export type ThreadFeedLatestTurn = Pick< | 85 | export type ThreadFeedLatestRun = Pick< |
| 134 | OrchestrationLatestTurn, | 86 | ThreadRunSummary, |
| 135 | "turnId" | "state" | "startedAt" | "completedAt" | 87 | "runId" | "status" | "startedAt" | "completedAt" |
| 136 | >; | 88 | >; |
| 137 | 89 | ||
| 138 | function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { | ||
| 139 | switch (requestType) { | ||
| 140 | case "command_execution_approval": | ||
| 141 | case "exec_command_approval": | ||
| 142 | return "command"; | ||
| 143 | case "file_read_approval": | ||
| 144 | return "file-read"; | ||
| 145 | case "file_change_approval": | ||
| 146 | case "apply_patch_approval": | ||
| 147 | return "file-change"; | ||
| 148 | default: | ||
| 149 | return null; | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { | ||
| 154 | const normalized = detail?.toLowerCase(); | ||
| 155 | if (!normalized) { | ||
| 156 | return false; | ||
| 157 | } | ||
| 158 | return ( | ||
| 159 | normalized.includes("stale pending approval request") || | ||
| 160 | normalized.includes("stale pending user-input request") || | ||
| 161 | normalized.includes("unknown pending approval request") || | ||
| 162 | normalized.includes("unknown pending permission request") || | ||
| 163 | normalized.includes("unknown pending user-input request") | ||
| 164 | ); | ||
| 165 | } | ||
| 166 | |||
| 167 | function parseApprovalRequestId(value: unknown): ApprovalRequestId | null { | ||
| 168 | return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null; | ||
| 169 | } | ||
| 170 | |||
| 171 | function parseUserInputQuestions( | ||
| 172 | payload: Record<string, unknown> | null, | ||
| 173 | ): ReadonlyArray<UserInputQuestion> | null { | ||
| 174 | const questions = payload?.questions; | ||
| 175 | if (!Array.isArray(questions)) { | ||
| 176 | return null; | ||
| 177 | } | ||
| 178 | |||
| 179 | const parsed = questions | ||
| 180 | .map<UserInputQuestion | null>((entry) => { | ||
| 181 | if (!entry || typeof entry !== "object") return null; | ||
| 182 | const question = entry as Record<string, unknown>; | ||
| 183 | if ( | ||
| 184 | typeof question.id !== "string" || | ||
| 185 | typeof question.header !== "string" || | ||
| 186 | typeof question.question !== "string" || | ||
| 187 | !Array.isArray(question.options) | ||
| 188 | ) { | ||
| 189 | return null; | ||
| 190 | } | ||
| 191 | const options = question.options | ||
| 192 | .map<UserInputQuestion["options"][number] | null>((option) => { | ||
| 193 | if (!option || typeof option !== "object") return null; | ||
| 194 | const record = option as Record<string, unknown>; | ||
| 195 | if (typeof record.label !== "string" || typeof record.description !== "string") { | ||
| 196 | return null; | ||
| 197 | } | ||
| 198 | return { | ||
| 199 | label: record.label, | ||
| 200 | description: record.description, | ||
| 201 | }; | ||
| 202 | }) | ||
| 203 | .filter((option): option is UserInputQuestion["options"][number] => option !== null); | ||
| 204 | if (options.length === 0) { | ||
| 205 | return null; | ||
| 206 | } | ||
| 207 | return { | ||
| 208 | id: question.id, | ||
| 209 | header: question.header, | ||
| 210 | question: question.question, | ||
| 211 | options, | ||
| 212 | multiSelect: question.multiSelect === true, | ||
| 213 | }; | ||
| 214 | }) | ||
| 215 | .filter((question): question is UserInputQuestion => question !== null); | ||
| 216 | |||
| 217 | return parsed.length > 0 ? parsed : null; | ||
| 218 | } | ||
| 219 | |||
| 220 | function normalizeDraftAnswer(value: string | undefined): string | null { | 90 | function normalizeDraftAnswer(value: string | undefined): string | null { |
| 221 | if (typeof value !== "string") { | 91 | if (typeof value !== "string") return null; |
| 222 | return null; | ||
| 223 | } | ||
| 224 | const trimmed = value.trim(); | 92 | const trimmed = value.trim(); |
| 225 | return trimmed.length > 0 ? trimmed : null; | 93 | return trimmed.length > 0 ? trimmed : null; |
| 226 | } | 94 | } |
| @@ -228,882 +96,247 @@ function normalizeDraftAnswer(value: string | undefined): string | null { · 1 unmodified line | |||
| 228 | function resolvePendingUserInputAnswer( | 96 | function resolvePendingUserInputAnswer( |
| 229 | draft: PendingUserInputDraftAnswer | undefined, | 97 | draft: PendingUserInputDraftAnswer | undefined, |
| 230 | ): string | null { | 98 | ): string | null { |
| 231 | const customAnswer = normalizeDraftAnswer(draft?.customAnswer); | ||
| 232 | if (customAnswer) { | ||
| 233 | return customAnswer; | ||
| 234 | } | ||
| 235 | return normalizeDraftAnswer(draft?.selectedOptionLabel); | ||
| 236 | } | ||
| 237 | |||
| 238 | function deriveWorkLogEntries( | ||
| 239 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 240 | ): DerivedWorkLogEntry[] { | ||
| 241 | const ordered = Arr.sort(activities, activityOrder); | ||
| 242 | const entries: DerivedWorkLogEntry[] = []; | ||
| 243 | for (const activity of ordered) { | ||
| 244 | if (activity.kind === "tool.started") continue; | ||
| 245 | if (activity.kind === "task.started") continue; | ||
| 246 | if (activity.kind === "context-window.updated") continue; | ||
| 247 | if (activity.summary === "Checkpoint captured") continue; | ||
| 248 | if (isPlanBoundaryToolActivity(activity)) continue; | ||
| 249 | entries.push(toDerivedWorkLogEntry(activity)); | ||
| 250 | } | ||
| 251 | return collapseDerivedWorkLogEntries(entries); | ||
| 252 | } | ||
| 253 | |||
| 254 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 255 | if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { | ||
| 256 | return false; | ||
| 257 | } | ||
| 258 | |||
| 259 | const payload = | ||
| 260 | activity.payload && typeof activity.payload === "object" | ||
| 261 | ? (activity.payload as Record<string, unknown>) | ||
| 262 | : null; | ||
| 263 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); | ||
| 264 | } | ||
| 265 | |||
| 266 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { | ||
| 267 | const payload = | ||
| 268 | activity.payload && typeof activity.payload === "object" | ||
| 269 | ? (activity.payload as Record<string, unknown>) | ||
| 270 | : null; | ||
| 271 | const commandPreview = extractToolCommand(payload); | ||
| 272 | const changedFiles = extractChangedFiles(payload); | ||
| 273 | const title = extractToolTitle(payload); | ||
| 274 | const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; | ||
| 275 | const taskSummary = | ||
| 276 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 | ||
| 277 | ? payload.summary | ||
| 278 | : null; | ||
| 279 | const taskDetailAsLabel = | ||
| 280 | isTaskActivity && | ||
| 281 | !taskSummary && | ||
| 282 | typeof payload?.detail === "string" && | ||
| 283 | payload.detail.length > 0 | ||
| 284 | ? payload.detail | ||
| 285 | : null; | ||
| 286 | const taskLabel = taskSummary || taskDetailAsLabel; | ||
| 287 | const entry: DerivedWorkLogEntry = { | ||
| 288 | id: activity.id, | ||
| 289 | createdAt: activity.createdAt, | ||
| 290 | turnId: activity.turnId, | ||
| 291 | label: taskLabel || activity.summary, | ||
| 292 | tone: | ||
| 293 | activity.kind === "task.progress" | ||
| 294 | ? "thinking" | ||
| 295 | : activity.tone === "approval" | ||
| 296 | ? "info" | ||
| 297 | : activity.tone, | ||
| 298 | activityKind: activity.kind, | ||
| 299 | }; | ||
| 300 | const itemType = extractWorkLogItemType(payload); | ||
| 301 | const requestKind = extractWorkLogRequestKind(payload); | ||
| 302 | if ( | ||
| 303 | !taskDetailAsLabel && | ||
| 304 | payload && | ||
| 305 | typeof payload.detail === "string" && | ||
| 306 | payload.detail.length > 0 | ||
| 307 | ) { | ||
| 308 | const detail = stripTrailingExitCode(payload.detail).output; | ||
| 309 | if (detail) { | ||
| 310 | entry.detail = detail; | ||
| 311 | } | ||
| 312 | } | ||
| 313 | if (commandPreview.command) { | ||
| 314 | entry.command = commandPreview.command; | ||
| 315 | } | ||
| 316 | if (commandPreview.rawCommand) { | ||
| 317 | entry.rawCommand = commandPreview.rawCommand; | ||
| 318 | } | ||
| 319 | if (changedFiles.length > 0) { | ||
| 320 | entry.changedFiles = changedFiles; | ||
| 321 | } | ||
| 322 | if (title) { | ||
| 323 | entry.toolTitle = title; | ||
| 324 | } | ||
| 325 | if (itemType === "mcp_tool_call") { | ||
| 326 | const data = asRecord(payload?.data); | ||
| 327 | if (data?.item !== undefined) { | ||
| 328 | entry.toolData = data.item; | ||
| 329 | } | ||
| 330 | } | ||
| 331 | if (itemType) { | ||
| 332 | entry.itemType = itemType; | ||
| 333 | } | ||
| 334 | if (requestKind) { | ||
| 335 | entry.requestKind = requestKind; | ||
| 336 | } | ||
| 337 | let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); | ||
| 338 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { | ||
| 339 | toolLifecycleStatus = "completed"; | ||
| 340 | } | ||
| 341 | if (toolLifecycleStatus) { | ||
| 342 | entry.toolLifecycleStatus = toolLifecycleStatus; | ||
| 343 | } | ||
| 344 | const collapseKey = deriveToolLifecycleCollapseKey(entry); | ||
| 345 | if (collapseKey) { | ||
| 346 | entry.collapseKey = collapseKey; | ||
| 347 | } | ||
| 348 | return entry; | ||
| 349 | } | ||
| 350 | |||
| 351 | function collapseDerivedWorkLogEntries( | ||
| 352 | entries: ReadonlyArray<DerivedWorkLogEntry>, | ||
| 353 | ): DerivedWorkLogEntry[] { | ||
| 354 | const collapsed: DerivedWorkLogEntry[] = []; | ||
| 355 | for (const entry of entries) { | ||
| 356 | const previous = collapsed.at(-1); | ||
| 357 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { | ||
| 358 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); | ||
| 359 | continue; | ||
| 360 | } | ||
| 361 | collapsed.push(entry); | ||
| 362 | } | ||
| 363 | return collapsed; | ||
| 364 | } | ||
| 365 | |||
| 366 | function shouldCollapseToolLifecycleEntries( | ||
| 367 | previous: DerivedWorkLogEntry, | ||
| 368 | next: DerivedWorkLogEntry, | ||
| 369 | ): boolean { | ||
| 370 | if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { | ||
| 371 | return false; | ||
| 372 | } | ||
| 373 | if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { | ||
| 374 | return false; | ||
| 375 | } | ||
| 376 | if (previous.activityKind === "tool.completed") { | ||
| 377 | return false; | ||
| 378 | } | ||
| 379 | return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; | ||
| 380 | } | ||
| 381 | |||
| 382 | function mergeDerivedWorkLogEntries( | ||
| 383 | previous: DerivedWorkLogEntry, | ||
| 384 | next: DerivedWorkLogEntry, | ||
| 385 | ): DerivedWorkLogEntry { | ||
| 386 | const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); | ||
| 387 | const detail = next.detail ?? previous.detail; | ||
| 388 | const command = next.command ?? previous.command; | ||
| 389 | const rawCommand = next.rawCommand ?? previous.rawCommand; | ||
| 390 | const toolTitle = next.toolTitle ?? previous.toolTitle; | ||
| 391 | const itemType = next.itemType ?? previous.itemType; | ||
| 392 | const requestKind = next.requestKind ?? previous.requestKind; | ||
| 393 | const collapseKey = next.collapseKey ?? previous.collapseKey; | ||
| 394 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; | ||
| 395 | const toolData = next.toolData ?? previous.toolData; | ||
| 396 | return { | ||
| 397 | ...previous, | ||
| 398 | ...next, | ||
| 399 | ...(detail ? { detail } : {}), | ||
| 400 | ...(command ? { command } : {}), | ||
| 401 | ...(rawCommand ? { rawCommand } : {}), | ||
| 402 | ...(changedFiles.length > 0 ? { changedFiles } : {}), | ||
| 403 | ...(toolTitle ? { toolTitle } : {}), | ||
| 404 | ...(itemType ? { itemType } : {}), | ||
| 405 | ...(requestKind ? { requestKind } : {}), | ||
| 406 | ...(collapseKey ? { collapseKey } : {}), | ||
| 407 | ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), | ||
| 408 | ...(toolData !== undefined ? { toolData } : {}), | ||
| 409 | }; | ||
| 410 | } | ||
| 411 | |||
| 412 | function mergeChangedFiles( | ||
| 413 | previous: ReadonlyArray<string> | undefined, | ||
| 414 | next: ReadonlyArray<string> | undefined, | ||
| 415 | ): string[] { | ||
| 416 | const merged = [...(previous ?? []), ...(next ?? [])]; | ||
| 417 | if (merged.length === 0) { | ||
| 418 | return []; | ||
| 419 | } | ||
| 420 | return [...new Set(merged)]; | ||
| 421 | } | ||
| 422 | |||
| 423 | function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { | ||
| 424 | if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { | ||
| 425 | return undefined; | ||
| 426 | } | ||
| 427 | const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); | ||
| 428 | const detail = entry.detail?.trim() ?? ""; | ||
| 429 | const itemType = entry.itemType ?? ""; | ||
| 430 | if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) { | ||
| 431 | return undefined; | ||
| 432 | } | ||
| 433 | return [itemType, normalizedLabel, detail].join("\u001f"); | ||
| 434 | } | ||
| 435 | |||
| 436 | function normalizeCompactToolLabel(value: string): string { | ||
| 437 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | ||
| 438 | } | ||
| 439 | |||
| 440 | function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { | ||
| 441 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { | ||
| 442 | return true; | ||
| 443 | } | ||
| 444 | if (entry.command !== undefined && entry.command.trim().length > 0) { | ||
| 445 | return true; | ||
| 446 | } | ||
| 447 | if (entry.requestKind !== undefined) { | ||
| 448 | return true; | ||
| 449 | } | ||
| 450 | return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); | ||
| 451 | } | ||
| 452 | |||
| 453 | function toolDetailTextLooksLikeFailure(text: string): boolean { | ||
| 454 | const normalized = text.toLowerCase(); | ||
| 455 | return ( | 99 | return ( |
| 456 | normalized.includes("file not found") || | 100 | normalizeDraftAnswer(draft?.customAnswer) ?? normalizeDraftAnswer(draft?.selectedOptionLabel) |
| 457 | normalized.includes("no files found") || | ||
| 458 | normalized.includes("enoent") || | ||
| 459 | normalized.includes("no such file or directory") || | ||
| 460 | normalized.includes("no such file") || | ||
| 461 | normalized.includes("commandnotfoundexception") || | ||
| 462 | normalized.includes("command not found") || | ||
| 463 | (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || | ||
| 464 | (normalized.includes("is not recognized") && normalized.includes("the term '")) || | ||
| 465 | /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) || | ||
| 466 | /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || | ||
| 467 | /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) | ||
| 468 | ); | 101 | ); |
| 469 | } | 102 | } |
| 470 | 103 | ||
| 471 | function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { | 104 | function capitalizePhrase(value: string): string { |
| 472 | if (entry.tone === "error") { | 105 | const trimmed = value.trim(); |
| 473 | return true; | 106 | return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; |
| 474 | } | ||
| 475 | if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { | ||
| 476 | return true; | ||
| 477 | } | ||
| 478 | if (!workLogEntryIsToolLike(entry)) { | ||
| 479 | return false; | ||
| 480 | } | ||
| 481 | return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); | ||
| 482 | } | 107 | } |
| 483 | 108 | ||
| 484 | function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | 109 | function workEntryIsToolLike(entry: ThreadWorkEntry): boolean { |
| 485 | if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { | ||
| 486 | return false; | ||
| 487 | } | ||
| 488 | if (entry.tone === "thinking") { | ||
| 489 | return false; | ||
| 490 | } | ||
| 491 | return ( | 110 | return ( |
| 492 | entry.toolLifecycleStatus !== "inProgress" && | 111 | entry.tone === "tool" || |
| 493 | entry.toolLifecycleStatus !== "stopped" && | 112 | entry.tone === "thinking" || |
| 494 | entry.toolLifecycleStatus !== "failed" && | 113 | entry.tone === "error" || |
| 495 | entry.toolLifecycleStatus !== "declined" | 114 | entry.command !== undefined || |
| 115 | entry.requestKind !== undefined | ||
| 496 | ); | 116 | ); |
| 497 | } | 117 | } |
| 498 | 118 | ||
| 499 | function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { | 119 | function workEntryStatus(entry: ThreadWorkEntry): ThreadFeedActivity["status"] { |
| 500 | if (!workLogEntryIsToolLike(entry)) { | 120 | if (!workEntryIsToolLike(entry)) return null; |
| 501 | return null; | ||
| 502 | } | ||
| 503 | if (workEntryIndicatesToolFailure(entry)) { | ||
| 504 | return "failure"; | ||
| 505 | } | ||
| 506 | if (workEntryIndicatesToolSuccess(entry)) { | ||
| 507 | return "success"; | ||
| 508 | } | ||
| 509 | return "neutral"; | ||
| 510 | } | ||
| 511 | |||
| 512 | function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { | ||
| 513 | if ( | 121 | if ( |
| 514 | entry.activityKind === "user-input.requested" || | 122 | entry.tone === "error" || |
| 515 | entry.activityKind === "user-input.resolved" | 123 | entry.toolLifecycleStatus === "failed" || |
| 124 | entry.toolLifecycleStatus === "declined" | ||
| 516 | ) { | 125 | ) { |
| 517 | return "message"; | 126 | return "failure"; |
| 518 | } | ||
| 519 | if (entry.activityKind === "runtime.warning") return "warning"; | ||
| 520 | if (entry.requestKind === "command") return "command"; | ||
| 521 | if (entry.requestKind === "file-read") return "eye"; | ||
| 522 | if (entry.requestKind === "file-change") return "edit"; | ||
| 523 | if (entry.itemType === "command_execution" || entry.command) return "command"; | ||
| 524 | if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; | ||
| 525 | if (entry.itemType === "web_search") return "globe"; | ||
| 526 | if (entry.itemType === "image_view") return "eye"; | ||
| 527 | if (entry.itemType === "mcp_tool_call") return "wrench"; | ||
| 528 | if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") { | ||
| 529 | return "hammer"; | ||
| 530 | } | 127 | } |
| 531 | if (entry.tone === "error") return "alert"; | 128 | return entry.toolLifecycleStatus === "completed" ? "success" : "neutral"; |
| 532 | if (entry.tone === "thinking") return "agent"; | ||
| 533 | if (entry.tone === "info") return "check"; | ||
| 534 | return "zap"; | ||
| 535 | } | 129 | } |
| 536 | 130 | ||
| 537 | function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { | 131 | function workEntryIcon(entry: ThreadWorkEntry): ThreadFeedActivity["icon"] { |
| 538 | const blocks: string[] = []; | 132 | switch (entry.itemType) { |
| 539 | const appendUniqueBlock = (value: string | null | undefined) => { | 133 | case "reasoning": |
| 540 | const trimmed = value?.trim(); | 134 | return "agent"; |
| 541 | if (trimmed && !blocks.includes(trimmed)) { | 135 | case "command_execution": |
| 542 | blocks.push(trimmed); | 136 | return "command"; |
| 543 | } | 137 | case "file_change": |
| 544 | }; | 138 | return "edit"; |
| 545 | 139 | case "file_search": | |
| 546 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { | 140 | return "eye"; |
| 547 | appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); | 141 | case "web_search": |
| 548 | } | 142 | return "globe"; |
| 549 | appendUniqueBlock(entry.rawCommand ?? entry.command); | 143 | case "approval_request": |
| 550 | appendUniqueBlock(entry.detail); | 144 | case "user_input_request": |
| 551 | if ((entry.changedFiles?.length ?? 0) > 0) { | 145 | return "message"; |
| 552 | appendUniqueBlock(entry.changedFiles!.join("\n")); | 146 | case "dynamic_tool": |
| 147 | return "wrench"; | ||
| 148 | case "subagent": | ||
| 149 | return "hammer"; | ||
| 150 | case "run_interrupt_request": | ||
| 151 | case "run_interrupt_result": | ||
| 152 | return "warning"; | ||
| 153 | case "checkpoint": | ||
| 154 | return "check"; | ||
| 155 | default: | ||
| 156 | if (entry.tone === "error") return "alert"; | ||
| 157 | if (entry.tone === "thinking") return "agent"; | ||
| 158 | if (entry.tone === "info") return "check"; | ||
| 159 | return "zap"; | ||
| 553 | } | 160 | } |
| 554 | |||
| 555 | return blocks.length > 0 ? blocks.join("\n\n") : null; | ||
| 556 | } | ||
| 557 | |||
| 558 | function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { | ||
| 559 | return ( | ||
| 560 | (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || | ||
| 561 | Boolean((entry.rawCommand ?? entry.command)?.trim()) || | ||
| 562 | Boolean(entry.detail?.trim()) || | ||
| 563 | (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) | ||
| 564 | ); | ||
| 565 | } | ||
| 566 | |||
| 567 | function memoizeValue<T>(build: () => T): () => T { | ||
| 568 | let value: T; | ||
| 569 | let initialized = false; | ||
| 570 | return () => { | ||
| 571 | if (!initialized) { | ||
| 572 | value = build(); | ||
| 573 | initialized = true; | ||
| 574 | } | ||
| 575 | return value; | ||
| 576 | }; | ||
| 577 | } | 161 | } |
| 578 | 162 | ||
| 579 | function workEntryPreview( | 163 | function workEntryPreview(entry: ThreadWorkEntry): string | null { |
| 580 | workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, | 164 | if (entry.command) return entry.command; |
| 581 | ): string | null { | 165 | if (entry.detail) return entry.detail; |
| 582 | if (workEntry.command) return workEntry.command; | 166 | const firstPath = entry.changedFiles?.[0]; |
| 583 | if (workEntry.detail) return workEntry.detail; | ||
| 584 | if ((workEntry.changedFiles?.length ?? 0) === 0) return null; | ||
| 585 | const [firstPath] = workEntry.changedFiles ?? []; | ||
| 586 | if (!firstPath) return null; | 167 | if (!firstPath) return null; |
| 587 | return workEntry.changedFiles!.length === 1 | 168 | return entry.changedFiles?.length === 1 |
| 588 | ? firstPath | 169 | ? firstPath |
| 589 | : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; | 170 | : `${firstPath} +${(entry.changedFiles?.length ?? 1) - 1} more`; |
| 590 | } | ||
| 591 | |||
| 592 | function capitalizePhrase(value: string): string { | ||
| 593 | const trimmed = value.trim(); | ||
| 594 | if (trimmed.length === 0) { | ||
| 595 | return value; | ||
| 596 | } | ||
| 597 | return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; | ||
| 598 | } | ||
| 599 | |||
| 600 | function workEntryHeading(workEntry: WorkLogEntry): string { | ||
| 601 | if (!workEntry.toolTitle) { | ||
| 602 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); | ||
| 603 | } | ||
| 604 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); | ||
| 605 | } | ||
| 606 | |||
| 607 | function asRecord(value: unknown): Record<string, unknown> | null { | ||
| 608 | return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | ||
| 609 | } | ||
| 610 | |||
| 611 | function asTrimmedString(value: unknown): string | null { | ||
| 612 | if (typeof value !== "string") { | ||
| 613 | return null; | ||
| 614 | } | ||
| 615 | const trimmed = value.trim(); | ||
| 616 | return trimmed.length > 0 ? trimmed : null; | ||
| 617 | } | ||
| 618 | |||
| 619 | function trimMatchingOuterQuotes(value: string): string { | ||
| 620 | const trimmed = value.trim(); | ||
| 621 | if ( | ||
| 622 | (trimmed.startsWith("'") && trimmed.endsWith("'")) || | ||
| 623 | (trimmed.startsWith('"') && trimmed.endsWith('"')) | ||
| 624 | ) { | ||
| 625 | const unquoted = trimmed.slice(1, -1).trim(); | ||
| 626 | return unquoted.length > 0 ? unquoted : trimmed; | ||
| 627 | } | ||
| 628 | return trimmed; | ||
| 629 | } | ||
| 630 | |||
| 631 | function executableBasename(value: string): string | null { | ||
| 632 | const trimmed = trimMatchingOuterQuotes(value); | ||
| 633 | if (trimmed.length === 0) { | ||
| 634 | return null; | ||
| 635 | } | ||
| 636 | const normalized = trimmed.replace(/\\/g, "/"); | ||
| 637 | const segments = normalized.split("/"); | ||
| 638 | const last = segments.at(-1)?.trim() ?? ""; | ||
| 639 | return last.length > 0 ? last.toLowerCase() : null; | ||
| 640 | } | ||
| 641 | |||
| 642 | function splitExecutableAndRest(value: string): { executable: string; rest: string } | null { | ||
| 643 | const trimmed = value.trim(); | ||
| 644 | if (trimmed.length === 0) { | ||
| 645 | return null; | ||
| 646 | } | ||
| 647 | |||
| 648 | if (trimmed.startsWith('"') || trimmed.startsWith("'")) { | ||
| 649 | const quote = trimmed.charAt(0); | ||
| 650 | const closeIndex = trimmed.indexOf(quote, 1); | ||
| 651 | if (closeIndex <= 0) { | ||
| 652 | return null; | ||
| 653 | } | ||
| 654 | return { | ||
| 655 | executable: trimmed.slice(0, closeIndex + 1), | ||
| 656 | rest: trimmed.slice(closeIndex + 1).trim(), | ||
| 657 | }; | ||
| 658 | } | ||
| 659 | |||
| 660 | const firstWhitespace = trimmed.search(/\s/); | ||
| 661 | if (firstWhitespace < 0) { | ||
| 662 | return { | ||
| 663 | executable: trimmed, | ||
| 664 | rest: "", | ||
| 665 | }; | ||
| 666 | } | ||
| 667 | |||
| 668 | return { | ||
| 669 | executable: trimmed.slice(0, firstWhitespace), | ||
| 670 | rest: trimmed.slice(firstWhitespace).trim(), | ||
| 671 | }; | ||
| 672 | } | ||
| 673 | |||
| 674 | const SHELL_WRAPPER_SPECS = [ | ||
| 675 | { | ||
| 676 | executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"], | ||
| 677 | wrapperFlagPattern: /(?:^|\s)-command\s+/i, | ||
| 678 | }, | ||
| 679 | { | ||
| 680 | executables: ["cmd", "cmd.exe"], | ||
| 681 | wrapperFlagPattern: /(?:^|\s)\/c\s+/i, | ||
| 682 | }, | ||
| 683 | { | ||
| 684 | executables: ["bash", "sh", "zsh"], | ||
| 685 | wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i, | ||
| 686 | }, | ||
| 687 | ] as const; | ||
| 688 | |||
| 689 | function findShellWrapperSpec(shell: string) { | ||
| 690 | return SHELL_WRAPPER_SPECS.find((spec) => | ||
| 691 | (spec.executables as ReadonlyArray<string>).includes(shell), | ||
| 692 | ); | ||
| 693 | } | 171 | } |
| 694 | 172 | ||
| 695 | function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { | 173 | function buildWorkEntryExpandedBody(entry: ThreadWorkEntry): string | null { |
| 696 | const match = wrapperFlagPattern.exec(value); | 174 | const blocks: string[] = []; |
| 697 | if (!match) { | 175 | const append = (value: string | null | undefined) => { |
| 698 | return null; | 176 | const trimmed = value?.trim(); |
| 699 | } | 177 | if (trimmed && !blocks.includes(trimmed)) blocks.push(trimmed); |
| 700 | |||
| 701 | const command = value.slice(match.index + match[0].length).trim(); | ||
| 702 | if (command.length === 0) { | ||
| 703 | return null; | ||
| 704 | } | ||
| 705 | |||
| 706 | const unwrapped = trimMatchingOuterQuotes(command); | ||
| 707 | return unwrapped.length > 0 ? unwrapped : null; | ||
| 708 | } | ||
| 709 | |||
| 710 | function unwrapKnownShellCommandWrapper(value: string): string { | ||
| 711 | const split = splitExecutableAndRest(value); | ||
| 712 | if (!split || split.rest.length === 0) { | ||
| 713 | return value; | ||
| 714 | } | ||
| 715 | |||
| 716 | const shell = executableBasename(split.executable); | ||
| 717 | if (!shell) { | ||
| 718 | return value; | ||
| 719 | } | ||
| 720 | |||
| 721 | const spec = findShellWrapperSpec(shell); | ||
| 722 | if (!spec) { | ||
| 723 | return value; | ||
| 724 | } | ||
| 725 | |||
| 726 | return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; | ||
| 727 | } | ||
| 728 | |||
| 729 | function formatCommandArrayPart(value: string): string { | ||
| 730 | return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; | ||
| 731 | } | ||
| 732 | |||
| 733 | function formatCommandValue(value: unknown): string | null { | ||
| 734 | const direct = asTrimmedString(value); | ||
| 735 | if (direct) { | ||
| 736 | return direct; | ||
| 737 | } | ||
| 738 | if (!Array.isArray(value)) { | ||
| 739 | return null; | ||
| 740 | } | ||
| 741 | const parts: Array<string> = []; | ||
| 742 | for (const entry of value) { | ||
| 743 | const part = asTrimmedString(entry); | ||
| 744 | if (part !== null) { | ||
| 745 | parts.push(part); | ||
| 746 | } | ||
| 747 | } | ||
| 748 | if (parts.length === 0) { | ||
| 749 | return null; | ||
| 750 | } | ||
| 751 | return parts.map((part) => formatCommandArrayPart(part)).join(" "); | ||
| 752 | } | ||
| 753 | |||
| 754 | function normalizeCommandValue(value: unknown): string | null { | ||
| 755 | const formatted = formatCommandValue(value); | ||
| 756 | return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; | ||
| 757 | } | ||
| 758 | |||
| 759 | function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null { | ||
| 760 | const formatted = formatCommandValue(value); | ||
| 761 | if (!formatted || normalizedCommand === null) { | ||
| 762 | return null; | ||
| 763 | } | ||
| 764 | return formatted === normalizedCommand ? null : formatted; | ||
| 765 | } | ||
| 766 | |||
| 767 | function extractToolCommand(payload: Record<string, unknown> | null): { | ||
| 768 | command: string | null; | ||
| 769 | rawCommand: string | null; | ||
| 770 | } { | ||
| 771 | const data = asRecord(payload?.data); | ||
| 772 | const item = asRecord(data?.item); | ||
| 773 | const itemResult = asRecord(item?.result); | ||
| 774 | const itemInput = asRecord(item?.input); | ||
| 775 | const itemType = asTrimmedString(payload?.itemType); | ||
| 776 | const detail = asTrimmedString(payload?.detail); | ||
| 777 | const candidates: unknown[] = [ | ||
| 778 | item?.command, | ||
| 779 | itemInput?.command, | ||
| 780 | itemResult?.command, | ||
| 781 | data?.command, | ||
| 782 | itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, | ||
| 783 | ]; | ||
| 784 | |||
| 785 | for (const candidate of candidates) { | ||
| 786 | const command = normalizeCommandValue(candidate); | ||
| 787 | if (!command) { | ||
| 788 | continue; | ||
| 789 | } | ||
| 790 | return { | ||
| 791 | command, | ||
| 792 | rawCommand: toRawToolCommand(candidate, command), | ||
| 793 | }; | ||
| 794 | } | ||
| 795 | |||
| 796 | return { | ||
| 797 | command: null, | ||
| 798 | rawCommand: null, | ||
| 799 | }; | 178 | }; |
| 179 | append(entry.rawCommand ?? entry.command); | ||
| 180 | append(entry.detail); | ||
| 181 | if (entry.changedFiles?.length) append(entry.changedFiles.join("\n")); | ||
| 182 | append(JSON.stringify(entry.structuredPayload, null, 2)); | ||
| 183 | return blocks.length === 0 ? null : blocks.join("\n\n"); | ||
| 800 | } | 184 | } |
| 801 | 185 | ||
| 802 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { | 186 | function toFeedActivity(entry: ThreadWorkEntry): ThreadFeedActivity { |
| 803 | return asTrimmedString(payload?.title); | 187 | const summary = capitalizePhrase(entry.toolTitle ?? entry.label); |
| 804 | } | 188 | const detail = workEntryPreview(entry); |
| 805 | 189 | const fullDetail = buildWorkEntryExpandedBody(entry); | |
| 806 | function extractWorkLogToolLifecycleStatus( | ||
| 807 | payload: Record<string, unknown> | null, | ||
| 808 | ): WorkLogToolLifecycleStatus | undefined { | ||
| 809 | const status = payload?.status; | ||
| 810 | if ( | ||
| 811 | status === "inProgress" || | ||
| 812 | status === "completed" || | ||
| 813 | status === "failed" || | ||
| 814 | status === "declined" || | ||
| 815 | status === "stopped" | ||
| 816 | ) { | ||
| 817 | return status; | ||
| 818 | } | ||
| 819 | return undefined; | ||
| 820 | } | ||
| 821 | |||
| 822 | function stripTrailingExitCode(value: string): { | ||
| 823 | output: string | null; | ||
| 824 | exitCode?: number | undefined; | ||
| 825 | } { | ||
| 826 | const trimmed = value.trim(); | ||
| 827 | const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec( | ||
| 828 | trimmed, | ||
| 829 | ); | ||
| 830 | if (!match?.groups) { | ||
| 831 | return { | ||
| 832 | output: trimmed.length > 0 ? trimmed : null, | ||
| 833 | }; | ||
| 834 | } | ||
| 835 | const exitCode = Number.parseInt(match.groups.code ?? "", 10); | ||
| 836 | const normalizedOutput = match.groups.output?.trim() ?? ""; | ||
| 837 | return { | 190 | return { |
| 838 | output: normalizedOutput.length > 0 ? normalizedOutput : null, | 191 | id: entry.id, |
| 839 | ...(Number.isInteger(exitCode) ? { exitCode } : {}), | 192 | createdAt: entry.createdAt, |
| 193 | runId: entry.runId, | ||
| 194 | summary, | ||
| 195 | detail, | ||
| 196 | fullDetail, | ||
| 197 | icon: workEntryIcon(entry), | ||
| 198 | copyText: [summary, detail, fullDetail] | ||
| 199 | .filter( | ||
| 200 | (value, index, values): value is string => | ||
| 201 | Boolean(value) && values.indexOf(value) === index, | ||
| 202 | ) | ||
| 203 | .join("\n"), | ||
| 204 | toolLike: workEntryIsToolLike(entry), | ||
| 205 | status: workEntryStatus(entry), | ||
| 840 | }; | 206 | }; |
| 841 | } | 207 | } |
| 842 | 208 | ||
| 843 | function extractWorkLogItemType( | 209 | function byCreatedAt<A extends { readonly createdAt: string }>(left: A, right: A): number { |
| 844 | payload: Record<string, unknown> | null, | 210 | return left.createdAt.localeCompare(right.createdAt); |
| 845 | ): WorkLogEntry["itemType"] | undefined { | ||
| 846 | if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { | ||
| 847 | return payload.itemType; | ||
| 848 | } | ||
| 849 | return undefined; | ||
| 850 | } | ||
| 851 | |||
| 852 | function extractWorkLogRequestKind( | ||
| 853 | payload: Record<string, unknown> | null, | ||
| 854 | ): WorkLogEntry["requestKind"] | undefined { | ||
| 855 | if ( | ||
| 856 | payload?.requestKind === "command" || | ||
| 857 | payload?.requestKind === "file-read" || | ||
| 858 | payload?.requestKind === "file-change" | ||
| 859 | ) { | ||
| 860 | return payload.requestKind; | ||
| 861 | } | ||
| 862 | return requestKindFromRequestType(payload?.requestType) ?? undefined; | ||
| 863 | } | ||
| 864 | |||
| 865 | function pushChangedFile(target: string[], seen: Set<string>, value: unknown) { | ||
| 866 | const normalized = asTrimmedString(value); | ||
| 867 | if (!normalized || seen.has(normalized)) { | ||
| 868 | return; | ||
| 869 | } | ||
| 870 | seen.add(normalized); | ||
| 871 | target.push(normalized); | ||
| 872 | } | 211 | } |
| 873 | 212 | ||
| 874 | function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) { | ||
| 875 | if (depth > 4 || target.length >= 12) { | ||
| 876 | return; | ||
| 877 | } | ||
| 878 | if (Array.isArray(value)) { | ||
| 879 | for (const entry of value) { | ||
| 880 | collectChangedFiles(entry, target, seen, depth + 1); | ||
| 881 | if (target.length >= 12) { | ||
| 882 | return; | ||
| 883 | } | ||
| 884 | } | ||
| 885 | return; | ||
| 886 | } | ||
| 887 | |||
| 888 | const record = asRecord(value); | ||
| 889 | if (!record) { | ||
| 890 | return; | ||
| 891 | } | ||
| 892 | |||
| 893 | pushChangedFile(target, seen, record.path); | ||
| 894 | pushChangedFile(target, seen, record.filePath); | ||
| 895 | pushChangedFile(target, seen, record.relativePath); | ||
| 896 | pushChangedFile(target, seen, record.filename); | ||
| 897 | pushChangedFile(target, seen, record.newPath); | ||
| 898 | pushChangedFile(target, seen, record.oldPath); | ||
| 899 | |||
| 900 | for (const nestedKey of [ | ||
| 901 | "item", | ||
| 902 | "result", | ||
| 903 | "input", | ||
| 904 | "data", | ||
| 905 | "changes", | ||
| 906 | "files", | ||
| 907 | "edits", | ||
| 908 | "patch", | ||
| 909 | "patches", | ||
| 910 | "operations", | ||
| 911 | ]) { | ||
| 912 | if (!(nestedKey in record)) { | ||
| 913 | continue; | ||
| 914 | } | ||
| 915 | collectChangedFiles(record[nestedKey], target, seen, depth + 1); | ||
| 916 | if (target.length >= 12) { | ||
| 917 | return; | ||
| 918 | } | ||
| 919 | } | ||
| 920 | } | ||
| 921 | |||
| 922 | function extractChangedFiles(payload: Record<string, unknown> | null): string[] { | ||
| 923 | const changedFiles: string[] = []; | ||
| 924 | const seen = new Set<string>(); | ||
| 925 | collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); | ||
| 926 | return changedFiles; | ||
| 927 | } | ||
| 928 | |||
| 929 | function compareActivityLifecycleRank(kind: string): number { | ||
| 930 | if (kind.endsWith(".started") || kind === "tool.started") { | ||
| 931 | return 0; | ||
| 932 | } | ||
| 933 | if (kind.endsWith(".progress") || kind.endsWith(".updated")) { | ||
| 934 | return 1; | ||
| 935 | } | ||
| 936 | if (kind.endsWith(".completed") || kind.endsWith(".resolved")) { | ||
| 937 | return 2; | ||
| 938 | } | ||
| 939 | return 1; | ||
| 940 | } | ||
| 941 | |||
| 942 | const activityOrder = Order.combineAll<OrchestrationThreadActivity>([ | ||
| 943 | Order.mapInput(Order.Number, (activity) => activity.sequence ?? Number.MAX_SAFE_INTEGER), | ||
| 944 | Order.mapInput(Order.String, (activity) => activity.createdAt), | ||
| 945 | Order.mapInput(Order.Number, (activity) => compareActivityLifecycleRank(activity.kind)), | ||
| 946 | Order.mapInput(Order.String, (activity) => activity.id), | ||
| 947 | ]); | ||
| 948 | |||
| 949 | function isEmptyMessage(entry: RawThreadFeedEntry): boolean { | 213 | function isEmptyMessage(entry: RawThreadFeedEntry): boolean { |
| 950 | if (entry.type !== "message") { | 214 | return ( |
| 951 | return false; | 215 | entry.type === "message" && |
| 952 | } | 216 | entry.message.text.trim().length === 0 && |
| 953 | const hasText = entry.message.text.trim().length > 0; | 217 | (entry.message.attachments ?? []).length === 0 |
| 954 | const hasAttachments = (entry.message.attachments ?? []).length > 0; | 218 | ); |
| 955 | return !hasText && !hasAttachments; | ||
| 956 | } | 219 | } |
| 957 | 220 | ||
| 958 | function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] { | 221 | function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] { |
| 959 | const grouped: ThreadFeedEntry[] = []; | 222 | const grouped: ThreadFeedEntry[] = []; |
| 960 | // Mutable backing array for the trailing group so appending an activity is | ||
| 961 | // O(1) instead of re-copying the group (which made this loop quadratic on | ||
| 962 | // long tool runs). The array is only mutated while it is the trailing group. | ||
| 963 | let openGroupActivities: ThreadFeedActivity[] | null = null; | ||
| 964 | let openGroupTurnId: TurnId | null = null; | ||
| 965 | |||
| 966 | for (const entry of entries) { | 223 | for (const entry of entries) { |
| 967 | // Skip empty messages so they don't break activity grouping. | 224 | if (isEmptyMessage(entry)) continue; |
| 968 | if (isEmptyMessage(entry)) { | ||
| 969 | continue; | ||
| 970 | } | ||
| 971 | |||
| 972 | if (entry.type !== "activity") { | 225 | if (entry.type !== "activity") { |
| 973 | grouped.push(entry); | 226 | grouped.push(entry); |
| 974 | openGroupActivities = null; | 227 | openGroupActivities = null; |
| 975 | continue; | 228 | continue; |
| 976 | } | 229 | } |
| 977 | 230 | const previous = grouped.at(-1); | |
| 978 | if (openGroupActivities !== null && openGroupTurnId === entry.turnId) { | 231 | if (previous?.type === "activity-group" && previous.runId === entry.runId) { |
| 979 | openGroupActivities.push(entry.activity); | 232 | grouped[grouped.length - 1] = { |
| 233 | ...previous, | ||
| 234 | activities: [...previous.activities, entry.activity], | ||
| 235 | }; | ||
| 980 | continue; | 236 | continue; |
| 981 | } | 237 | } |
| 982 | |||
| 983 | openGroupActivities = [entry.activity]; | ||
| 984 | openGroupTurnId = entry.turnId; | ||
| 985 | grouped.push({ | 238 | grouped.push({ |
| 986 | type: "activity-group", | 239 | type: "activity-group", |
| 987 | id: entry.id, | 240 | id: entry.id, |
| 988 | createdAt: entry.createdAt, | 241 | createdAt: entry.createdAt, |
| 989 | turnId: entry.turnId, | 242 | runId: entry.runId, |
| 990 | activities: openGroupActivities, | 243 | activities: [entry.activity], |
| 991 | }); | 244 | }); |
| 992 | } | 245 | } |
| 993 | |||
| 994 | return grouped; | 246 | return grouped; |
| 995 | } | 247 | } |
| 996 | 248 | ||
| 997 | function computeElapsedMs(startIso: string, endIso: string): number | null { | 249 | function computeElapsedMs(startIso: string, endIso: string): number | null { |
| 998 | const start = Date.parse(startIso); | 250 | const start = Date.parse(startIso); |
| 999 | const end = Date.parse(endIso); | 251 | const end = Date.parse(endIso); |
| 1000 | if (!Number.isFinite(start) || !Number.isFinite(end)) { | 252 | return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; |
| 1001 | return null; | ||
| 1002 | } | ||
| 1003 | return Math.max(0, end - start); | ||
| 1004 | } | 253 | } |
| 1005 | 254 | ||
| 1006 | function maxIsoTimestamp(a: string | null, b: string | null): string | null { | 255 | function maxIsoTimestamp(left: string | null, right: string | null): string | null { |
| 1007 | if (a === null) return b; | 256 | if (left === null) return right; |
| 1008 | if (b === null) return a; | 257 | if (right === null) return left; |
| 1009 | const aMs = Date.parse(a); | 258 | return Date.parse(right) > Date.parse(left) ? right : left; |
| 1010 | const bMs = Date.parse(b); | ||
| 1011 | if (!Number.isFinite(aMs)) return b; | ||
| 1012 | if (!Number.isFinite(bMs)) return a; | ||
| 1013 | return bMs > aMs ? b : a; | ||
| 1014 | } | 259 | } |
| 1015 | 260 | ||
| 1016 | function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { | 261 | function unsettledRunId(latestRun: ThreadFeedLatestRun | null): RunId | null { |
| 1017 | if (!latestTurn) { | 262 | if (!latestRun) return null; |
| 1018 | return null; | 263 | return latestRun.completedAt === null || |
| 1019 | } | 264 | latestRun.status === "starting" || |
| 1020 | const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; | 265 | latestRun.status === "running" || |
| 1021 | return settled ? null : latestTurn.turnId; | 266 | latestRun.status === "waiting" |
| 267 | ? latestRun.runId | ||
| 268 | : null; | ||
| 1022 | } | 269 | } |
| 1023 | 270 | ||
| 1024 | interface ThreadFeedTurnFold { | 271 | interface ThreadFeedRunFold { |
| 1025 | readonly turnId: TurnId; | 272 | readonly runId: RunId; |
| 1026 | readonly createdAt: string; | 273 | readonly createdAt: string; |
| 1027 | readonly hiddenEntryIds: ReadonlySet<string>; | 274 | readonly hiddenEntryIds: ReadonlySet<string>; |
| 1028 | readonly label: string; | 275 | readonly label: string; |
| 1029 | } | 276 | } |
| 1030 | 277 | ||
| 1031 | function deriveThreadFeedTurnFolds( | 278 | function deriveThreadFeedRunFolds( |
| 1032 | feed: ReadonlyArray<ThreadFeedEntry>, | 279 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1033 | latestTurn: ThreadFeedLatestTurn | null, | 280 | latestRun: ThreadFeedLatestRun | null, |
| 1034 | ): ReadonlyMap<string, ThreadFeedTurnFold> { | 281 | ): ReadonlyMap<string, ThreadFeedRunFold> { |
| 1035 | const terminalAssistantMessageIdByTurn = new Map<TurnId, string>(); | 282 | const terminalAssistantMessageIdByRun = new Map<RunId, string>(); |
| 1036 | for (const entry of feed) { | 283 | for (const entry of feed) { |
| 1037 | if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { | 284 | if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) { |
| 1038 | terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); | 285 | terminalAssistantMessageIdByRun.set(entry.message.runId, entry.id); |
| 1039 | } | 286 | } |
| 1040 | } | 287 | } |
| 1041 | 288 | ||
| 1042 | interface TurnGroup { | 289 | const groupsByRunId = new Map< |
| 1043 | readonly entries: ThreadFeedEntry[]; | 290 | RunId, |
| 1044 | readonly startBoundary: string | null; | 291 | { entries: ThreadFeedEntry[]; startBoundary: string | null } |
| 1045 | } | 292 | >(); |
| 1046 | const groupsByTurnId = new Map<TurnId, TurnGroup>(); | ||
| 1047 | let pendingUserBoundary: string | null = null; | 293 | let pendingUserBoundary: string | null = null; |
| 1048 | for (const entry of feed) { | 294 | for (const entry of feed) { |
| 1049 | if (entry.type === "message" && entry.message.role === "user") { | 295 | if (entry.type === "message" && entry.message.role === "user") { |
| 1050 | pendingUserBoundary = entry.message.createdAt; | 296 | pendingUserBoundary = entry.message.createdAt; |
| 1051 | continue; | 297 | continue; |
| 1052 | } | 298 | } |
| 1053 | const turnId = | 299 | const runId = |
| 1054 | entry.type === "message" && entry.message.role === "assistant" | 300 | entry.type === "message" && entry.message.role === "assistant" |
| 1055 | ? entry.message.turnId | 301 | ? entry.message.runId |
| 1056 | : entry.type === "activity-group" | 302 | : entry.type === "activity-group" |
| 1057 | ? entry.turnId | 303 | ? entry.runId |
| 1058 | : null; | 304 | : null; |
| 1059 | if (!turnId) { | 305 | if (!runId) continue; |
| 1060 | continue; | 306 | let group = groupsByRunId.get(runId); |
| 1061 | } | ||
| 1062 | let group = groupsByTurnId.get(turnId); | ||
| 1063 | if (!group) { | 307 | if (!group) { |
| 1064 | group = { | 308 | group = { entries: [], startBoundary: pendingUserBoundary }; |
| 1065 | entries: [], | ||
| 1066 | startBoundary: pendingUserBoundary, | ||
| 1067 | }; | ||
| 1068 | pendingUserBoundary = null; | 309 | pendingUserBoundary = null; |
| 1069 | groupsByTurnId.set(turnId, group); | 310 | groupsByRunId.set(runId, group); |
| 1070 | } | 311 | } |
| 1071 | group.entries.push(entry); | 312 | group.entries.push(entry); |
| 1072 | } | 313 | } |
| 1073 | 314 | ||
| 1074 | const unsettledTurnId = deriveUnsettledTurnId(latestTurn); | 315 | const activeRunId = unsettledRunId(latestRun); |
| 1075 | const foldsByAnchorId = new Map<string, ThreadFeedTurnFold>(); | 316 | const foldsByAnchorId = new Map<string, ThreadFeedRunFold>(); |
| 1076 | for (const [turnId, group] of groupsByTurnId) { | 317 | for (const [runId, group] of groupsByRunId) { |
| 1077 | const { entries } = group; | 318 | if ( |
| 1078 | if (turnId === unsettledTurnId) { | 319 | runId === activeRunId || |
| 1079 | continue; | 320 | group.entries.some((entry) => entry.type === "message" && entry.message.streaming) |
| 1080 | } | 321 | ) { |
| 1081 | if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) { | ||
| 1082 | continue; | 322 | continue; |
| 1083 | } | 323 | } |
| 1084 | 324 | const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId); | |
| 1085 | const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); | ||
| 1086 | const hiddenEntryIds = new Set( | 325 | const hiddenEntryIds = new Set( |
| 1087 | entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), | 326 | group.entries.filter((entry) => entry.id !== terminalAssistantId).map((entry) => entry.id), |
| 1088 | ); | 327 | ); |
| 1089 | if (hiddenEntryIds.size === 0) { | 328 | const firstEntry = group.entries[0]; |
| 1090 | continue; | 329 | const lastEntry = group.entries.at(-1); |
| 1091 | } | 330 | if (hiddenEntryIds.size === 0 || !firstEntry || !lastEntry) continue; |
| 1092 | 331 | const terminalEntry = terminalAssistantId | |
| 1093 | const firstEntry = entries[0]; | 332 | ? group.entries.find((entry) => entry.id === terminalAssistantId) |
| 1094 | const lastEntry = entries.at(-1); | ||
| 1095 | if (!firstEntry || !lastEntry) { | ||
| 1096 | continue; | ||
| 1097 | } | ||
| 1098 | const terminalEntry = terminalAssistantMessageId | ||
| 1099 | ? entries.find((entry) => entry.id === terminalAssistantMessageId) | ||
| 1100 | : null; | 333 | : null; |
| 1101 | const latestTurnMatches = latestTurn?.turnId === turnId; | 334 | const latestRunMatches = latestRun?.runId === runId; |
| 1102 | const lastEntryEnd = | 335 | const lastEntryEnd = |
| 1103 | lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; | 336 | lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; |
| 1104 | const elapsedMs = | 337 | const elapsedMs = |
| 1105 | latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt | 338 | latestRunMatches && latestRun.startedAt && latestRun.completedAt |
| 1106 | ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt) | 339 | ? computeElapsedMs(latestRun.startedAt, latestRun.completedAt) |
| 1107 | : computeElapsedMs( | 340 | : computeElapsedMs( |
| 1108 | group.startBoundary ?? firstEntry.createdAt, | 341 | group.startBoundary ?? firstEntry.createdAt, |
| 1109 | maxIsoTimestamp( | 342 | maxIsoTimestamp( |
| @@ -1112,20 +345,19 @@ function deriveThreadFeedTurnFolds( · 2 unmodified lines | |||
| 1112 | ) ?? lastEntryEnd, | 345 | ) ?? lastEntryEnd, |
| 1113 | ); | 346 | ); |
| 1114 | const duration = elapsedMs === null ? null : formatDuration(elapsedMs); | 347 | const duration = elapsedMs === null ? null : formatDuration(elapsedMs); |
| 1115 | const interrupted = latestTurnMatches && latestTurn.state === "interrupted"; | 348 | const interrupted = |
| 1116 | const label = interrupted | 349 | latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); |
| 1117 | ? duration | ||
| 1118 | ? `You stopped after ${duration}` | ||
| 1119 | : "You stopped this response" | ||
| 1120 | : duration | ||
| 1121 | ? `Worked for ${duration}` | ||
| 1122 | : "Worked"; | ||
| 1123 | |||
| 1124 | foldsByAnchorId.set(firstEntry.id, { | 350 | foldsByAnchorId.set(firstEntry.id, { |
| 1125 | turnId, | 351 | runId, |
| 1126 | createdAt: firstEntry.createdAt, | 352 | createdAt: firstEntry.createdAt, |
| 1127 | hiddenEntryIds, | 353 | hiddenEntryIds, |
| 1128 | label, | 354 | label: interrupted |
| 355 | ? duration | ||
| 356 | ? `You stopped after ${duration}` | ||
| 357 | : "You stopped this response" | ||
| 358 | : duration | ||
| 359 | ? `Worked for ${duration}` | ||
| 360 | : "Worked", | ||
| 1129 | }); | 361 | }); |
| 1130 | } | 362 | } |
| 1131 | return foldsByAnchorId; | 363 | return foldsByAnchorId; |
| @@ -1133,41 +365,31 @@ function deriveThreadFeedTurnFolds( · 1 unmodified line | |||
| 1133 | 365 | ||
| 1134 | export function deriveThreadFeedPresentation( | 366 | export function deriveThreadFeedPresentation( |
| 1135 | feed: ReadonlyArray<ThreadFeedEntry>, | 367 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1136 | latestTurn: ThreadFeedLatestTurn | null, | 368 | latestRun: ThreadFeedLatestRun | null, |
| 1137 | expandedTurnIds: ReadonlySet<TurnId>, | 369 | expandedRunIds: ReadonlySet<RunId>, |
| 1138 | expandedWorkGroupIds: ReadonlySet<string> = new Set(), | ||
| 1139 | activeWorkStartedAt: string | null = null, | ||
| 1140 | ): ThreadFeedEntry[] { | 370 | ): ThreadFeedEntry[] { |
| 1141 | const sourceFeed = feed.filter( | 371 | const sourceFeed = feed.filter((entry) => entry.type !== "run-fold"); |
| 1142 | (entry) => | 372 | const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun); |
| 1143 | entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", | ||
| 1144 | ); | ||
| 1145 | const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); | ||
| 1146 | const collapsedEntryIds = new Set<string>(); | 373 | const collapsedEntryIds = new Set<string>(); |
| 1147 | for (const fold of foldsByAnchorId.values()) { | 374 | for (const fold of foldsByAnchorId.values()) { |
| 1148 | if (!expandedTurnIds.has(fold.turnId)) { | 375 | if (!expandedRunIds.has(fold.runId)) { |
| 1149 | for (const entryId of fold.hiddenEntryIds) { | 376 | for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); |
| 1150 | collapsedEntryIds.add(entryId); | ||
| 1151 | } | ||
| 1152 | } | 377 | } |
| 1153 | } | 378 | } |
| 1154 | |||
| 1155 | const result: ThreadFeedEntry[] = []; | 379 | const result: ThreadFeedEntry[] = []; |
| 1156 | for (const entry of sourceFeed) { | 380 | for (const entry of sourceFeed) { |
| 1157 | const fold = foldsByAnchorId.get(entry.id); | 381 | const fold = foldsByAnchorId.get(entry.id); |
| 1158 | if (fold) { | 382 | if (fold) { |
| 1159 | result.push({ | 383 | result.push({ |
| 1160 | type: "turn-fold", | 384 | type: "run-fold", |
| 1161 | id: `turn-fold:${fold.turnId}`, | 385 | id: `run-fold:${fold.runId}`, |
| 1162 | createdAt: fold.createdAt, | 386 | createdAt: fold.createdAt, |
| 1163 | turnId: fold.turnId, | 387 | runId: fold.runId, |
| 1164 | label: fold.label, | 388 | label: fold.label, |
| 1165 | expanded: expandedTurnIds.has(fold.turnId), | 389 | expanded: expandedRunIds.has(fold.runId), |
| 1166 | }); | 390 | }); |
| 1167 | } | 391 | } |
| 1168 | if (!collapsedEntryIds.has(entry.id)) { | 392 | if (!collapsedEntryIds.has(entry.id)) result.push(entry); |
| 1169 | appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); | ||
| 1170 | } | ||
| 1171 | } | 393 | } |
| 1172 | if (activeWorkStartedAt !== null) { | 394 | if (activeWorkStartedAt !== null) { |
| 1173 | result.push({ | 395 | result.push({ |
| @@ -1179,248 +401,54 @@ export function deriveThreadFeedPresentation( · 5 unmodified lines | |||
| 1179 | return result; | 401 | return result; |
| 1180 | } | 402 | } |
| 1181 | 403 | ||
| 1182 | function appendPresentedFeedEntry( | ||
| 1183 | result: ThreadFeedEntry[], | ||
| 1184 | entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "working" }>, | ||
| 1185 | expandedWorkGroupIds: ReadonlySet<string>, | ||
| 1186 | ): void { | ||
| 1187 | if (entry.type !== "activity-group") { | ||
| 1188 | result.push(entry); | ||
| 1189 | return; | ||
| 1190 | } | ||
| 1191 | |||
| 1192 | const activities = entry.activities.filter( | ||
| 1193 | (activity) => !(activity.toolLike && activity.status === "neutral"), | ||
| 1194 | ); | ||
| 1195 | if (activities.length === 0) { | ||
| 1196 | return; | ||
| 1197 | } | ||
| 1198 | if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { | ||
| 1199 | result.push({ | ||
| 1200 | ...entry, | ||
| 1201 | activities, | ||
| 1202 | }); | ||
| 1203 | return; | ||
| 1204 | } | ||
| 1205 | |||
| 1206 | const groupId = entry.id; | ||
| 1207 | const expanded = expandedWorkGroupIds.has(groupId); | ||
| 1208 | const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 1209 | const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES); | ||
| 1210 | |||
| 1211 | for (const activity of visibleActivities) { | ||
| 1212 | result.push({ | ||
| 1213 | type: "activity-group", | ||
| 1214 | id: activity.id, | ||
| 1215 | createdAt: activity.createdAt, | ||
| 1216 | turnId: activity.turnId, | ||
| 1217 | activities: [activity], | ||
| 1218 | }); | ||
| 1219 | } | ||
| 1220 | result.push({ | ||
| 1221 | type: "work-toggle", | ||
| 1222 | id: `work-toggle:${groupId}`, | ||
| 1223 | createdAt: entry.createdAt, | ||
| 1224 | turnId: entry.turnId, | ||
| 1225 | groupId, | ||
| 1226 | hiddenCount, | ||
| 1227 | expanded, | ||
| 1228 | onlyToolActivities: activities.every((activity) => activity.toolLike), | ||
| 1229 | }); | ||
| 1230 | } | ||
| 1231 | |||
| 1232 | /** | ||
| 1233 | * Sorts activities into lifecycle order. `derivePendingApprovals` and | ||
| 1234 | * `derivePendingUserInputs` both expect this ordering; sorting once and | ||
| 1235 | * passing the result to both avoids re-sorting the full activity history | ||
| 1236 | * per derivation. | ||
| 1237 | */ | ||
| 1238 | export function sortThreadActivities( | ||
| 1239 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 1240 | ): ReadonlyArray<OrchestrationThreadActivity> { | ||
| 1241 | return Arr.sort(activities, activityOrder); | ||
| 1242 | } | ||
| 1243 | |||
| 1244 | export function derivePendingApprovals( | ||
| 1245 | sortedActivities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 1246 | ): PendingApproval[] { | ||
| 1247 | const openByRequestId = new Map<ApprovalRequestId, PendingApproval>(); | ||
| 1248 | |||
| 1249 | for (const activity of sortedActivities) { | ||
| 1250 | const payload = | ||
| 1251 | activity.payload && typeof activity.payload === "object" | ||
| 1252 | ? (activity.payload as Record<string, unknown>) | ||
| 1253 | : null; | ||
| 1254 | const requestId = parseApprovalRequestId(payload?.requestId); | ||
| 1255 | const requestKind = | ||
| 1256 | payload?.requestKind === "command" || | ||
| 1257 | payload?.requestKind === "file-read" || | ||
| 1258 | payload?.requestKind === "file-change" | ||
| 1259 | ? payload.requestKind | ||
| 1260 | : requestKindFromRequestType(payload?.requestType); | ||
| 1261 | const detail = typeof payload?.detail === "string" ? payload.detail : undefined; | ||
| 1262 | |||
| 1263 | if (activity.kind === "approval.requested" && requestId && requestKind) { | ||
| 1264 | openByRequestId.set(requestId, { | ||
| 1265 | requestId, | ||
| 1266 | requestKind, | ||
| 1267 | createdAt: activity.createdAt, | ||
| 1268 | ...(detail ? { detail } : {}), | ||
| 1269 | }); | ||
| 1270 | continue; | ||
| 1271 | } | ||
| 1272 | |||
| 1273 | if (activity.kind === "approval.resolved" && requestId) { | ||
| 1274 | openByRequestId.delete(requestId); | ||
| 1275 | continue; | ||
| 1276 | } | ||
| 1277 | |||
| 1278 | if ( | ||
| 1279 | activity.kind === "provider.approval.respond.failed" && | ||
| 1280 | requestId && | ||
| 1281 | isStalePendingRequestFailureDetail(detail) | ||
| 1282 | ) { | ||
| 1283 | openByRequestId.delete(requestId); | ||
| 1284 | } | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date); | ||
| 1288 | } | ||
| 1289 | |||
| 1290 | export function derivePendingUserInputs( | ||
| 1291 | sortedActivities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 1292 | ): PendingUserInput[] { | ||
| 1293 | const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>(); | ||
| 1294 | |||
| 1295 | for (const activity of sortedActivities) { | ||
| 1296 | const payload = | ||
| 1297 | activity.payload && typeof activity.payload === "object" | ||
| 1298 | ? (activity.payload as Record<string, unknown>) | ||
| 1299 | : null; | ||
| 1300 | const requestId = parseApprovalRequestId(payload?.requestId); | ||
| 1301 | const detail = typeof payload?.detail === "string" ? payload.detail : undefined; | ||
| 1302 | |||
| 1303 | if (activity.kind === "user-input.requested" && requestId) { | ||
| 1304 | const questions = parseUserInputQuestions(payload); | ||
| 1305 | if (!questions) { | ||
| 1306 | continue; | ||
| 1307 | } | ||
| 1308 | openByRequestId.set(requestId, { | ||
| 1309 | requestId, | ||
| 1310 | createdAt: activity.createdAt, | ||
| 1311 | questions, | ||
| 1312 | }); | ||
| 1313 | continue; | ||
| 1314 | } | ||
| 1315 | |||
| 1316 | if (activity.kind === "user-input.resolved" && requestId) { | ||
| 1317 | openByRequestId.delete(requestId); | ||
| 1318 | continue; | ||
| 1319 | } | ||
| 1320 | |||
| 1321 | if ( | ||
| 1322 | activity.kind === "provider.user-input.respond.failed" && | ||
| 1323 | requestId && | ||
| 1324 | isStalePendingRequestFailureDetail(detail) | ||
| 1325 | ) { | ||
| 1326 | openByRequestId.delete(requestId); | ||
| 1327 | } | ||
| 1328 | } | ||
| 1329 | |||
| 1330 | return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date); | ||
| 1331 | } | ||
| 1332 | |||
| 1333 | export function setPendingUserInputCustomAnswer( | 404 | export function setPendingUserInputCustomAnswer( |
| 1334 | draft: PendingUserInputDraftAnswer | undefined, | 405 | draft: PendingUserInputDraftAnswer | undefined, |
| 1335 | customAnswer: string, | 406 | customAnswer: string, |
| 1336 | ): PendingUserInputDraftAnswer { | 407 | ): PendingUserInputDraftAnswer { |
| 1337 | const selectedOptionLabel = | 408 | const selectedOptionLabel = |
| 1338 | customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; | 409 | customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; |
| 1339 | return { | 410 | return { customAnswer, ...(selectedOptionLabel ? { selectedOptionLabel } : {}) }; |
| 1340 | customAnswer, | ||
| 1341 | ...(selectedOptionLabel ? { selectedOptionLabel } : {}), | ||
| 1342 | }; | ||
| 1343 | } | 411 | } |
| 1344 | 412 | ||
| 1345 | export function buildPendingUserInputAnswers( | 413 | export function buildPendingUserInputAnswers( |
| 1346 | questions: ReadonlyArray<UserInputQuestion>, | 414 | questions: ReadonlyArray<ThreadUserInputQuestion>, |
| 1347 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | 415 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 1348 | ): Record<string, string> | null { | 416 | ): Record<string, string> | null { |
| 1349 | const answers: Record<string, string> = {}; | 417 | const answers: Record<string, string> = {}; |
| 1350 | |||
| 1351 | for (const question of questions) { | 418 | for (const question of questions) { |
| 1352 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); | 419 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); |
| 1353 | if (!answer) { | 420 | if (!answer) return null; |
| 1354 | return null; | ||
| 1355 | } | ||
| 1356 | answers[question.id] = answer; | 421 | answers[question.id] = answer; |
| 1357 | } | 422 | } |
| 1358 | |||
| 1359 | return answers; | 423 | return answers; |
| 1360 | } | 424 | } |
| 1361 | 425 | ||
| 1362 | export function buildThreadFeed( | 426 | export function buildThreadFeed( |
| 1363 | thread: OrchestrationThread, | 427 | thread: EnvironmentThread, |
| 1364 | options?: { | 428 | options?: { readonly loadedMessages?: ReadonlyArray<ThreadConversationMessage> }, |
| 1365 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; | ||
| 1366 | }, | ||
| 1367 | ): ThreadFeedEntry[] { | 429 | ): ThreadFeedEntry[] { |
| 1368 | const loadedMessages = options?.loadedMessages ?? thread.messages; | 430 | const loadedMessages = options?.loadedMessages ?? thread.messages; |
| 1369 | const oldestLoadedMessageCreatedAt = | 431 | const oldestLoadedMessageCreatedAt = |
| 1370 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; | 432 | options?.loadedMessages === undefined ? null : (loadedMessages[0]?.createdAt ?? null); |
| 1371 | const workLogEntries = deriveWorkLogEntries(thread.activities); | 433 | const entries: RawThreadFeedEntry[] = [ |
| 1372 | const entries = Arr.sortWith( | 434 | ...loadedMessages.map((message) => ({ |
| 1373 | [ | 435 | type: "message" as const, |
| 1374 | ...loadedMessages.map<RawThreadFeedEntry>((message) => ({ | 436 | id: message.id, |
| 1375 | type: "message", | 437 | createdAt: message.createdAt, |
| 1376 | id: message.id, | 438 | message, |
| 1377 | createdAt: message.createdAt, | 439 | })), |
| 1378 | message, | 440 | ...thread.workEntries |
| 441 | .filter( | ||
| 442 | (entry) => | ||
| 443 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, | ||
| 444 | ) | ||
| 445 | .map((entry) => ({ | ||
| 446 | type: "activity" as const, | ||
| 447 | id: entry.id, | ||
| 448 | createdAt: entry.createdAt, | ||
| 449 | runId: entry.runId, | ||
| 450 | activity: toFeedActivity(entry), | ||
| 1379 | })), | 451 | })), |
| 1380 | ...workLogEntries | 452 | ]; |
| 1381 | .filter((entry) => { | 453 | return groupAdjacentActivities(entries.toSorted(byCreatedAt)); |
| 1382 | if (options?.loadedMessages === undefined) { | ||
| 1383 | return true; | ||
| 1384 | } | ||
| 1385 | return ( | ||
| 1386 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt | ||
| 1387 | ); | ||
| 1388 | }) | ||
| 1389 | .map<RawThreadFeedEntry>((entry) => { | ||
| 1390 | const summary = workEntryHeading(entry); | ||
| 1391 | const detail = workEntryPreview(entry); | ||
| 1392 | const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); | ||
| 1393 | const getCopyText = memoizeValue(() => | ||
| 1394 | [summary, detail, getFullDetail()] | ||
| 1395 | .filter((value, index, values): value is string => { | ||
| 1396 | return Boolean(value) && values.indexOf(value) === index; | ||
| 1397 | }) | ||
| 1398 | .join("\n"), | ||
| 1399 | ); | ||
| 1400 | return { | ||
| 1401 | type: "activity", | ||
| 1402 | id: entry.id, | ||
| 1403 | createdAt: entry.createdAt, | ||
| 1404 | turnId: entry.turnId, | ||
| 1405 | activity: { | ||
| 1406 | id: entry.id, | ||
| 1407 | createdAt: entry.createdAt, | ||
| 1408 | turnId: entry.turnId, | ||
| 1409 | summary, | ||
| 1410 | detail, | ||
| 1411 | canExpand: workEntryHasExpandedBody(entry), | ||
| 1412 | getFullDetail, | ||
| 1413 | getCopyText, | ||
| 1414 | icon: workEntryIcon(entry), | ||
| 1415 | toolLike: workLogEntryIsToolLike(entry), | ||
| 1416 | status: workEntryStatus(entry), | ||
| 1417 | }, | ||
| 1418 | }; | ||
| 1419 | }), | ||
| 1420 | ], | ||
| 1421 | (s) => new Date(s.createdAt), | ||
| 1422 | Order.Date, | ||
| 1423 | ); | ||
| 1424 | |||
| 1425 | return groupAdjacentActivities(entries); | ||
| 1426 | } | 454 | } |
| @@ -1,10 +1,5 @@ | |||
| 1 | import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; | 1 | import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | createThreadSearchResultsAtomFamily, | ||
| 4 | makeThreadSearchKey, | ||
| 5 | type EnvironmentThreadSearchMatch, | ||
| 6 | } from "@t3tools/client-runtime/state/thread-search"; | ||
| 7 | import { useAtomValue } from "@effect/atom-react"; | ||
| 8 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 9 | import { Atom } from "effect/unstable/reactivity"; | 4 | import { Atom } from "effect/unstable/reactivity"; |
| 10 | import { useEffect, useMemo, useState } from "react"; | 5 | import { useEffect, useMemo, useState } from "react"; |
| @@ -40,7 +35,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 29 unmodified lines | |||
| 40 | }); | 35 | }); |
| 41 | 36 | ||
| 42 | export interface ThreadDetailView { | 37 | export interface ThreadDetailView { |
| 43 | readonly data: OrchestrationThread | null; | 38 | readonly data: EnvironmentThread | null; |
| 44 | readonly error: string | null; | 39 | readonly error: string | null; |
| 45 | readonly isPending: boolean; | 40 | readonly isPending: boolean; |
| 46 | readonly isDeleted: boolean; | 41 | readonly isDeleted: boolean; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | type EnvironmentThreadState, | 7 | type EnvironmentThreadState, |
| 8 | createThreadEnvironmentAtoms, | 8 | createThreadEnvironmentAtoms, |
| 9 | } from "@t3tools/client-runtime/state/threads"; | 9 | } from "@t3tools/client-runtime/state/threads"; |
| 10 | import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell"; | ||
| 10 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 11 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 11 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 13 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| @@ -32,14 +33,21 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ · 19 unmodified lines | |||
| 32 | export function useEnvironmentThread( | 33 | export function useEnvironmentThread( |
| 33 | environmentId: EnvironmentId | null, | 34 | environmentId: EnvironmentId | null, |
| 34 | threadId: ThreadId | null, | 35 | threadId: ThreadId | null, |
| 35 | ): EnvironmentThreadState { | 36 | ): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } { |
| 36 | const result = useAtomValue( | 37 | const result = useAtomValue( |
| 37 | environmentId !== null && threadId !== null | 38 | environmentId !== null && threadId !== null |
| 38 | ? environmentThreads.stateAtom(environmentId, threadId) | 39 | ? environmentThreads.stateAtom(environmentId, threadId) |
| 39 | : EMPTY_THREAD_STATE_ATOM, | 40 | : EMPTY_THREAD_STATE_ATOM, |
| 40 | ); | 41 | ); |
| 41 | return Option.getOrElse( | 42 | const state = Option.getOrElse( |
| 42 | AsyncResult.value(result), | 43 | AsyncResult.value(result), |
| 43 | () => EMPTY_ENVIRONMENT_THREAD_STATE, | 44 | () => EMPTY_ENVIRONMENT_THREAD_STATE, |
| 44 | ) as EnvironmentThreadState; | 45 | ) as EnvironmentThreadState; |
| 46 | return { | ||
| 47 | ...state, | ||
| 48 | data: | ||
| 49 | environmentId === null | ||
| 50 | ? Option.none() | ||
| 51 | : Option.map(state.data, (projection) => presentThread(environmentId, projection)), | ||
| 52 | }; | ||
| 45 | } | 53 | } |
| @@ -1,15 +1,13 @@ | |||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useMemo, useState } from "react"; | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | 3 | ||
| 4 | import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; | 4 | import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts"; |
| 5 | import { Atom } from "effect/unstable/reactivity"; | 5 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 6 | ||
| 7 | import { threadEnvironment } from "../state/threads"; | 7 | import { threadEnvironment } from "../state/threads"; |
| 8 | import { scopedRequestKey } from "../lib/scopedEntities"; | 8 | import { scopedRequestKey } from "../lib/scopedEntities"; |
| 9 | import { | 9 | import { |
| 10 | buildPendingUserInputAnswers, | 10 | buildPendingUserInputAnswers, |
| 11 | derivePendingApprovals, | ||
| 12 | derivePendingUserInputs, | ||
| 13 | setPendingUserInputCustomAnswer, | 11 | setPendingUserInputCustomAnswer, |
| 14 | sortThreadActivities, | 12 | sortThreadActivities, |
| 15 | type PendingUserInputDraftAnswer, | 13 | type PendingUserInputDraftAnswer, |
| @@ -66,14 +64,11 @@ export function useSelectedThreadRequests() { · 50 unmodified lines | |||
| 66 | const { selectedThread: selectedThreadShell } = useThreadSelection(); | 64 | const { selectedThread: selectedThreadShell } = useThreadSelection(); |
| 67 | const selectedThread = useSelectedThreadDetail(); | 65 | const selectedThread = useSelectedThreadDetail(); |
| 68 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); | 66 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); |
| 69 | const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null); | 67 | const [respondingApprovalId, setRespondingApprovalId] = useState<RuntimeRequestId | null>(null); |
| 70 | const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>( | 68 | const [respondingUserInputId, setRespondingUserInputId] = useState<RuntimeRequestId | null>(null); |
| 71 | null, | ||
| 72 | ); | ||
| 73 | 69 | ||
| 74 | // Sort once; both derivations expect the same lifecycle ordering. | 70 | const activePendingApprovals = useMemo( |
| 75 | const sortedActivities = useMemo( | 71 | () => selectedThread?.pendingApprovals ?? [], |
| 76 | () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []), | ||
| 77 | [selectedThread], | 72 | [selectedThread], |
| 78 | ); | 73 | ); |
| 79 | const activePendingApprovals = useMemo( | 74 | const activePendingApprovals = useMemo( |
| @@ -82,8 +77,8 @@ export function useSelectedThreadRequests() { · 2 unmodified lines | |||
| 82 | ); | 77 | ); |
| 83 | const activePendingApproval = activePendingApprovals[0] ?? null; | 78 | const activePendingApproval = activePendingApprovals[0] ?? null; |
| 84 | const activePendingUserInputs = useMemo( | 79 | const activePendingUserInputs = useMemo( |
| 85 | () => derivePendingUserInputs(sortedActivities), | 80 | () => selectedThread?.pendingUserInputs ?? [], |
| 86 | [sortedActivities], | 81 | [selectedThread], |
| 87 | ); | 82 | ); |
| 88 | const activePendingUserInput = activePendingUserInputs[0] ?? null; | 83 | const activePendingUserInput = activePendingUserInputs[0] ?? null; |
| 89 | const activePendingUserInputDrafts = | 84 | const activePendingUserInputDrafts = |
| @@ -97,7 +92,7 @@ export function useSelectedThreadRequests() { · 7 unmodified lines | |||
| 97 | : null; | 92 | : null; |
| 98 | 93 | ||
| 99 | const onSelectUserInputOption = useCallback( | 94 | const onSelectUserInputOption = useCallback( |
| 100 | (requestId: ApprovalRequestId, questionId: string, label: string) => { | 95 | (requestId: RuntimeRequestId, questionId: string, label: string) => { |
| 101 | if (!selectedThreadShell) { | 96 | if (!selectedThreadShell) { |
| 102 | return; | 97 | return; |
| 103 | } | 98 | } |
| @@ -109,7 +104,7 @@ export function useSelectedThreadRequests() { · 5 unmodified lines | |||
| 109 | ); | 104 | ); |
| 110 | 105 | ||
| 111 | const onChangeUserInputCustomAnswer = useCallback( | 106 | const onChangeUserInputCustomAnswer = useCallback( |
| 112 | (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => { | 107 | (requestId: RuntimeRequestId, questionId: string, customAnswer: string) => { |
| 113 | if (!selectedThreadShell) { | 108 | if (!selectedThreadShell) { |
| 114 | return; | 109 | return; |
| 115 | } | 110 | } |
| @@ -121,10 +116,16 @@ export function useSelectedThreadRequests() { · 5 unmodified lines | |||
| 121 | ); | 116 | ); |
| 122 | 117 | ||
| 123 | const onRespondToApproval = useCallback( | 118 | const onRespondToApproval = useCallback( |
| 124 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | 119 | async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { |
| 125 | if (!selectedThreadShell) { | 120 | if (!selectedThreadShell) { |
| 126 | return; | 121 | return; |
| 127 | } | 122 | } |
| 123 | if ( | ||
| 124 | activePendingApprovals.find((approval) => approval.requestId === requestId) | ||
| 125 | ?.responseCapability !== "live" | ||
| 126 | ) { | ||
| 127 | return; | ||
| 128 | } | ||
| 128 | 129 | ||
| 129 | setRespondingApprovalId(requestId); | 130 | setRespondingApprovalId(requestId); |
| 130 | const result = await respondToApproval({ | 131 | const result = await respondToApproval({ |
| @@ -138,11 +139,16 @@ export function useSelectedThreadRequests() { · 7 unmodified lines | |||
| 138 | setRespondingApprovalId((current) => (current === requestId ? null : current)); | 139 | setRespondingApprovalId((current) => (current === requestId ? null : current)); |
| 139 | return result; | 140 | return result; |
| 140 | }, | 141 | }, |
| 141 | [respondToApproval, selectedThreadShell], | 142 | [activePendingApprovals, respondToApproval, selectedThreadShell], |
| 142 | ); | 143 | ); |
| 143 | 144 | ||
| 144 | const onSubmitUserInput = useCallback(async () => { | 145 | const onSubmitUserInput = useCallback(async () => { |
| 145 | if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) { | 146 | if ( |
| 147 | !selectedThreadShell || | ||
| 148 | !activePendingUserInput || | ||
| 149 | activePendingUserInput.responseCapability !== "live" || | ||
| 150 | !activePendingUserInputAnswers | ||
| 151 | ) { | ||
| 146 | return; | 152 | return; |
| 147 | } | 153 | } |
| 148 | 154 | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { useCallback, useEffect, useMemo } from "react"; | 3 | import { useCallback, useEffect, useMemo } from "react"; |
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| @@ -106,13 +107,13 @@ export function useThreadComposerState() { · 101 unmodified lines | |||
| 106 | 107 | ||
| 107 | const selectedThreadSessionActivity = useMemo(() => { | 108 | const selectedThreadSessionActivity = useMemo(() => { |
| 108 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; | 109 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; |
| 109 | if (!selectedThread?.session) { | 110 | if (!selectedThread?.runtime) { |
| 110 | return null; | 111 | return null; |
| 111 | } | 112 | } |
| 112 | 113 | ||
| 113 | return { | 114 | return { |
| 114 | orchestrationStatus: selectedThread.session.status, | 115 | orchestrationStatus: selectedThread.runtime.status, |
| 115 | activeTurnId: selectedThread.session.activeTurnId ?? undefined, | 116 | activeRunId: selectedThread.runtime.activeRunId ?? undefined, |
| 116 | }; | 117 | }; |
| 117 | }, [selectedThreadDetail, selectedThreadShell]); | 118 | }, [selectedThreadDetail, selectedThreadShell]); |
| 118 | 119 | ||
| @@ -123,15 +124,13 @@ export function useThreadComposerState() { · 4 unmodified lines | |||
| 123 | } | 124 | } |
| 124 | 125 | ||
| 125 | return deriveActiveWorkStartedAt( | 126 | return deriveActiveWorkStartedAt( |
| 126 | selectedThread.latestTurn, | 127 | selectedThread.latestRun, |
| 127 | selectedThreadSessionActivity, | 128 | selectedThreadSessionActivity, |
| 128 | null, | 129 | null, |
| 129 | ); | 130 | ); |
| 130 | }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); | 131 | }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); |
| 131 | 132 | ||
| 132 | const activeThreadBusy = | 133 | const activeThreadBusy = !!selectedThread && threadRuntimeIsActive(selectedThread.runtime); |
| 133 | !!selectedThread && | ||
| 134 | (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); | ||
| 135 | 134 | ||
| 136 | const onSendMessage = useCallback(async () => { | 135 | const onSendMessage = useCallback(async () => { |
| 137 | if (!selectedThreadShell) { | 136 | if (!selectedThreadShell) { |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { | 2 | import { |
| 3 | EnvironmentProject, | 3 | threadRuntimeIsActive, |
| 4 | EnvironmentThreadShell, | 4 | type EnvironmentThreadShell, |
| 5 | } from "@t3tools/client-runtime/state/shell"; | 5 | } from "@t3tools/client-runtime/state/shell"; |
| 6 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | 6 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { | 7 | import { |
| @@ -221,6 +221,7 @@ export function useThreadOutboxDrain(): void { · 213 unmodified lines | |||
| 221 | environmentId: queuedMessage.environmentId, | 221 | environmentId: queuedMessage.environmentId, |
| 222 | input: { | 222 | input: { |
| 223 | commandId: queuedMessage.commandId, | 223 | commandId: queuedMessage.commandId, |
| 224 | creationSource: "mobile", | ||
| 224 | threadId: queuedMessage.threadId, | 225 | threadId: queuedMessage.threadId, |
| 225 | message: { | 226 | message: { |
| 226 | messageId: queuedMessage.messageId, | 227 | messageId: queuedMessage.messageId, |
| @@ -314,7 +315,7 @@ export function useThreadOutboxDrain(): void { · 87 unmodified lines | |||
| 314 | threadExists: thread !== undefined, | 315 | threadExists: thread !== undefined, |
| 315 | shellStatus, | 316 | shellStatus, |
| 316 | environmentConnected: environment?.connectionState === "connected", | 317 | environmentConnected: environment?.connectionState === "connected", |
| 317 | threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", | 318 | threadBusy: threadRuntimeIsActive(thread?.runtime), |
| 318 | }); | 319 | }); |
| 319 | if (deliveryAction === "wait") { | 320 | if (deliveryAction === "wait") { |
| 320 | continue; | 321 | continue; |
| @@ -0,0 +1,130 @@ | |||
| 1 | import { | ||
| 2 | presentThread, | ||
| 3 | presentThreadShell, | ||
| 4 | type EnvironmentThread, | ||
| 5 | type EnvironmentThreadShell, | ||
| 6 | } from "@t3tools/client-runtime/state/shell"; | ||
| 7 | import { | ||
| 8 | EnvironmentId, | ||
| 9 | ProjectId, | ||
| 10 | ProviderInstanceId, | ||
| 11 | ThreadId, | ||
| 12 | type OrchestrationV2ThreadProjection, | ||
| 13 | type OrchestrationV2ThreadShell, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | |||
| 17 | const DEFAULT_TIMESTAMP = "2026-01-01T00:00:00.000Z"; | ||
| 18 | |||
| 19 | export function makeRawThreadShell( | ||
| 20 | input: Partial<OrchestrationV2ThreadShell> = {}, | ||
| 21 | ): OrchestrationV2ThreadShell { | ||
| 22 | const id = input.id ?? ThreadId.make("thread-test"); | ||
| 23 | const providerInstanceId = input.providerInstanceId ?? ProviderInstanceId.make("codex"); | ||
| 24 | const now = DateTime.makeUnsafe(DEFAULT_TIMESTAMP); | ||
| 25 | return { | ||
| 26 | id, | ||
| 27 | projectId: ProjectId.make("project-test"), | ||
| 28 | title: "Thread", | ||
| 29 | providerInstanceId, | ||
| 30 | modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, | ||
| 31 | runtimeMode: "full-access", | ||
| 32 | interactionMode: "default", | ||
| 33 | branch: null, | ||
| 34 | worktreePath: null, | ||
| 35 | activeProviderThreadId: null, | ||
| 36 | lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null }, | ||
| 37 | forkedFrom: null, | ||
| 38 | createdBy: "user", | ||
| 39 | creationSource: "mobile", | ||
| 40 | latestRunId: null, | ||
| 41 | activeRunId: null, | ||
| 42 | status: "idle", | ||
| 43 | pendingRuntimeRequest: null, | ||
| 44 | latestVisibleMessage: null, | ||
| 45 | latestUserMessageAt: null, | ||
| 46 | hasActionableProposedPlan: false, | ||
| 47 | itemCount: 0, | ||
| 48 | visibleItemCount: 0, | ||
| 49 | createdAt: now, | ||
| 50 | updatedAt: now, | ||
| 51 | archivedAt: null, | ||
| 52 | deletedAt: null, | ||
| 53 | ...input, | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function makeThreadShellFixture( | ||
| 58 | overrides: Partial<EnvironmentThreadShell> = {}, | ||
| 59 | ): EnvironmentThreadShell { | ||
| 60 | const environmentId = overrides.environmentId ?? EnvironmentId.make("environment-test"); | ||
| 61 | const raw = makeRawThreadShell({ | ||
| 62 | id: overrides.id, | ||
| 63 | projectId: overrides.projectId, | ||
| 64 | title: overrides.title, | ||
| 65 | providerInstanceId: overrides.providerInstanceId, | ||
| 66 | modelSelection: overrides.modelSelection, | ||
| 67 | runtimeMode: overrides.runtimeMode, | ||
| 68 | interactionMode: overrides.interactionMode, | ||
| 69 | branch: overrides.branch, | ||
| 70 | worktreePath: overrides.worktreePath, | ||
| 71 | }); | ||
| 72 | return { ...presentThreadShell(environmentId, raw), ...overrides }; | ||
| 73 | } | ||
| 74 | |||
| 75 | export function makeThreadFixture(overrides: Partial<EnvironmentThread> = {}): EnvironmentThread { | ||
| 76 | const shell = makeRawThreadShell({ | ||
| 77 | id: overrides.id, | ||
| 78 | projectId: overrides.projectId, | ||
| 79 | title: overrides.title, | ||
| 80 | providerInstanceId: overrides.providerInstanceId, | ||
| 81 | modelSelection: overrides.modelSelection, | ||
| 82 | runtimeMode: overrides.runtimeMode, | ||
| 83 | interactionMode: overrides.interactionMode, | ||
| 84 | branch: overrides.branch, | ||
| 85 | worktreePath: overrides.worktreePath, | ||
| 86 | }); | ||
| 87 | const projection: OrchestrationV2ThreadProjection = { | ||
| 88 | thread: { | ||
| 89 | id: shell.id, | ||
| 90 | projectId: shell.projectId, | ||
| 91 | title: shell.title, | ||
| 92 | providerInstanceId: shell.providerInstanceId, | ||
| 93 | modelSelection: shell.modelSelection, | ||
| 94 | runtimeMode: shell.runtimeMode, | ||
| 95 | interactionMode: shell.interactionMode, | ||
| 96 | branch: shell.branch, | ||
| 97 | worktreePath: shell.worktreePath, | ||
| 98 | activeProviderThreadId: shell.activeProviderThreadId, | ||
| 99 | lineage: shell.lineage, | ||
| 100 | forkedFrom: shell.forkedFrom, | ||
| 101 | createdBy: shell.createdBy, | ||
| 102 | creationSource: shell.creationSource, | ||
| 103 | createdAt: shell.createdAt, | ||
| 104 | updatedAt: shell.updatedAt, | ||
| 105 | archivedAt: shell.archivedAt, | ||
| 106 | deletedAt: shell.deletedAt, | ||
| 107 | }, | ||
| 108 | runs: [], | ||
| 109 | attempts: [], | ||
| 110 | nodes: [], | ||
| 111 | subagents: [], | ||
| 112 | providerSessions: [], | ||
| 113 | providerThreads: [], | ||
| 114 | providerTurns: [], | ||
| 115 | runtimeRequests: [], | ||
| 116 | messages: [], | ||
| 117 | plans: [], | ||
| 118 | turnItems: [], | ||
| 119 | checkpointScopes: [], | ||
| 120 | checkpoints: [], | ||
| 121 | contextHandoffs: [], | ||
| 122 | contextTransfers: [], | ||
| 123 | visibleTurnItems: [], | ||
| 124 | updatedAt: shell.updatedAt, | ||
| 125 | }; | ||
| 126 | return { | ||
| 127 | ...presentThread(overrides.environmentId ?? EnvironmentId.make("environment-test"), projection), | ||
| 128 | ...overrides, | ||
| 129 | }; | ||
| 130 | } | ||
| @@ -0,0 +1,3 @@ | |||
| 1 | # Server | ||
| 2 | |||
| 3 | Node.js WebSocket server for the T3 Code app. | ||
| @@ -7,11 +7,22 @@ import { describe, expect, it } from "vite-plus/test"; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | createAttachmentId, | 9 | createAttachmentId, |
| 10 | createDeterministicAttachmentId, | ||
| 10 | parseThreadSegmentFromAttachmentId, | 11 | parseThreadSegmentFromAttachmentId, |
| 11 | resolveAttachmentPathById, | 12 | resolveAttachmentPathById, |
| 12 | } from "./attachmentStore.ts"; | 13 | } from "./attachmentStore.ts"; |
| 13 | 14 | ||
| 14 | describe("attachmentStore", () => { | 15 | describe("attachmentStore", () => { |
| 16 | it("derives stable attachment ids for idempotent message retries", () => { | ||
| 17 | const first = createDeterministicAttachmentId("thread-1", "message-1:0"); | ||
| 18 | const retry = createDeterministicAttachmentId("thread-1", "message-1:0"); | ||
| 19 | const next = createDeterministicAttachmentId("thread-1", "message-1:1"); | ||
| 20 | |||
| 21 | expect(first).toBe(retry); | ||
| 22 | expect(next).not.toBe(first); | ||
| 23 | expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1"); | ||
| 24 | }); | ||
| 25 | |||
| 15 | it("sanitizes thread ids when creating attachment ids", () => { | 26 | it("sanitizes thread ids when creating attachment ids", () => { |
| 16 | const attachmentId = createAttachmentId("thread.folder/unsafe space"); | 27 | const attachmentId = createAttachmentId("thread.folder/unsafe space"); |
| 17 | expect(attachmentId).toBeTruthy(); | 28 | expect(attachmentId).toBeTruthy(); |
| @@ -42,6 +42,17 @@ export function createAttachmentId(threadId: string): string | null { · 41 unmodified lines | |||
| 42 | return `${threadSegment}-${NodeCrypto.randomUUID()}`; | 42 | return `${threadSegment}-${NodeCrypto.randomUUID()}`; |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | export function createDeterministicAttachmentId( | ||
| 46 | threadId: string, | ||
| 47 | stableKey: string, | ||
| 48 | ): string | null { | ||
| 49 | const threadSegment = toSafeThreadAttachmentSegment(threadId); | ||
| 50 | if (!threadSegment) return null; | ||
| 51 | const hash = NodeCrypto.createHash("sha256").update(stableKey).digest("hex").slice(0, 32); | ||
| 52 | const uuid = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20)}`; | ||
| 53 | return `${threadSegment}-${uuid}`; | ||
| 54 | } | ||
| 55 | |||
| 45 | export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { | 56 | export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { |
| 46 | const normalizedId = normalizeAttachmentRelativePath(attachmentId); | 57 | const normalizedId = normalizeAttachmentRelativePath(attachmentId); |
| 47 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { | 58 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { |
| @@ -11,7 +11,9 @@ import { · 10 unmodified lines | |||
| 11 | type OrchestrationV2AppThread, | 11 | type OrchestrationV2AppThread, |
| 12 | type OrchestrationV2DomainEvent, | 12 | type OrchestrationV2DomainEvent, |
| 13 | ProjectId, | 13 | ProjectId, |
| 14 | ProviderDriverKind, | ||
| 14 | ProviderInstanceId, | 15 | ProviderInstanceId, |
| 16 | ProviderThreadId, | ||
| 15 | RunId, | 17 | RunId, |
| 16 | ThreadId, | 18 | ThreadId, |
| 17 | TurnItemId, | 19 | TurnItemId, |
| @@ -60,6 +62,7 @@ const TestLayer = Layer.mergeAll( · 42 unmodified lines | |||
| 60 | ); | 62 | ); |
| 61 | 63 | ||
| 62 | const providerInstanceId = ProviderInstanceId.make("codex"); | 64 | const providerInstanceId = ProviderInstanceId.make("codex"); |
| 65 | const providerDriver = ProviderDriverKind.make("codex"); | ||
| 63 | const modelSelection = { | 66 | const modelSelection = { |
| 64 | instanceId: providerInstanceId, | 67 | instanceId: providerInstanceId, |
| 65 | model: "gpt-5.4", | 68 | model: "gpt-5.4", |
| @@ -404,7 +407,32 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 338 unmodified lines | |||
| 404 | const now = yield* DateTime.now; | 407 | const now = yield* DateTime.now; |
| 405 | const threadId = ThreadId.make("thread:foundation-many-items"); | 408 | const threadId = ThreadId.make("thread:foundation-many-items"); |
| 406 | const runId = RunId.make("run:foundation-many-items"); | 409 | const runId = RunId.make("run:foundation-many-items"); |
| 410 | const providerThreadId = ProviderThreadId.make("provider-thread:foundation-many-items"); | ||
| 407 | const thread = makeThread(threadId, now); | 411 | const thread = makeThread(threadId, now); |
| 412 | const providerThreadEvent = { | ||
| 413 | id: EventId.make("event:foundation-many-items:provider-thread"), | ||
| 414 | type: "provider-thread.updated" as const, | ||
| 415 | threadId, | ||
| 416 | providerInstanceId, | ||
| 417 | occurredAt: now, | ||
| 418 | payload: { | ||
| 419 | id: providerThreadId, | ||
| 420 | driver: providerDriver, | ||
| 421 | providerInstanceId, | ||
| 422 | providerSessionId: null, | ||
| 423 | appThreadId: threadId, | ||
| 424 | ownerNodeId: null, | ||
| 425 | nativeThreadRef: null, | ||
| 426 | nativeConversationHeadRef: null, | ||
| 427 | status: "active" as const, | ||
| 428 | firstRunOrdinal: 1, | ||
| 429 | lastRunOrdinal: 1, | ||
| 430 | handoffIds: [], | ||
| 431 | forkedFrom: null, | ||
| 432 | createdAt: now, | ||
| 433 | updatedAt: now, | ||
| 434 | }, | ||
| 435 | } satisfies OrchestrationV2DomainEvent; | ||
| 408 | const runEvent = { | 436 | const runEvent = { |
| 409 | id: EventId.make("event:foundation-many-items:run"), | 437 | id: EventId.make("event:foundation-many-items:run"), |
| 410 | type: "run.created" as const, | 438 | type: "run.created" as const, |
| @@ -467,11 +495,13 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 56 unmodified lines | |||
| 467 | yield* eventSink.write({ | 495 | yield* eventSink.write({ |
| 468 | events: [ | 496 | events: [ |
| 469 | threadCreatedEvent({ id: "event:foundation-many-items:thread", thread, now }), | 497 | threadCreatedEvent({ id: "event:foundation-many-items:thread", thread, now }), |
| 498 | providerThreadEvent, | ||
| 470 | runEvent, | 499 | runEvent, |
| 471 | ...itemEvents, | 500 | ...itemEvents, |
| 472 | ], | 501 | ], |
| 473 | }); | 502 | }); |
| 474 | const beforeUpdate = yield* projectionStore.getThreadProjection(threadId); | 503 | const beforeUpdate = yield* projectionStore.getThreadProjection(threadId); |
| 504 | assert.equal(beforeUpdate.thread.activeProviderThreadId, providerThreadId); | ||
| 475 | const ordinals = beforeUpdate.turnItems.map((item) => item.ordinal); | 505 | const ordinals = beforeUpdate.turnItems.map((item) => item.ordinal); |
| 476 | assert.lengthOf(ordinals, 151); | 506 | assert.lengthOf(ordinals, 151); |
| 477 | assert.equal(new Set(ordinals).size, 151); | 507 | assert.equal(new Set(ordinals).size, 151); |
| @@ -1649,6 +1649,54 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1648 unmodified lines | |||
| 1649 | const projection = yield* getProjectionWithPendingEvents(command.threadId, events); | 1649 | const projection = yield* getProjectionWithPendingEvents(command.threadId, events); |
| 1650 | const modelSelection = command.modelSelection ?? projection.thread.modelSelection; | 1650 | const modelSelection = command.modelSelection ?? projection.thread.modelSelection; |
| 1651 | const dispatchMode = command.dispatchMode; | 1651 | const dispatchMode = command.dispatchMode; |
| 1652 | const sourcePlanProjection = | ||
| 1653 | command.sourcePlanRef === undefined | ||
| 1654 | ? null | ||
| 1655 | : yield* getProjectionWithPendingEvents(command.sourcePlanRef.threadId, events); | ||
| 1656 | const sourcePlan = | ||
| 1657 | command.sourcePlanRef === undefined | ||
| 1658 | ? null | ||
| 1659 | : (sourcePlanProjection?.plans.find( | ||
| 1660 | (plan) => plan.id === command.sourcePlanRef?.planId && plan.kind === "proposed_plan", | ||
| 1661 | ) ?? null); | ||
| 1662 | if (command.sourcePlanRef !== undefined && sourcePlan === null) { | ||
| 1663 | return yield* new OrchestratorDispatchError({ | ||
| 1664 | commandId: command.commandId, | ||
| 1665 | commandType: command.type, | ||
| 1666 | cause: `Proposed plan ${command.sourcePlanRef.planId} does not exist on thread ${command.sourcePlanRef.threadId}.`, | ||
| 1667 | }); | ||
| 1668 | } | ||
| 1669 | if ( | ||
| 1670 | sourcePlanProjection !== null && | ||
| 1671 | sourcePlanProjection.thread.projectId !== projection.thread.projectId | ||
| 1672 | ) { | ||
| 1673 | return yield* new OrchestratorDispatchError({ | ||
| 1674 | commandId: command.commandId, | ||
| 1675 | commandType: command.type, | ||
| 1676 | cause: `Proposed plan ${command.sourcePlanRef?.planId} belongs to a different project.`, | ||
| 1677 | }); | ||
| 1678 | } | ||
| 1679 | if (sourcePlan !== null && sourcePlan.status !== "active") { | ||
| 1680 | return yield* new OrchestratorDispatchError({ | ||
| 1681 | commandId: command.commandId, | ||
| 1682 | commandType: command.type, | ||
| 1683 | cause: `Proposed plan ${sourcePlan.id} is not active.`, | ||
| 1684 | }); | ||
| 1685 | } | ||
| 1686 | const completeSourcePlan = (occurredAt: DateTime.Utc) => | ||
| 1687 | sourcePlan === null | ||
| 1688 | ? Effect.void | ||
| 1689 | : emit( | ||
| 1690 | events, | ||
| 1691 | command, | ||
| 1692 | )({ | ||
| 1693 | type: "plan.updated", | ||
| 1694 | threadId: sourcePlan.threadId, | ||
| 1695 | ...(sourcePlan.runId === null ? {} : { runId: sourcePlan.runId }), | ||
| 1696 | nodeId: sourcePlan.nodeId, | ||
| 1697 | occurredAt, | ||
| 1698 | payload: { ...sourcePlan, status: "completed" }, | ||
| 1699 | }); | ||
| 1652 | 1700 | ||
| 1653 | if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { | 1701 | if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { |
| 1654 | yield* dispatchSteerIntoRun({ | 1702 | yield* dispatchSteerIntoRun({ |
| @@ -1783,6 +1831,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 128 unmodified lines | |||
| 1783 | completedAt: null, | 1831 | completedAt: null, |
| 1784 | checkpointId: null, | 1832 | checkpointId: null, |
| 1785 | contextHandoffId: null, | 1833 | contextHandoffId: null, |
| 1834 | ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), | ||
| 1786 | }; | 1835 | }; |
| 1787 | const attempt: OrchestrationV2RunAttempt = { | 1836 | const attempt: OrchestrationV2RunAttempt = { |
| 1788 | id: attemptId, | 1837 | id: attemptId, |
| @@ -1861,6 +1910,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 72 unmodified lines | |||
| 1861 | occurredAt: now, | 1910 | occurredAt: now, |
| 1862 | payload: run, | 1911 | payload: run, |
| 1863 | }); | 1912 | }); |
| 1913 | yield* completeSourcePlan(now); | ||
| 1864 | yield* emitEvent({ | 1914 | yield* emitEvent({ |
| 1865 | type: "run-attempt.created", | 1915 | type: "run-attempt.created", |
| 1866 | threadId: command.threadId, | 1916 | threadId: command.threadId, |
| @@ -2030,6 +2080,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 163 unmodified lines | |||
| 2030 | completedAt: null, | 2080 | completedAt: null, |
| 2031 | checkpointId: null, | 2081 | checkpointId: null, |
| 2032 | contextHandoffId: null, | 2082 | contextHandoffId: null, |
| 2083 | ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), | ||
| 2033 | }; | 2084 | }; |
| 2034 | const attempt: OrchestrationV2RunAttempt = { | 2085 | const attempt: OrchestrationV2RunAttempt = { |
| 2035 | id: attemptId, | 2086 | id: attemptId, |
| @@ -2116,6 +2167,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 80 unmodified lines | |||
| 2116 | occurredAt: now, | 2167 | occurredAt: now, |
| 2117 | payload: run, | 2168 | payload: run, |
| 2118 | }); | 2169 | }); |
| 2170 | yield* completeSourcePlan(now); | ||
| 2119 | yield* emitEvent({ | 2171 | yield* emitEvent({ |
| 2120 | type: "run-attempt.created", | 2172 | type: "run-attempt.created", |
| 2121 | threadId: command.threadId, | 2173 | threadId: command.threadId, |
| @@ -2613,6 +2665,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 491 unmodified lines | |||
| 2613 | checkpointId: null, | 2665 | checkpointId: null, |
| 2614 | contextHandoffId: | 2666 | contextHandoffId: |
| 2615 | portableForkHandoff?.id ?? providerSwitchHandoff?.id ?? mergeBackHandoff?.id ?? null, | 2667 | portableForkHandoff?.id ?? providerSwitchHandoff?.id ?? mergeBackHandoff?.id ?? null, |
| 2668 | ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), | ||
| 2616 | }; | 2669 | }; |
| 2617 | const attempt: OrchestrationV2RunAttempt = { | 2670 | const attempt: OrchestrationV2RunAttempt = { |
| 2618 | id: attemptId, | 2671 | id: attemptId, |
| @@ -2917,6 +2970,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 298 unmodified lines | |||
| 2917 | occurredAt: now, | 2970 | occurredAt: now, |
| 2918 | payload: run, | 2971 | payload: run, |
| 2919 | }); | 2972 | }); |
| 2973 | yield* completeSourcePlan(now); | ||
| 2920 | yield* emitEvent({ | 2974 | yield* emitEvent({ |
| 2921 | type: "run-attempt.created", | 2975 | type: "run-attempt.created", |
| 2922 | threadId: command.threadId, | 2976 | threadId: command.threadId, |
| @@ -223,6 +223,13 @@ export function applyToProjection( · 222 unmodified lines | |||
| 223 | case "provider-thread.updated": | 223 | case "provider-thread.updated": |
| 224 | return { | 224 | return { |
| 225 | ...base, | 225 | ...base, |
| 226 | thread: | ||
| 227 | event.payload.appThreadId === base.thread.id | ||
| 228 | ? { | ||
| 229 | ...base.thread, | ||
| 230 | activeProviderThreadId: event.payload.id, | ||
| 231 | } | ||
| 232 | : base.thread, | ||
| 226 | providerThreads: upsertById(base.providerThreads, event.payload), | 233 | providerThreads: upsertById(base.providerThreads, event.payload), |
| 227 | }; | 234 | }; |
| 228 | case "provider-turn.updated": | 235 | case "provider-turn.updated": |
| @@ -288,6 +295,8 @@ type ShellThreadRow = { · 59 unmodified lines | |||
| 288 | readonly active_run_id: string | null; | 295 | readonly active_run_id: string | null; |
| 289 | readonly pending_request_payload_json: string | null; | 296 | readonly pending_request_payload_json: string | null; |
| 290 | readonly latest_message_payload_json: string | null; | 297 | readonly latest_message_payload_json: string | null; |
| 298 | readonly latest_user_message_at: string | null; | ||
| 299 | readonly has_actionable_proposed_plan: number; | ||
| 291 | readonly item_count: number; | 300 | readonly item_count: number; |
| 292 | }; | 301 | }; |
| 293 | 302 | ||
| @@ -638,6 +647,13 @@ export function threadShellFromProjection( · 344 unmodified lines | |||
| 638 | (left, right) => | 647 | (left, right) => |
| 639 | DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), | 648 | DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), |
| 640 | )[0] ?? null; | 649 | )[0] ?? null; |
| 650 | const latestUserMessage = | ||
| 651 | projection.messages | ||
| 652 | .filter((message) => message.role === "user") | ||
| 653 | .toSorted( | ||
| 654 | (left, right) => | ||
| 655 | DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), | ||
| 656 | )[0] ?? null; | ||
| 641 | return { | 657 | return { |
| 642 | createdBy: projection.thread.createdBy, | 658 | createdBy: projection.thread.createdBy, |
| 643 | creationSource: projection.thread.creationSource, | 659 | creationSource: projection.thread.creationSource, |
| @@ -673,6 +689,10 @@ export function threadShellFromProjection( · 29 unmodified lines | |||
| 673 | text: latestVisibleMessage.text, | 689 | text: latestVisibleMessage.text, |
| 674 | updatedAt: latestVisibleMessage.updatedAt, | 690 | updatedAt: latestVisibleMessage.updatedAt, |
| 675 | }, | 691 | }, |
| 692 | latestUserMessageAt: latestUserMessage?.updatedAt ?? null, | ||
| 693 | hasActionableProposedPlan: projection.plans.some( | ||
| 694 | (plan) => plan.kind === "proposed_plan" && plan.status === "active", | ||
| 695 | ), | ||
| 676 | itemCount: activeLocalTurnItems(projection).length, | 696 | itemCount: activeLocalTurnItems(projection).length, |
| 677 | visibleItemCount: projection.visibleTurnItems.length, | 697 | visibleItemCount: projection.visibleTurnItems.length, |
| 678 | createdAt: projection.thread.createdAt, | 698 | createdAt: projection.thread.createdAt, |
| @@ -693,6 +713,8 @@ type ShellThreadState = { · 14 unmodified lines | |||
| 693 | readonly activeRunId: RunId | null; | 713 | readonly activeRunId: RunId | null; |
| 694 | readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null; | 714 | readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null; |
| 695 | readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null; | 715 | readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null; |
| 716 | readonly latestUserMessageAt: DateTime.Utc | null; | ||
| 717 | readonly hasActionableProposedPlan: boolean; | ||
| 696 | readonly itemCount: number; | 718 | readonly itemCount: number; |
| 697 | readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"]; | 719 | readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"]; |
| 698 | readonly runOrdinalById: ReadonlyMap<RunId, number>; | 720 | readonly runOrdinalById: ReadonlyMap<RunId, number>; |
| @@ -819,6 +841,8 @@ function shellFromState(input: { · 120 unmodified lines | |||
| 819 | text: input.state.latestVisibleMessage.text, | 841 | text: input.state.latestVisibleMessage.text, |
| 820 | updatedAt: input.state.latestVisibleMessage.updatedAt, | 842 | updatedAt: input.state.latestVisibleMessage.updatedAt, |
| 821 | }, | 843 | }, |
| 844 | latestUserMessageAt: input.state.latestUserMessageAt, | ||
| 845 | hasActionableProposedPlan: input.state.hasActionableProposedPlan, | ||
| 822 | itemCount: input.state.itemCount, | 846 | itemCount: input.state.itemCount, |
| 823 | visibleItemCount: input.visibleItemCount, | 847 | visibleItemCount: input.visibleItemCount, |
| 824 | createdAt: input.state.thread.createdAt, | 848 | createdAt: input.state.thread.createdAt, |
| @@ -1949,6 +1973,21 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 1124 unmodified lines | |||
| 1949 | ORDER BY message.updated_at DESC, message.message_id DESC | 1973 | ORDER BY message.updated_at DESC, message.message_id DESC |
| 1950 | LIMIT 1 | 1974 | LIMIT 1 |
| 1951 | ) AS latest_message_payload_json, | 1975 | ) AS latest_message_payload_json, |
| 1976 | ( | ||
| 1977 | SELECT message.updated_at | ||
| 1978 | FROM orchestration_v2_projection_messages message | ||
| 1979 | WHERE message.thread_id = t.thread_id | ||
| 1980 | AND message.role = 'user' | ||
| 1981 | ORDER BY message.updated_at DESC, message.message_id DESC | ||
| 1982 | LIMIT 1 | ||
| 1983 | ) AS latest_user_message_at, | ||
| 1984 | EXISTS ( | ||
| 1985 | SELECT 1 | ||
| 1986 | FROM orchestration_v2_projection_plans plan | ||
| 1987 | WHERE plan.thread_id = t.thread_id | ||
| 1988 | AND plan.kind = 'proposed_plan' | ||
| 1989 | AND plan.status = 'active' | ||
| 1990 | ) AS has_actionable_proposed_plan, | ||
| 1952 | ( | 1991 | ( |
| 1953 | SELECT COUNT(*) | 1992 | SELECT COUNT(*) |
| 1954 | FROM orchestration_v2_projection_turn_items i | 1993 | FROM orchestration_v2_projection_turn_items i |
| @@ -2015,6 +2054,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 60 unmodified lines | |||
| 2015 | activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), | 2054 | activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), |
| 2016 | pendingRuntimeRequest, | 2055 | pendingRuntimeRequest, |
| 2017 | latestVisibleMessage, | 2056 | latestVisibleMessage, |
| 2057 | latestUserMessageAt: | ||
| 2058 | row.latest_user_message_at === null | ||
| 2059 | ? null | ||
| 2060 | : DateTime.makeUnsafe(row.latest_user_message_at), | ||
| 2061 | hasActionableProposedPlan: row.has_actionable_proposed_plan === 1, | ||
| 2018 | itemCount: row.item_count, | 2062 | itemCount: row.item_count, |
| 2019 | updatedAt: thread.updatedAt, | 2063 | updatedAt: thread.updatedAt, |
| 2020 | runOrdinalById: | 2064 | runOrdinalById: |
| @@ -0,0 +1,99 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | type ModelSelection, | ||
| 4 | type OrchestrationV2AppThread, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | |||
| 14 | import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; | ||
| 15 | import { layerFromProjectRepository, RuntimePolicyV2 } from "./RuntimePolicy.ts"; | ||
| 16 | |||
| 17 | const projectId = ProjectId.make("project:runtime-policy"); | ||
| 18 | const providerInstanceId = ProviderInstanceId.make("codex"); | ||
| 19 | const modelSelection = { | ||
| 20 | instanceId: providerInstanceId, | ||
| 21 | model: "gpt-5.5", | ||
| 22 | } satisfies ModelSelection; | ||
| 23 | |||
| 24 | function makeThread(input: { | ||
| 25 | readonly now: DateTime.Utc; | ||
| 26 | readonly worktreePath: string | null; | ||
| 27 | }): OrchestrationV2AppThread { | ||
| 28 | const threadId = ThreadId.make("thread:runtime-policy"); | ||
| 29 | return { | ||
| 30 | createdBy: "user", | ||
| 31 | creationSource: "web", | ||
| 32 | id: threadId, | ||
| 33 | projectId, | ||
| 34 | title: "Runtime policy", | ||
| 35 | providerInstanceId, | ||
| 36 | modelSelection, | ||
| 37 | runtimeMode: "full-access", | ||
| 38 | interactionMode: "default", | ||
| 39 | branch: null, | ||
| 40 | worktreePath: input.worktreePath, | ||
| 41 | activeProviderThreadId: null, | ||
| 42 | lineage: { | ||
| 43 | parentThreadId: null, | ||
| 44 | relationshipToParent: null, | ||
| 45 | rootThreadId: threadId, | ||
| 46 | }, | ||
| 47 | forkedFrom: null, | ||
| 48 | createdAt: input.now, | ||
| 49 | updatedAt: input.now, | ||
| 50 | archivedAt: null, | ||
| 51 | deletedAt: null, | ||
| 52 | }; | ||
| 53 | } | ||
| 54 | |||
| 55 | const TestLayer = layerFromProjectRepository.pipe( | ||
| 56 | Layer.provide( | ||
| 57 | Layer.mock(ProjectionProjects.ProjectionProjectRepository)({ | ||
| 58 | getById: () => | ||
| 59 | Effect.succeed( | ||
| 60 | Option.some({ | ||
| 61 | projectId, | ||
| 62 | title: "Project", | ||
| 63 | workspaceRoot: "/project-root", | ||
| 64 | defaultModelSelection: null, | ||
| 65 | scripts: [], | ||
| 66 | createdAt: "2026-06-21T00:00:00.000Z", | ||
| 67 | updatedAt: "2026-06-21T00:00:00.000Z", | ||
| 68 | deletedAt: null, | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | }), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 75 | it.layer(TestLayer)("RuntimePolicyV2", (it) => { | ||
| 76 | it.effect("uses the project root for local-checkout threads", () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | const policy = yield* RuntimePolicyV2; | ||
| 79 | const now = yield* DateTime.now; | ||
| 80 | const resolved = yield* policy.resolve({ | ||
| 81 | thread: makeThread({ now, worktreePath: null }), | ||
| 82 | modelSelection, | ||
| 83 | }); | ||
| 84 | assert.equal(resolved.cwd, "/project-root"); | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | |||
| 88 | it.effect("prefers a provisioned worktree over the project root", () => | ||
| 89 | Effect.gen(function* () { | ||
| 90 | const policy = yield* RuntimePolicyV2; | ||
| 91 | const now = yield* DateTime.now; | ||
| 92 | const resolved = yield* policy.resolve({ | ||
| 93 | thread: makeThread({ now, worktreePath: "/project-worktree" }), | ||
| 94 | modelSelection, | ||
| 95 | }); | ||
| 96 | assert.equal(resolved.cwd, "/project-worktree"); | ||
| 97 | }), | ||
| 98 | ); | ||
| 99 | }); | ||
| @@ -1,9 +1,16 @@ | |||
| 1 | import { ModelSelection, OrchestrationV2AppThread, ProviderDriverKind } from "@t3tools/contracts"; | 1 | import { |
| 2 | ModelSelection, | ||
| 3 | OrchestrationV2AppThread, | ||
| 4 | ProjectId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 6 | 12 | ||
| 13 | import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; | ||
| 7 | import { | 14 | import { |
| 8 | ProviderAdapterV2RuntimePolicy, | 15 | ProviderAdapterV2RuntimePolicy, |
| 9 | type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, | 16 | type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, |
| @@ -15,12 +22,13 @@ import { · 5 unmodified lines | |||
| 15 | export class RuntimePolicyResolveError extends Schema.TaggedErrorClass<RuntimePolicyResolveError>()( | 22 | export class RuntimePolicyResolveError extends Schema.TaggedErrorClass<RuntimePolicyResolveError>()( |
| 16 | "RuntimePolicyResolveError", | 23 | "RuntimePolicyResolveError", |
| 17 | { | 24 | { |
| 18 | driver: ProviderDriverKind, | 25 | projectId: ProjectId, |
| 26 | providerInstanceId: ProviderInstanceId, | ||
| 19 | cause: Schema.optional(Schema.Defect()), | 27 | cause: Schema.optional(Schema.Defect()), |
| 20 | }, | 28 | }, |
| 21 | ) { | 29 | ) { |
| 22 | override get message(): string { | 30 | override get message(): string { |
| 23 | return `Failed to resolve runtime policy for ${this.driver}.`; | 31 | return `Failed to resolve runtime policy for provider instance ${this.providerInstanceId} in project ${this.projectId}.`; |
| 24 | } | 32 | } |
| 25 | } | 33 | } |
| 26 | 34 | ||
| @@ -61,6 +69,51 @@ export const layer: Layer.Layer<RuntimePolicyV2> = Layer.succeed(RuntimePolicyV2 · 34 unmodified lines | |||
| 61 | }), | 69 | }), |
| 62 | }); | 70 | }); |
| 63 | 71 | ||
| 72 | export const layerFromProjectRepository: Layer.Layer< | ||
| 73 | RuntimePolicyV2, | ||
| 74 | never, | ||
| 75 | ProjectionProjects.ProjectionProjectRepository | ||
| 76 | > = Layer.effect( | ||
| 77 | RuntimePolicyV2, | ||
| 78 | Effect.gen(function* () { | ||
| 79 | const projects = yield* ProjectionProjects.ProjectionProjectRepository; | ||
| 80 | return RuntimePolicyV2.of({ | ||
| 81 | resolve: Effect.fn("RuntimePolicyV2.resolve")(function* (input) { | ||
| 82 | const cwd = | ||
| 83 | input.thread.worktreePath ?? | ||
| 84 | (yield* projects.getById({ projectId: input.thread.projectId }).pipe( | ||
| 85 | Effect.mapError( | ||
| 86 | (cause) => | ||
| 87 | new RuntimePolicyResolveError({ | ||
| 88 | projectId: input.thread.projectId, | ||
| 89 | providerInstanceId: input.modelSelection.instanceId, | ||
| 90 | cause, | ||
| 91 | }), | ||
| 92 | ), | ||
| 93 | Effect.flatMap( | ||
| 94 | Option.match({ | ||
| 95 | onNone: () => | ||
| 96 | Effect.fail( | ||
| 97 | new RuntimePolicyResolveError({ | ||
| 98 | projectId: input.thread.projectId, | ||
| 99 | providerInstanceId: input.modelSelection.instanceId, | ||
| 100 | cause: "Project not found.", | ||
| 101 | }), | ||
| 102 | ), | ||
| 103 | onSome: (project) => Effect.succeed(project.workspaceRoot), | ||
| 104 | }), | ||
| 105 | ), | ||
| 106 | )); | ||
| 107 | return ProviderAdapterV2RuntimePolicy.make({ | ||
| 108 | runtimeMode: input.thread.runtimeMode, | ||
| 109 | interactionMode: input.thread.interactionMode, | ||
| 110 | cwd, | ||
| 111 | }); | ||
| 112 | }), | ||
| 113 | }); | ||
| 114 | }), | ||
| 115 | ); | ||
| 116 | |||
| 64 | export function layerWithOverride( | 117 | export function layerWithOverride( |
| 65 | override: RuntimePolicyV2Override, | 118 | override: RuntimePolicyV2Override, |
| 66 | ): Layer.Layer<RuntimePolicyV2, never, RuntimePolicyV2> { | 119 | ): Layer.Layer<RuntimePolicyV2, never, RuntimePolicyV2> { |
| @@ -80,6 +80,20 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 79 unmodified lines | |||
| 80 | ( | 80 | ( |
| 81 | command: Parameters<ThreadManagement.ThreadManagementService["Service"]["dispatch"]>[0], | 81 | command: Parameters<ThreadManagement.ThreadManagementService["Service"]["dispatch"]>[0], |
| 82 | ): Effect.Effect<OrchestratorV2DispatchResult, OrchestratorDispatchError> => { | 82 | ): Effect.Effect<OrchestratorV2DispatchResult, OrchestratorDispatchError> => { |
| 83 | if (command.type === "thread.metadata.update") { | ||
| 84 | const existing = projections.get(command.threadId); | ||
| 85 | if (existing === undefined) return Effect.die("missing existing projection"); | ||
| 86 | projections.set(command.threadId, { | ||
| 87 | ...existing, | ||
| 88 | thread: { | ||
| 89 | ...existing.thread, | ||
| 90 | ...(command.title === undefined ? {} : { title: command.title }), | ||
| 91 | ...(command.branch === undefined ? {} : { branch: command.branch }), | ||
| 92 | ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), | ||
| 93 | }, | ||
| 94 | }); | ||
| 95 | return Effect.succeed({ sequence: 1, storedEvents: [] }); | ||
| 96 | } | ||
| 83 | if (command.type !== "thread.create") return Effect.die("unexpected command"); | 97 | if (command.type !== "thread.create") return Effect.die("unexpected command"); |
| 84 | if (options?.failCreate) { | 98 | if (options?.failCreate) { |
| 85 | return Effect.fail( | 99 | return Effect.fail( |
| @@ -90,16 +104,37 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 4 unmodified lines | |||
| 90 | }), | 104 | }), |
| 91 | ); | 105 | ); |
| 92 | } | 106 | } |
| 93 | projections.set(command.threadId, projectionFor(command.threadId)); | 107 | const projection = projectionFor(command.threadId); |
| 108 | projections.set(command.threadId, { | ||
| 109 | ...projection, | ||
| 110 | thread: { | ||
| 111 | ...projection.thread, | ||
| 112 | title: command.title, | ||
| 113 | branch: command.branch, | ||
| 114 | worktreePath: command.worktreePath, | ||
| 115 | }, | ||
| 116 | }); | ||
| 94 | return Effect.succeed({ sequence: 1, storedEvents: [] }); | 117 | return Effect.succeed({ sequence: 1, storedEvents: [] }); |
| 95 | }, | 118 | }, |
| 96 | ); | 119 | ); |
| 97 | const createWorktree = vi.fn(() => | 120 | const createWorktree = vi.fn( |
| 98 | Effect.succeed({ | 121 | (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["createWorktree"]>[0]) => |
| 99 | worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" }, | 122 | Effect.succeed({ |
| 100 | } as never), | 123 | worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" }, |
| 124 | } as never), | ||
| 101 | ); | 125 | ); |
| 102 | const removeWorktree = vi.fn(() => Effect.void); | 126 | const removeWorktree = vi.fn(() => Effect.void); |
| 127 | const fetchRemote = vi.fn( | ||
| 128 | (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]>[0]) => | ||
| 129 | Effect.void, | ||
| 130 | ); | ||
| 131 | const resolveRemoteTrackingCommit = vi.fn( | ||
| 132 | ( | ||
| 133 | _input: Parameters< | ||
| 134 | GitWorkflow.GitWorkflowService["Service"]["resolveRemoteTrackingCommit"] | ||
| 135 | >[0], | ||
| 136 | ) => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), | ||
| 137 | ); | ||
| 103 | const runForThread = vi.fn(() => | 138 | const runForThread = vi.fn(() => |
| 104 | options?.failSetup | 139 | options?.failSetup |
| 105 | ? Effect.fail( | 140 | ? Effect.fail( |
| @@ -126,7 +161,12 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 20 unmodified lines | |||
| 126 | getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), | 161 | getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), |
| 127 | snapshot: Effect.die("unused"), | 162 | snapshot: Effect.die("unused"), |
| 128 | }), | 163 | }), |
| 129 | Layer.mock(GitWorkflow.GitWorkflowService)({ createWorktree, removeWorktree }), | 164 | Layer.mock(GitWorkflow.GitWorkflowService)({ |
| 165 | createWorktree, | ||
| 166 | fetchRemote, | ||
| 167 | removeWorktree, | ||
| 168 | resolveRemoteTrackingCommit, | ||
| 169 | }), | ||
| 130 | Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { | 170 | Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { |
| 131 | runForThread, | 171 | runForThread, |
| 132 | }), | 172 | }), |
| @@ -150,8 +190,11 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 17 unmodified lines | |||
| 150 | Layer.provideMerge(SqlitePersistenceMemory), | 190 | Layer.provideMerge(SqlitePersistenceMemory), |
| 151 | ), | 191 | ), |
| 152 | dispatch, | 192 | dispatch, |
| 193 | fetchRemote, | ||
| 153 | createWorktree, | 194 | createWorktree, |
| 195 | projections, | ||
| 154 | removeWorktree, | 196 | removeWorktree, |
| 197 | resolveRemoteTrackingCommit, | ||
| 155 | runForThread, | 198 | runForThread, |
| 156 | sendToThread, | 199 | sendToThread, |
| 157 | }; | 200 | }; |
| @@ -213,6 +256,104 @@ it.effect( · 55 unmodified lines | |||
| 213 | }, | 256 | }, |
| 214 | ); | 257 | ); |
| 215 | 258 | ||
| 259 | it.effect("preserves an existing worktree when launching a new thread", () => { | ||
| 260 | const test = makeLayer(); | ||
| 261 | return Effect.gen(function* () { | ||
| 262 | const service = yield* ThreadLaunch.ThreadLaunchService; | ||
| 263 | const result = yield* service.launch({ | ||
| 264 | commandId: CommandId.make("command_launch_existing_worktree"), | ||
| 265 | threadId: ThreadId.make("thread_existing_worktree"), | ||
| 266 | projectId, | ||
| 267 | title: "Thread", | ||
| 268 | modelSelection, | ||
| 269 | runtimeMode: "full-access", | ||
| 270 | interactionMode: "default", | ||
| 271 | workspaceStrategy: { | ||
| 272 | type: "existing_worktree", | ||
| 273 | worktreePath: "/repo-worktrees/existing", | ||
| 274 | branch: "existing", | ||
| 275 | }, | ||
| 276 | createdBy: "user", | ||
| 277 | creationSource: "web", | ||
| 278 | }); | ||
| 279 | assert.equal(result.projection.thread.worktreePath, "/repo-worktrees/existing"); | ||
| 280 | assert.equal(result.projection.thread.branch, "existing"); | ||
| 281 | assert.equal(test.createWorktree.mock.calls.length, 0); | ||
| 282 | assert.equal(test.removeWorktree.mock.calls.length, 0); | ||
| 283 | }).pipe(Effect.provide(test.layer)); | ||
| 284 | }); | ||
| 285 | |||
| 286 | it.effect("fetches and resolves origin before provisioning an origin-based worktree", () => { | ||
| 287 | const test = makeLayer(); | ||
| 288 | return Effect.gen(function* () { | ||
| 289 | const service = yield* ThreadLaunch.ThreadLaunchService; | ||
| 290 | yield* service.launch({ | ||
| 291 | commandId: CommandId.make("command_launch_origin_worktree"), | ||
| 292 | projectId, | ||
| 293 | title: "Thread", | ||
| 294 | modelSelection, | ||
| 295 | runtimeMode: "full-access", | ||
| 296 | interactionMode: "default", | ||
| 297 | workspaceStrategy: { | ||
| 298 | type: "worktree", | ||
| 299 | baseRef: "main", | ||
| 300 | branch: "feature", | ||
| 301 | startFromOrigin: true, | ||
| 302 | }, | ||
| 303 | createdBy: "user", | ||
| 304 | creationSource: "web", | ||
| 305 | }); | ||
| 306 | assert.deepEqual(test.fetchRemote.mock.calls[0]?.[0], { | ||
| 307 | cwd: "/repo", | ||
| 308 | remoteName: "origin", | ||
| 309 | }); | ||
| 310 | assert.deepEqual(test.resolveRemoteTrackingCommit.mock.calls[0]?.[0], { | ||
| 311 | cwd: "/repo", | ||
| 312 | refName: "main", | ||
| 313 | fallbackRemoteName: "origin", | ||
| 314 | }); | ||
| 315 | assert.deepEqual(test.createWorktree.mock.calls[0]?.[0], { | ||
| 316 | cwd: "/repo", | ||
| 317 | refName: "remote-main-sha", | ||
| 318 | newRefName: "feature", | ||
| 319 | baseRefName: "main", | ||
| 320 | path: null, | ||
| 321 | }); | ||
| 322 | }).pipe(Effect.provide(test.layer)); | ||
| 323 | }); | ||
| 324 | |||
| 325 | it.effect("provisions a worktree for an existing empty thread before sending", () => { | ||
| 326 | const test = makeLayer(); | ||
| 327 | const threadId = ThreadId.make("thread_existing_empty"); | ||
| 328 | test.projections.set(threadId, projectionFor(threadId)); | ||
| 329 | return Effect.gen(function* () { | ||
| 330 | const service = yield* ThreadLaunch.ThreadLaunchService; | ||
| 331 | yield* service.launch({ | ||
| 332 | commandId: CommandId.make("command_launch_existing_empty"), | ||
| 333 | threadId, | ||
| 334 | reuseExistingThread: true, | ||
| 335 | projectId, | ||
| 336 | title: "Thread", | ||
| 337 | modelSelection, | ||
| 338 | runtimeMode: "full-access", | ||
| 339 | interactionMode: "default", | ||
| 340 | workspaceStrategy: { type: "worktree", baseRef: "main", branch: "feature" }, | ||
| 341 | initialMessage: { text: "Implement it", attachments: [] }, | ||
| 342 | createdBy: "user", | ||
| 343 | creationSource: "web", | ||
| 344 | }); | ||
| 345 | const updateCommand = test.dispatch.mock.calls.find( | ||
| 346 | ([command]) => command.type === "thread.metadata.update", | ||
| 347 | )?.[0]; | ||
| 348 | assert.equal(updateCommand?.type, "thread.metadata.update"); | ||
| 349 | if (updateCommand?.type === "thread.metadata.update") { | ||
| 350 | assert.equal(updateCommand.worktreePath, "/repo-worktrees/feature"); | ||
| 351 | assert.equal(updateCommand.branch, "feature"); | ||
| 352 | } | ||
| 353 | assert.equal(test.sendToThread.mock.calls.length, 1); | ||
| 354 | }).pipe(Effect.provide(test.layer)); | ||
| 355 | }); | ||
| 356 | |||
| 216 | it.effect("removes a new worktree and does not create a thread when setup fails", () => { | 357 | it.effect("removes a new worktree and does not create a thread when setup fails", () => { |
| 217 | const test = makeLayer({ failSetup: true }); | 358 | const test = makeLayer({ failSetup: true }); |
| 218 | return Effect.gen(function* () { | 359 | return Effect.gen(function* () { |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | type ChatAttachment, | 3 | type ChatAttachment, |
| 4 | type MessageId, | ||
| 4 | type ModelSelection, | 5 | type ModelSelection, |
| 5 | type OrchestrationV2Actor, | 6 | type OrchestrationV2Actor, |
| 6 | type OrchestrationV2CreationSource, | 7 | type OrchestrationV2CreationSource, |
| @@ -27,20 +28,29 @@ import * as IdAllocator from "./IdAllocator.ts"; · 20 unmodified lines | |||
| 27 | import * as ThreadManagement from "./ThreadManagementService.ts"; | 28 | import * as ThreadManagement from "./ThreadManagementService.ts"; |
| 28 | 29 | ||
| 29 | export type ThreadLaunchWorkspaceStrategy = | 30 | export type ThreadLaunchWorkspaceStrategy = |
| 30 | | { readonly type: "root" } | 31 | | { readonly type: "root"; readonly branch?: string | undefined } |
| 32 | | { | ||
| 33 | readonly type: "existing_worktree"; | ||
| 34 | readonly worktreePath: string; | ||
| 35 | readonly branch?: string | undefined; | ||
| 36 | } | ||
| 31 | | { | 37 | | { |
| 32 | readonly type: "worktree"; | 38 | readonly type: "worktree"; |
| 33 | readonly baseRef: string; | 39 | readonly baseRef: string; |
| 34 | readonly branch?: string; | 40 | readonly branch?: string | undefined; |
| 41 | readonly startFromOrigin?: boolean | undefined; | ||
| 35 | }; | 42 | }; |
| 36 | 43 | ||
| 37 | export interface ThreadLaunchInitialMessage { | 44 | export interface ThreadLaunchInitialMessage { |
| 45 | readonly messageId?: MessageId; | ||
| 38 | readonly text: string; | 46 | readonly text: string; |
| 39 | readonly attachments: ReadonlyArray<ChatAttachment>; | 47 | readonly attachments: ReadonlyArray<ChatAttachment>; |
| 40 | } | 48 | } |
| 41 | 49 | ||
| 42 | export interface ThreadLaunchInput { | 50 | export interface ThreadLaunchInput { |
| 43 | readonly commandId: CommandId; | 51 | readonly commandId: CommandId; |
| 52 | readonly threadId?: ThreadId; | ||
| 53 | readonly reuseExistingThread?: boolean; | ||
| 44 | readonly projectId: ProjectId; | 54 | readonly projectId: ProjectId; |
| 45 | readonly title: string; | 55 | readonly title: string; |
| 46 | readonly modelSelection: ModelSelection; | 56 | readonly modelSelection: ModelSelection; |
| @@ -69,6 +79,7 @@ export class ThreadLaunchError extends Schema.TaggedErrorClass<ThreadLaunchError · 22 unmodified lines | |||
| 69 | "provision-worktree", | 79 | "provision-worktree", |
| 70 | "run-setup-script", | 80 | "run-setup-script", |
| 71 | "create-thread", | 81 | "create-thread", |
| 82 | "update-thread", | ||
| 72 | "dispatch-message", | 83 | "dispatch-message", |
| 73 | "compensate-worktree", | 84 | "compensate-worktree", |
| 74 | ]), | 85 | ]), |
| @@ -185,11 +196,18 @@ export const make = Effect.gen(function* () { · 110 unmodified lines | |||
| 185 | ), | 196 | ), |
| 186 | ); | 197 | ); |
| 187 | const stored = yield* loadWorkflow(input); | 198 | const stored = yield* loadWorkflow(input); |
| 199 | if (input.reuseExistingThread === true && input.threadId === undefined) { | ||
| 200 | return yield* mapError( | ||
| 201 | input, | ||
| 202 | "update-thread", | ||
| 203 | )("Reusing an existing thread requires a thread id."); | ||
| 204 | } | ||
| 188 | const threadId = | 205 | const threadId = |
| 189 | stored === null | 206 | stored === null |
| 190 | ? yield* ids.allocate | 207 | ? (input.threadId ?? |
| 208 | (yield* ids.allocate | ||
| 191 | .thread({ projectId: input.projectId }) | 209 | .thread({ projectId: input.projectId }) |
| 192 | .pipe(Effect.mapError(mapError(input, "persist-workflow"))) | 210 | .pipe(Effect.mapError(mapError(input, "persist-workflow"))))) |
| 193 | : ThreadId.make(stored.thread_id); | 211 | : ThreadId.make(stored.thread_id); |
| 194 | if (stored?.status === "completed") { | 212 | if (stored?.status === "completed") { |
| 195 | return { | 213 | return { |
| @@ -201,7 +219,38 @@ export const make = Effect.gen(function* () { · 5 unmodified lines | |||
| 201 | }; | 219 | }; |
| 202 | } | 220 | } |
| 203 | 221 | ||
| 204 | let branch = stored?.branch ?? null; | 222 | const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")( |
| 223 | function* () { | ||
| 224 | const projection = yield* threads | ||
| 225 | .getThreadProjection(threadId) | ||
| 226 | .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); | ||
| 227 | if ( | ||
| 228 | projection.thread.projectId !== input.projectId || | ||
| 229 | projection.thread.archivedAt !== null || | ||
| 230 | projection.thread.deletedAt !== null || | ||
| 231 | projection.messages.length > 0 || | ||
| 232 | projection.runs.length > 0 | ||
| 233 | ) { | ||
| 234 | return yield* mapError( | ||
| 235 | input, | ||
| 236 | "update-thread", | ||
| 237 | threadId, | ||
| 238 | )( | ||
| 239 | "Only an empty active thread in the target project can change workspace during launch.", | ||
| 240 | ); | ||
| 241 | } | ||
| 242 | return projection; | ||
| 243 | }, | ||
| 244 | ); | ||
| 245 | if (input.reuseExistingThread === true && stored?.thread_committed !== 1) { | ||
| 246 | yield* validateReusableThread(); | ||
| 247 | } | ||
| 248 | |||
| 249 | let branch = | ||
| 250 | stored?.branch ?? | ||
| 251 | (input.workspaceStrategy.type === "worktree" | ||
| 252 | ? null | ||
| 253 | : (input.workspaceStrategy.branch ?? null)); | ||
| 205 | let title = stored?.title ?? input.title; | 254 | let title = stored?.title ?? input.title; |
| 206 | if (input.initialMessage !== undefined) { | 255 | if (input.initialMessage !== undefined) { |
| 207 | if (title === "New thread") { | 256 | if (title === "New thread") { |
| @@ -235,15 +284,36 @@ export const make = Effect.gen(function* () { · 27 unmodified lines | |||
| 235 | branch = branch ?? input.workspaceStrategy.branch ?? `thread-${threadId}`; | 284 | branch = branch ?? input.workspaceStrategy.branch ?? `thread-${threadId}`; |
| 236 | } | 285 | } |
| 237 | 286 | ||
| 238 | let worktreePath = stored?.worktree_path ?? null; | 287 | let worktreePath = |
| 288 | stored?.worktree_path ?? | ||
| 289 | (input.workspaceStrategy.type === "existing_worktree" | ||
| 290 | ? input.workspaceStrategy.worktreePath | ||
| 291 | : null); | ||
| 292 | const ownsProvisionedWorktree = input.workspaceStrategy.type === "worktree"; | ||
| 239 | const threadCommitted = stored?.thread_committed === 1; | 293 | const threadCommitted = stored?.thread_committed === 1; |
| 240 | let setupCommitted = stored?.setup_committed === 1; | 294 | let setupCommitted = stored?.setup_committed === 1; |
| 241 | let messageCommitted = stored?.message_committed === 1; | 295 | let messageCommitted = stored?.message_committed === 1; |
| 242 | if (input.workspaceStrategy.type === "worktree" && worktreePath === null) { | 296 | if (input.workspaceStrategy.type === "worktree" && worktreePath === null) { |
| 297 | let startRef = input.workspaceStrategy.baseRef; | ||
| 298 | if (input.workspaceStrategy.startFromOrigin === true) { | ||
| 299 | yield* git | ||
| 300 | .fetchRemote({ cwd: project.workspaceRoot, remoteName: "origin" }) | ||
| 301 | .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); | ||
| 302 | startRef = yield* git | ||
| 303 | .resolveRemoteTrackingCommit({ | ||
| 304 | cwd: project.workspaceRoot, | ||
| 305 | refName: input.workspaceStrategy.baseRef, | ||
| 306 | fallbackRemoteName: "origin", | ||
| 307 | }) | ||
| 308 | .pipe( | ||
| 309 | Effect.map((resolved) => resolved.commitSha), | ||
| 310 | Effect.mapError(mapError(input, "provision-worktree", threadId)), | ||
| 311 | ); | ||
| 312 | } | ||
| 243 | const worktree = yield* git | 313 | const worktree = yield* git |
| 244 | .createWorktree({ | 314 | .createWorktree({ |
| 245 | cwd: project.workspaceRoot, | 315 | cwd: project.workspaceRoot, |
| 246 | refName: input.workspaceStrategy.baseRef, | 316 | refName: startRef, |
| 247 | newRefName: branch!, | 317 | newRefName: branch!, |
| 248 | baseRefName: input.workspaceStrategy.baseRef, | 318 | baseRefName: input.workspaceStrategy.baseRef, |
| 249 | path: null, | 319 | path: null, |
| @@ -278,7 +348,7 @@ export const make = Effect.gen(function* () { · 28 unmodified lines | |||
| 278 | }), | 348 | }), |
| 279 | ); | 349 | ); |
| 280 | if (setupExit._tag === "Failure") { | 350 | if (setupExit._tag === "Failure") { |
| 281 | if (worktreePath !== null && !threadCommitted) { | 351 | if (ownsProvisionedWorktree && worktreePath !== null && !threadCommitted) { |
| 282 | yield* git | 352 | yield* git |
| 283 | .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath }) | 353 | .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath }) |
| 284 | .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId))); | 354 | .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId))); |
| @@ -313,24 +383,36 @@ export const make = Effect.gen(function* () { · 28 unmodified lines | |||
| 313 | } | 383 | } |
| 314 | 384 | ||
| 315 | if (!threadCommitted) { | 385 | if (!threadCommitted) { |
| 316 | const createExit = yield* Effect.exit( | 386 | const commitThread = |
| 317 | threads.dispatch({ | 387 | input.reuseExistingThread === true |
| 318 | type: "thread.create", | 388 | ? Effect.gen(function* () { |
| 319 | commandId: input.commandId, | 389 | yield* validateReusableThread(); |
| 320 | threadId, | 390 | return yield* threads.dispatch({ |
| 321 | projectId: input.projectId, | 391 | type: "thread.metadata.update", |
| 322 | title, | 392 | commandId: input.commandId, |
| 323 | modelSelection: input.modelSelection, | 393 | threadId, |
| 324 | runtimeMode: input.runtimeMode, | 394 | title, |
| 325 | interactionMode: input.interactionMode, | 395 | branch, |
| 326 | branch, | 396 | worktreePath, |
| 327 | worktreePath, | 397 | }); |
| 328 | createdBy: input.createdBy, | 398 | }) |
| 329 | creationSource: input.creationSource, | 399 | : threads.dispatch({ |
| 330 | }), | 400 | type: "thread.create", |
| 331 | ); | 401 | commandId: input.commandId, |
| 402 | threadId, | ||
| 403 | projectId: input.projectId, | ||
| 404 | title, | ||
| 405 | modelSelection: input.modelSelection, | ||
| 406 | runtimeMode: input.runtimeMode, | ||
| 407 | interactionMode: input.interactionMode, | ||
| 408 | branch, | ||
| 409 | worktreePath, | ||
| 410 | createdBy: input.createdBy, | ||
| 411 | creationSource: input.creationSource, | ||
| 412 | }); | ||
| 413 | const createExit = yield* Effect.exit(commitThread); | ||
| 332 | if (createExit._tag === "Failure") { | 414 | if (createExit._tag === "Failure") { |
| 333 | if (worktreePath !== null) { | 415 | if (ownsProvisionedWorktree && worktreePath !== null) { |
| 334 | yield* git | 416 | yield* git |
| 335 | .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath }) | 417 | .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath }) |
| 336 | .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId))); | 418 | .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId))); |
| @@ -339,17 +421,27 @@ export const make = Effect.gen(function* () { · 2 unmodified lines | |||
| 339 | } | 421 | } |
| 340 | yield* saveWorkflow(input, { | 422 | yield* saveWorkflow(input, { |
| 341 | threadId, | 423 | threadId, |
| 342 | status: "thread_create_failed", | 424 | status: |
| 425 | input.reuseExistingThread === true ? "thread_update_failed" : "thread_create_failed", | ||
| 343 | title, | 426 | title, |
| 344 | worktreePath, | 427 | worktreePath, |
| 345 | branch, | 428 | branch, |
| 346 | setupCommitted, | 429 | setupCommitted, |
| 347 | threadCommitted: false, | 430 | threadCommitted: false, |
| 348 | messageCommitted, | 431 | messageCommitted, |
| 349 | lastError: "Thread creation failed.", | 432 | lastError: |
| 433 | input.reuseExistingThread === true | ||
| 434 | ? "Thread workspace update failed." | ||
| 435 | : "Thread creation failed.", | ||
| 350 | }); | 436 | }); |
| 351 | return yield* Effect.failCause(createExit.cause).pipe( | 437 | return yield* Effect.failCause(createExit.cause).pipe( |
| 352 | Effect.mapError(mapError(input, "create-thread", threadId)), | 438 | Effect.mapError( |
| 439 | mapError( | ||
| 440 | input, | ||
| 441 | input.reuseExistingThread === true ? "update-thread" : "create-thread", | ||
| 442 | threadId, | ||
| 443 | ), | ||
| 444 | ), | ||
| 353 | ); | 445 | ); |
| 354 | } | 446 | } |
| 355 | yield* saveWorkflow(input, { | 447 | yield* saveWorkflow(input, { |
| @@ -365,9 +457,11 @@ export const make = Effect.gen(function* () { · 9 unmodified lines | |||
| 365 | } | 457 | } |
| 366 | 458 | ||
| 367 | if (input.initialMessage !== undefined && !messageCommitted) { | 459 | if (input.initialMessage !== undefined && !messageCommitted) { |
| 368 | const messageId = yield* ids.allocate | 460 | const messageId = |
| 369 | .message({ threadId, ordinal: 1 }) | 461 | input.initialMessage.messageId ?? |
| 370 | .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); | 462 | (yield* ids.allocate |
| 463 | .message({ threadId, ordinal: 1 }) | ||
| 464 | .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)))); | ||
| 371 | yield* threads | 465 | yield* threads |
| 372 | .sendToThread({ | 466 | .sendToThread({ |
| 373 | projectId: input.projectId, | 467 | projectId: input.projectId, |
| @@ -32,7 +32,7 @@ import { layer as providerTurnControlServiceLayer } from "./ProviderTurnControlS · 31 unmodified lines | |||
| 32 | import { layer as providerTurnStartServiceLayer } from "./ProviderTurnStartService.ts"; | 32 | import { layer as providerTurnStartServiceLayer } from "./ProviderTurnStartService.ts"; |
| 33 | import { layer as runExecutionServiceLayer } from "./RunExecutionService.ts"; | 33 | import { layer as runExecutionServiceLayer } from "./RunExecutionService.ts"; |
| 34 | import { layer as runFinalizationServiceLayer } from "./RunFinalizationService.ts"; | 34 | import { layer as runFinalizationServiceLayer } from "./RunFinalizationService.ts"; |
| 35 | import { layer as runtimePolicyLayer } from "./RuntimePolicy.ts"; | 35 | import { layerFromProjectRepository as runtimePolicyLayerFromProjectRepository } from "./RuntimePolicy.ts"; |
| 36 | import { layer as runtimeRequestServiceLayer } from "./RuntimeRequestService.ts"; | 36 | import { layer as runtimeRequestServiceLayer } from "./RuntimeRequestService.ts"; |
| 37 | import { layer as threadManagementServiceLayer } from "./ThreadManagementService.ts"; | 37 | import { layer as threadManagementServiceLayer } from "./ThreadManagementService.ts"; |
| 38 | import { layer as threadLaunchServiceLayer } from "./ThreadLaunchService.ts"; | 38 | import { layer as threadLaunchServiceLayer } from "./ThreadLaunchService.ts"; |
| @@ -40,6 +40,13 @@ import { layer as threadLifecycleServiceLayer } from "./ThreadLifecycleService.t · 1 unmodified line | |||
| 40 | import { layer as threadForkServiceLayer } from "./ThreadForkService.ts"; | 40 | import { layer as threadForkServiceLayer } from "./ThreadForkService.ts"; |
| 41 | import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts"; | 41 | import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts"; |
| 42 | 42 | ||
| 43 | export const ProjectServiceLayerLive = projectServiceLayer.pipe( | ||
| 44 | Layer.provide(Layer.merge(ProjectionProjectRepositoryLive, OrchestrationLayerLive)), | ||
| 45 | ); | ||
| 46 | const runtimePolicyProvided = runtimePolicyLayerFromProjectRepository.pipe( | ||
| 47 | Layer.provide(ProjectionProjectRepositoryLive), | ||
| 48 | ); | ||
| 49 | |||
| 43 | const eventStoreProvided = eventStoreLayer.pipe( | 50 | const eventStoreProvided = eventStoreLayer.pipe( |
| 44 | Layer.provide(OrchestrationEventInfrastructureLayerLive), | 51 | Layer.provide(OrchestrationEventInfrastructureLayerLive), |
| 45 | ); | 52 | ); |
| @@ -111,7 +118,7 @@ const providerTurnStartServiceProvided = providerTurnStartServiceLayer.pipe( · 65 unmodified lines | |||
| 111 | projectionStoreLayer, | 118 | projectionStoreLayer, |
| 112 | providerSessionManagerProvided, | 119 | providerSessionManagerProvided, |
| 113 | runExecutionServiceProvided, | 120 | runExecutionServiceProvided, |
| 114 | runtimePolicyLayer, | 121 | runtimePolicyProvided, |
| 115 | ), | 122 | ), |
| 116 | ), | 123 | ), |
| 117 | ); | 124 | ); |
| @@ -130,7 +137,7 @@ const checkpointRollbackServiceProvided = checkpointRollbackServiceLayer.pipe( · 12 unmodified lines | |||
| 130 | idAllocatorLayer, | 137 | idAllocatorLayer, |
| 131 | projectionStoreLayer, | 138 | projectionStoreLayer, |
| 132 | providerSessionManagerProvided, | 139 | providerSessionManagerProvided, |
| 133 | runtimePolicyLayer, | 140 | runtimePolicyProvided, |
| 134 | ), | 141 | ), |
| 135 | ), | 142 | ), |
| 136 | ); | 143 | ); |
| @@ -189,7 +196,7 @@ const orchestratorProvided = orchestratorLayer.pipe( · 52 unmodified lines | |||
| 189 | idAllocatorLayer, | 196 | idAllocatorLayer, |
| 190 | providerAdapterRegistryProvided, | 197 | providerAdapterRegistryProvided, |
| 191 | providerEventIngestorProvided, | 198 | providerEventIngestorProvided, |
| 192 | runtimePolicyLayer, | 199 | runtimePolicyProvided, |
| 193 | providerSessionManagerProvided, | 200 | providerSessionManagerProvided, |
| 194 | providerSwitchServiceProvided, | 201 | providerSwitchServiceProvided, |
| 195 | runExecutionServiceProvided, | 202 | runExecutionServiceProvided, |
| @@ -201,9 +208,6 @@ const orchestratorProvided = orchestratorLayer.pipe( · 5 unmodified lines | |||
| 201 | const threadManagementProvided = threadManagementServiceLayer.pipe( | 208 | const threadManagementProvided = threadManagementServiceLayer.pipe( |
| 202 | Layer.provide(orchestratorProvided), | 209 | Layer.provide(orchestratorProvided), |
| 203 | ); | 210 | ); |
| 204 | export const ProjectServiceLayerLive = projectServiceLayer.pipe( | ||
| 205 | Layer.provide(Layer.merge(ProjectionProjectRepositoryLive, OrchestrationLayerLive)), | ||
| 206 | ); | ||
| 207 | export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe( | 211 | export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe( |
| 208 | Layer.provide(ProjectServiceLayerLive), | 212 | Layer.provide(ProjectServiceLayerLive), |
| 209 | ); | 213 | ); |
| @@ -1,8 +1,11 @@ | |||
| 1 | import * as DateTime from "effect/DateTime"; | 1 | import * as DateTime from "effect/DateTime"; |
| 2 | import * as Duration from "effect/Duration"; | 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Encoding from "effect/Encoding"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; | ||
| 6 | import * as Queue from "effect/Queue"; | 9 | import * as Queue from "effect/Queue"; |
| 7 | import * as Ref from "effect/Ref"; | 10 | import * as Ref from "effect/Ref"; |
| 8 | import * as Result from "effect/Result"; | 11 | import * as Result from "effect/Result"; |
| @@ -13,9 +16,11 @@ import { · 4 unmodified lines | |||
| 13 | type AuthAccessStreamEvent, | 16 | type AuthAccessStreamEvent, |
| 14 | type AuthEnvironmentScope, | 17 | type AuthEnvironmentScope, |
| 15 | AuthSessionId, | 18 | AuthSessionId, |
| 19 | CommandId, | ||
| 16 | type DiscoveredLocalServerList, | 20 | type DiscoveredLocalServerList, |
| 17 | type GitActionProgressEvent, | 21 | type GitActionProgressEvent, |
| 18 | type GitManagerServiceError, | 22 | type GitManagerServiceError, |
| 23 | type MessageId, | ||
| 19 | OrchestrationGetFullThreadDiffError, | 24 | OrchestrationGetFullThreadDiffError, |
| 20 | OrchestrationGetTurnDiffError, | 25 | OrchestrationGetTurnDiffError, |
| 21 | ORCHESTRATION_V2_WS_METHODS, | 26 | ORCHESTRATION_V2_WS_METHODS, |
| @@ -28,18 +33,21 @@ import { · 6 unmodified lines | |||
| 28 | type ProjectEntriesFailure, | 33 | type ProjectEntriesFailure, |
| 29 | type ProjectFileFailure, | 34 | type ProjectFileFailure, |
| 30 | type ProjectFileOperation, | 35 | type ProjectFileOperation, |
| 36 | type ProjectMutation, | ||
| 31 | ProjectListEntriesError, | 37 | ProjectListEntriesError, |
| 32 | ProjectReadFileError, | 38 | ProjectReadFileError, |
| 33 | ProjectSearchContentsError, | 39 | ProjectSearchContentsError, |
| 34 | ProjectSearchEntriesError, | 40 | ProjectSearchEntriesError, |
| 35 | ProjectWriteFileError, | 41 | ProjectWriteFileError, |
| 42 | ProjectMutationError, | ||
| 36 | RelayClientInstallFailedError, | 43 | RelayClientInstallFailedError, |
| 37 | type RelayClientInstallProgressEvent, | 44 | type RelayClientInstallProgressEvent, |
| 38 | type FilesystemBrowseFailure, | 45 | type FilesystemBrowseFailure, |
| 39 | FilesystemBrowseError, | 46 | FilesystemBrowseError, |
| 40 | AssetWorkspaceContextNotFoundError, | 47 | AssetWorkspaceContextNotFoundError, |
| 41 | AssetWorkspaceContextResolutionError, | 48 | AssetWorkspaceContextResolutionError, |
| 42 | RpcClientId, | 49 | ChatAttachmentId, |
| 50 | PersistChatAttachmentsError, | ||
| 43 | EnvironmentAuthorizationError, | 51 | EnvironmentAuthorizationError, |
| 44 | ThreadId, | 52 | ThreadId, |
| 45 | type TerminalAttachStreamEvent, | 53 | type TerminalAttachStreamEvent, |
| @@ -81,6 +89,8 @@ import * as TerminalManager from "./terminal/Manager.ts"; · 35 unmodified lines | |||
| 81 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | 89 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 82 | import * as PreviewManager from "./preview/Manager.ts"; | 90 | import * as PreviewManager from "./preview/Manager.ts"; |
| 83 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; | 91 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 92 | import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts"; | ||
| 93 | import { parseBase64DataUrl } from "./imageMime.ts"; | ||
| 84 | import * as PortScanner from "./preview/PortScanner.ts"; | 94 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 85 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 95 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 86 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 96 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| @@ -118,6 +128,73 @@ function unexpectedCompatibilityError(error: never): never { · 31 unmodified lines | |||
| 118 | throw new Error(`Unhandled compatibility error: ${String(error)}`); | 128 | throw new Error(`Unhandled compatibility error: ${String(error)}`); |
| 119 | } | 129 | } |
| 120 | 130 | ||
| 131 | const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(function* (input: { | ||
| 132 | readonly threadId: ThreadId; | ||
| 133 | readonly messageId: MessageId; | ||
| 134 | readonly attachments: ReadonlyArray<{ | ||
| 135 | readonly type: "image"; | ||
| 136 | readonly name: string; | ||
| 137 | readonly mimeType: string; | ||
| 138 | readonly sizeBytes: number; | ||
| 139 | readonly dataUrl: string; | ||
| 140 | }>; | ||
| 141 | }) { | ||
| 142 | const config = yield* ServerConfig.ServerConfig; | ||
| 143 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 144 | const path = yield* Path.Path; | ||
| 145 | return yield* Effect.forEach( | ||
| 146 | input.attachments.map((attachment, index) => ({ attachment, index })), | ||
| 147 | Effect.fn("ws.assets.persistChatAttachment")(function* ({ attachment, index }) { | ||
| 148 | const parsed = parseBase64DataUrl(attachment.dataUrl); | ||
| 149 | if (parsed === null || parsed.mimeType !== attachment.mimeType.toLowerCase()) { | ||
| 150 | return yield* new PersistChatAttachmentsError({ | ||
| 151 | message: `Attachment ${attachment.name} has an invalid image payload.`, | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | const bytes = yield* Effect.fromResult(Encoding.decodeBase64(parsed.base64)).pipe( | ||
| 155 | Effect.mapError( | ||
| 156 | (cause) => | ||
| 157 | new PersistChatAttachmentsError({ | ||
| 158 | message: `Attachment ${attachment.name} is not valid base64.`, | ||
| 159 | cause, | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | if (bytes.byteLength !== attachment.sizeBytes) { | ||
| 164 | return yield* new PersistChatAttachmentsError({ | ||
| 165 | message: `Attachment ${attachment.name} size does not match its payload.`, | ||
| 166 | }); | ||
| 167 | } | ||
| 168 | const rawId = createDeterministicAttachmentId(input.threadId, `${input.messageId}:${index}`); | ||
| 169 | if (rawId === null) { | ||
| 170 | return yield* new PersistChatAttachmentsError({ | ||
| 171 | message: "Could not allocate an attachment identifier.", | ||
| 172 | }); | ||
| 173 | } | ||
| 174 | const persisted = { | ||
| 175 | type: "image" as const, | ||
| 176 | id: ChatAttachmentId.make(rawId), | ||
| 177 | name: attachment.name, | ||
| 178 | mimeType: attachment.mimeType, | ||
| 179 | sizeBytes: attachment.sizeBytes, | ||
| 180 | }; | ||
| 181 | yield* fileSystem | ||
| 182 | .writeFile(path.join(config.attachmentsDir, attachmentRelativePath(persisted)), bytes) | ||
| 183 | .pipe( | ||
| 184 | Effect.mapError( | ||
| 185 | (cause) => | ||
| 186 | new PersistChatAttachmentsError({ | ||
| 187 | message: `Could not persist attachment ${attachment.name}.`, | ||
| 188 | cause, | ||
| 189 | }), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | return persisted; | ||
| 193 | }), | ||
| 194 | { concurrency: 2 }, | ||
| 195 | ); | ||
| 196 | }); | ||
| 197 | |||
| 121 | function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { | 198 | function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { |
| 122 | readonly failure: ProjectEntriesFailure; | 199 | readonly failure: ProjectEntriesFailure; |
| 123 | readonly normalizedCwd?: string; | 200 | readonly normalizedCwd?: string; |
| @@ -253,8 +330,11 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 129 unmodified lines | |||
| 253 | [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], | 330 | [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], |
| 254 | [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], | 331 | [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], |
| 255 | [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], | 332 | [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], |
| 333 | [WS_METHODS.projectsMutate, AuthOrchestrationOperateScope], | ||
| 256 | [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], | 334 | [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], |
| 257 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], | 335 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], |
| 336 | [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], | ||
| 337 | [WS_METHODS.assetsPersistChatAttachments, AuthOrchestrationOperateScope], | ||
| 258 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], | 338 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], |
| 259 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], | 339 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], |
| 260 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], | 340 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], |
| @@ -623,20 +703,28 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 362 unmodified lines | |||
| 623 | }, | 703 | }, |
| 624 | ); | 704 | ); |
| 625 | 705 | ||
| 626 | const getOrchestrationV2ArchivedShellSnapshot = threadManagement.getShellSnapshot().pipe( | 706 | const getOrchestrationV2ArchivedShellSnapshot = sql |
| 627 | Effect.map((snapshot) => ({ | 707 | .withTransaction( |
| 628 | schemaVersion: snapshot.schemaVersion, | 708 | Effect.gen(function* () { |
| 629 | snapshotSequence: snapshot.snapshotSequence, | 709 | const projects = yield* projectionSnapshotQuery.getShellSnapshot(); |
| 630 | threads: snapshot.archivedThreads, | 710 | const threads = yield* threadManagement.getShellSnapshot(); |
| 631 | })), | 711 | return { |
| 632 | Effect.mapError( | 712 | schemaVersion: threads.schemaVersion, |
| 633 | (cause) => | 713 | snapshotSequence: yield* applicationEvents.latestApplicationSequence, |
| 634 | new OrchestrationV2GetShellSnapshotError({ | 714 | projects: projects.projects, |
| 635 | message: "Failed to load archived thread snapshot", | 715 | threads: threads.archivedThreads, |
| 636 | cause, | 716 | } as const; |
| 637 | }), | 717 | }), |
| 638 | ), | 718 | ) |
| 639 | ); | 719 | .pipe( |
| 720 | Effect.mapError( | ||
| 721 | (cause) => | ||
| 722 | new OrchestrationV2GetShellSnapshotError({ | ||
| 723 | message: "Failed to load archived thread snapshot", | ||
| 724 | cause, | ||
| 725 | }), | ||
| 726 | ), | ||
| 727 | ); | ||
| 640 | 728 | ||
| 641 | const subscribeOrchestrationV2ArchivedShell = Effect.fn( | 729 | const subscribeOrchestrationV2ArchivedShell = Effect.fn( |
| 642 | "ws.orchestrationV2.subscribeArchivedShell", | 730 | "ws.orchestrationV2.subscribeArchivedShell", |
| @@ -671,6 +759,64 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 28 unmodified lines | |||
| 671 | return Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live); | 759 | return Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live); |
| 672 | }); | 760 | }); |
| 673 | 761 | ||
| 762 | const mutateProject = Effect.fn("ws.projects.mutate")(function* (mutation: ProjectMutation) { | ||
| 763 | switch (mutation.type) { | ||
| 764 | case "project.create": | ||
| 765 | return yield* projectService.create({ | ||
| 766 | commandId: mutation.commandId, | ||
| 767 | projectId: mutation.projectId, | ||
| 768 | title: mutation.title, | ||
| 769 | workspaceRoot: mutation.workspaceRoot, | ||
| 770 | ...(mutation.createWorkspaceRootIfMissing === undefined | ||
| 771 | ? {} | ||
| 772 | : { createWorkspaceRootIfMissing: mutation.createWorkspaceRootIfMissing }), | ||
| 773 | ...(mutation.defaultModelSelection === undefined | ||
| 774 | ? {} | ||
| 775 | : { defaultModelSelection: mutation.defaultModelSelection }), | ||
| 776 | ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), | ||
| 777 | }); | ||
| 778 | case "project.update": | ||
| 779 | return yield* projectService.update({ | ||
| 780 | commandId: mutation.commandId, | ||
| 781 | projectId: mutation.projectId, | ||
| 782 | ...(mutation.title === undefined ? {} : { title: mutation.title }), | ||
| 783 | ...(mutation.workspaceRoot === undefined | ||
| 784 | ? {} | ||
| 785 | : { workspaceRoot: mutation.workspaceRoot }), | ||
| 786 | ...(mutation.defaultModelSelection === undefined | ||
| 787 | ? {} | ||
| 788 | : { defaultModelSelection: mutation.defaultModelSelection }), | ||
| 789 | ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), | ||
| 790 | }); | ||
| 791 | case "project.delete": { | ||
| 792 | const snapshot = yield* threadManagement.getShellSnapshot(); | ||
| 793 | const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( | ||
| 794 | (thread) => thread.projectId === mutation.projectId, | ||
| 795 | ); | ||
| 796 | if (projectThreads.length > 0 && mutation.force !== true) { | ||
| 797 | return yield* new ProjectMutationError({ | ||
| 798 | commandId: mutation.commandId, | ||
| 799 | message: `Project ${mutation.projectId} is not empty.`, | ||
| 800 | }); | ||
| 801 | } | ||
| 802 | yield* Effect.forEach( | ||
| 803 | projectThreads, | ||
| 804 | (thread) => | ||
| 805 | threadManagement.dispatch({ | ||
| 806 | type: "thread.delete", | ||
| 807 | commandId: CommandId.make(`${mutation.commandId}:delete-thread:${thread.id}`), | ||
| 808 | threadId: thread.id, | ||
| 809 | }), | ||
| 810 | { concurrency: 1, discard: true }, | ||
| 811 | ); | ||
| 812 | return yield* projectService.delete({ | ||
| 813 | commandId: mutation.commandId, | ||
| 814 | projectId: mutation.projectId, | ||
| 815 | }); | ||
| 816 | } | ||
| 817 | } | ||
| 818 | }); | ||
| 819 | |||
| 674 | const handlers = ServerWsRpcGroup.of({ | 820 | const handlers = ServerWsRpcGroup.of({ |
| 675 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => | 821 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => |
| 676 | observeRpcEffect( | 822 | observeRpcEffect( |
| @@ -680,7 +826,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 3 unmodified lines | |||
| 680 | threadManagement.dispatch( | 826 | threadManagement.dispatch( |
| 681 | ThreadManagementService.withCreationProvenance(command, { | 827 | ThreadManagementService.withCreationProvenance(command, { |
| 682 | createdBy: "user", | 828 | createdBy: "user", |
| 683 | creationSource: "web", | 829 | creationSource: "creationSource" in command ? command.creationSource : "web", |
| 684 | }), | 830 | }), |
| 685 | ), | 831 | ), |
| 686 | ) | 832 | ) |
| @@ -771,26 +917,29 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 84 unmodified lines | |||
| 771 | .enqueueCommand( | 917 | .enqueueCommand( |
| 772 | threadLaunch.launch({ | 918 | threadLaunch.launch({ |
| 773 | commandId: input.commandId, | 919 | commandId: input.commandId, |
| 920 | ...(input.threadId === undefined ? {} : { threadId: input.threadId }), | ||
| 921 | ...(input.reuseExistingThread === undefined | ||
| 922 | ? {} | ||
| 923 | : { reuseExistingThread: input.reuseExistingThread }), | ||
| 774 | projectId: input.projectId, | 924 | projectId: input.projectId, |
| 775 | title: input.title, | 925 | title: input.title, |
| 776 | modelSelection: input.modelSelection, | 926 | modelSelection: input.modelSelection, |
| 777 | runtimeMode: input.runtimeMode, | 927 | runtimeMode: input.runtimeMode, |
| 778 | interactionMode: input.interactionMode, | 928 | interactionMode: input.interactionMode, |
| 779 | workspaceStrategy: | 929 | workspaceStrategy: input.workspaceStrategy, |
| 780 | input.workspaceStrategy.type === "root" | ||
| 781 | ? { type: "root" } | ||
| 782 | : { | ||
| 783 | type: "worktree", | ||
| 784 | baseRef: input.workspaceStrategy.baseRef, | ||
| 785 | ...(input.workspaceStrategy.branch === undefined | ||
| 786 | ? {} | ||
| 787 | : { branch: input.workspaceStrategy.branch }), | ||
| 788 | }, | ||
| 789 | ...(input.initialMessage === undefined | 930 | ...(input.initialMessage === undefined |
| 790 | ? {} | 931 | ? {} |
| 791 | : { initialMessage: input.initialMessage }), | 932 | : { |
| 933 | initialMessage: { | ||
| 934 | ...(input.initialMessage.messageId === undefined | ||
| 935 | ? {} | ||
| 936 | : { messageId: input.initialMessage.messageId }), | ||
| 937 | text: input.initialMessage.text, | ||
| 938 | attachments: input.initialMessage.attachments, | ||
| 939 | }, | ||
| 940 | }), | ||
| 792 | createdBy: "user", | 941 | createdBy: "user", |
| 793 | creationSource: "web", | 942 | creationSource: input.creationSource ?? "web", |
| 794 | }), | 943 | }), |
| 795 | ) | 944 | ) |
| 796 | .pipe( | 945 | .pipe( |
| @@ -1137,6 +1286,22 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 340 unmodified lines | |||
| 1137 | ), | 1286 | ), |
| 1138 | { "rpc.aggregate": "workspace" }, | 1287 | { "rpc.aggregate": "workspace" }, |
| 1139 | ), | 1288 | ), |
| 1289 | [WS_METHODS.projectsMutate]: (mutation) => | ||
| 1290 | observeRpcEffect( | ||
| 1291 | WS_METHODS.projectsMutate, | ||
| 1292 | startup.enqueueCommand(mutateProject(mutation)).pipe( | ||
| 1293 | Effect.mapError((cause) => | ||
| 1294 | cause._tag === "ProjectMutationError" | ||
| 1295 | ? cause | ||
| 1296 | : new ProjectMutationError({ | ||
| 1297 | commandId: mutation.commandId, | ||
| 1298 | message: "Failed to mutate project.", | ||
| 1299 | cause, | ||
| 1300 | }), | ||
| 1301 | ), | ||
| 1302 | ), | ||
| 1303 | { "rpc.aggregate": "orchestration" }, | ||
| 1304 | ), | ||
| 1140 | [WS_METHODS.shellOpenInEditor]: (input) => | 1305 | [WS_METHODS.shellOpenInEditor]: (input) => |
| 1141 | observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { | 1306 | observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { |
| 1142 | "rpc.aggregate": "workspace", | 1307 | "rpc.aggregate": "workspace", |
| @@ -1195,6 +1360,12 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 52 unmodified lines | |||
| 1195 | }), | 1360 | }), |
| 1196 | { "rpc.aggregate": "workspace" }, | 1361 | { "rpc.aggregate": "workspace" }, |
| 1197 | ), | 1362 | ), |
| 1363 | [WS_METHODS.assetsPersistChatAttachments]: (input) => | ||
| 1364 | observeRpcEffect( | ||
| 1365 | WS_METHODS.assetsPersistChatAttachments, | ||
| 1366 | persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), | ||
| 1367 | { "rpc.aggregate": "orchestration" }, | ||
| 1368 | ), | ||
| 1198 | [WS_METHODS.subscribeVcsStatus]: (input) => | 1369 | [WS_METHODS.subscribeVcsStatus]: (input) => |
| 1199 | observeRpcStream( | 1370 | observeRpcStream( |
| 1200 | WS_METHODS.subscribeVcsStatus, | 1371 | WS_METHODS.subscribeVcsStatus, |
| @@ -116,6 +116,8 @@ export function BranchToolbarBranchSelector({ · 115 unmodified lines | |||
| 116 | () => scopeThreadRef(environmentId, threadId), | 116 | () => scopeThreadRef(environmentId, threadId), |
| 117 | [environmentId, threadId], | 117 | [environmentId, threadId], |
| 118 | ); | 118 | ); |
| 119 | const serverThread = useThread(threadRef); | ||
| 120 | const serverSession = serverThread?.runtime ?? null; | ||
| 119 | const draftThread = useComposerDraftStore((store) => | 121 | const draftThread = useComposerDraftStore((store) => |
| 120 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), | 122 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), |
| 121 | ); | 123 | ); |
| @@ -1,14 +1,8 @@ | |||
| 1 | import { | 1 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts"; |
| 2 | EnvironmentId, | ||
| 3 | MessageId, | ||
| 4 | ProjectId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ThreadId, | ||
| 7 | TurnId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 3 | ||
| 11 | import type { Thread, ThreadShell } from "../types"; | 4 | import type { Thread } from "../types"; |
| 5 | import { makeThreadFixture } from "../test-fixtures"; | ||
| 12 | import { | 6 | import { |
| 13 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | 7 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, |
| 14 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 8 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| @@ -37,7 +31,7 @@ const threadId = ThreadId.make("thread-1"); · 22 unmodified lines | |||
| 37 | const now = "2026-03-29T00:00:00.000Z"; | 31 | const now = "2026-03-29T00:00:00.000Z"; |
| 38 | 32 | ||
| 39 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 33 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 40 | return { | 34 | return makeThreadFixture({ |
| 41 | id: threadId, | 35 | id: threadId, |
| 42 | environmentId, | 36 | environmentId, |
| 43 | projectId, | 37 | projectId, |
| @@ -48,27 +42,25 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines | |||
| 48 | }, | 42 | }, |
| 49 | runtimeMode: "full-access", | 43 | runtimeMode: "full-access", |
| 50 | interactionMode: "default", | 44 | interactionMode: "default", |
| 51 | session: null, | 45 | runtime: null, |
| 52 | messages: [], | 46 | messages: [], |
| 53 | proposedPlans: [], | 47 | proposedPlans: [], |
| 54 | activities: [], | ||
| 55 | checkpoints: [], | ||
| 56 | createdAt: now, | 48 | createdAt: now, |
| 57 | updatedAt: now, | 49 | updatedAt: now, |
| 58 | archivedAt: null, | 50 | archivedAt: null, |
| 59 | settledOverride: null, | 51 | settledOverride: null, |
| 60 | settledAt: null, | 52 | settledAt: null, |
| 61 | deletedAt: null, | 53 | deletedAt: null, |
| 62 | latestTurn: null, | 54 | latestRun: null, |
| 63 | branch: null, | 55 | branch: null, |
| 64 | worktreePath: null, | 56 | worktreePath: null, |
| 65 | ...overrides, | 57 | ...overrides, |
| 66 | }; | 58 | }); |
| 67 | } | 59 | } |
| 68 | 60 | ||
| 69 | const completedTurn = { | 61 | const completedTurn = { |
| 70 | turnId: TurnId.make("turn-1"), | 62 | runId: RunId.make("turn-1"), |
| 71 | state: "completed" as const, | 63 | status: "completed" as const, |
| 72 | requestedAt: now, | 64 | requestedAt: now, |
| 73 | startedAt: "2026-03-29T00:00:01.000Z", | 65 | startedAt: "2026-03-29T00:00:01.000Z", |
| 74 | completedAt: "2026-03-29T00:00:10.000Z", | 66 | completedAt: "2026-03-29T00:00:10.000Z", |
| @@ -76,12 +68,10 @@ const completedTurn = { · 1 unmodified line | |||
| 76 | }; | 68 | }; |
| 77 | 69 | ||
| 78 | const readySession = { | 70 | const readySession = { |
| 79 | threadId, | 71 | status: "completed" as const, |
| 80 | status: "ready" as const, | ||
| 81 | providerName: "codex", | 72 | providerName: "codex", |
| 82 | providerInstanceId: ProviderInstanceId.make("codex"), | 73 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 83 | runtimeMode: "full-access" as const, | 74 | activeRunId: null, |
| 84 | activeTurnId: null, | ||
| 85 | lastError: null, | 75 | lastError: null, |
| 86 | updatedAt: "2026-03-29T00:00:10.000Z", | 76 | updatedAt: "2026-03-29T00:00:10.000Z", |
| 87 | }; | 77 | }; |
| @@ -527,16 +517,15 @@ describe("startNewThreadForProject", () => { · 439 unmodified lines | |||
| 527 | describe("hasServerAcknowledgedLocalDispatch", () => { | 517 | describe("hasServerAcknowledgedLocalDispatch", () => { |
| 528 | it("does not acknowledge unchanged server state", () => { | 518 | it("does not acknowledge unchanged server state", () => { |
| 529 | const localDispatch = createLocalDispatchSnapshot( | 519 | const localDispatch = createLocalDispatchSnapshot( |
| 530 | makeThread({ latestTurn: completedTurn, session: readySession }), | 520 | makeThread({ latestRun: completedTurn, runtime: readySession }), |
| 531 | ); | 521 | ); |
| 532 | 522 | ||
| 533 | expect( | 523 | expect( |
| 534 | hasServerAcknowledgedLocalDispatch({ | 524 | hasServerAcknowledgedLocalDispatch({ |
| 535 | localDispatch, | 525 | localDispatch, |
| 536 | phase: "ready", | 526 | phase: "ready", |
| 537 | latestTurn: completedTurn, | 527 | latestRun: completedTurn, |
| 538 | latestUserMessageId: localDispatch.latestUserMessageId, | 528 | runtime: readySession, |
| 539 | session: readySession, | ||
| 540 | hasPendingApproval: false, | 529 | hasPendingApproval: false, |
| 541 | hasPendingUserInput: false, | 530 | hasPendingUserInput: false, |
| 542 | threadError: null, | 531 | threadError: null, |
| @@ -546,11 +535,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | |||
| 546 | 535 | ||
| 547 | it("acknowledges a settled newer turn", () => { | 536 | it("acknowledges a settled newer turn", () => { |
| 548 | const localDispatch = createLocalDispatchSnapshot( | 537 | const localDispatch = createLocalDispatchSnapshot( |
| 549 | makeThread({ latestTurn: completedTurn, session: readySession }), | 538 | makeThread({ latestRun: completedTurn, runtime: readySession }), |
| 550 | ); | 539 | ); |
| 551 | const newerTurn = { | 540 | const newerTurn = { |
| 552 | ...completedTurn, | 541 | ...completedTurn, |
| 553 | turnId: TurnId.make("turn-2"), | 542 | runId: RunId.make("turn-2"), |
| 554 | requestedAt: "2026-03-29T00:01:00.000Z", | 543 | requestedAt: "2026-03-29T00:01:00.000Z", |
| 555 | startedAt: "2026-03-29T00:01:01.000Z", | 544 | startedAt: "2026-03-29T00:01:01.000Z", |
| 556 | completedAt: "2026-03-29T00:01:30.000Z", | 545 | completedAt: "2026-03-29T00:01:30.000Z", |
| @@ -560,9 +549,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | |||
| 560 | hasServerAcknowledgedLocalDispatch({ | 549 | hasServerAcknowledgedLocalDispatch({ |
| 561 | localDispatch, | 550 | localDispatch, |
| 562 | phase: "ready", | 551 | phase: "ready", |
| 563 | latestTurn: newerTurn, | 552 | latestRun: newerTurn, |
| 564 | latestUserMessageId: localDispatch.latestUserMessageId, | 553 | runtime: { ...readySession, updatedAt: newerTurn.completedAt }, |
| 565 | session: { ...readySession, updatedAt: newerTurn.completedAt }, | ||
| 566 | hasPendingApproval: false, | 554 | hasPendingApproval: false, |
| 567 | hasPendingUserInput: false, | 555 | hasPendingUserInput: false, |
| 568 | threadError: null, | 556 | threadError: null, |
| @@ -572,12 +560,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | |||
| 572 | 560 | ||
| 573 | it("waits for the matching running turn before acknowledging", () => { | 561 | it("waits for the matching running turn before acknowledging", () => { |
| 574 | const localDispatch = createLocalDispatchSnapshot( | 562 | const localDispatch = createLocalDispatchSnapshot( |
| 575 | makeThread({ latestTurn: completedTurn, session: readySession }), | 563 | makeThread({ latestRun: completedTurn, runtime: readySession }), |
| 576 | ); | 564 | ); |
| 577 | const runningTurn = { | 565 | const runningTurn = { |
| 578 | ...completedTurn, | 566 | ...completedTurn, |
| 579 | turnId: TurnId.make("turn-2"), | 567 | runId: RunId.make("turn-2"), |
| 580 | state: "running" as const, | 568 | status: "running" as const, |
| 581 | requestedAt: "2026-03-29T00:01:00.000Z", | 569 | requestedAt: "2026-03-29T00:01:00.000Z", |
| 582 | startedAt: "2026-03-29T00:01:01.000Z", | 570 | startedAt: "2026-03-29T00:01:01.000Z", |
| 583 | completedAt: null, | 571 | completedAt: null, |
| @@ -587,12 +575,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | |||
| 587 | hasServerAcknowledgedLocalDispatch({ | 575 | hasServerAcknowledgedLocalDispatch({ |
| 588 | localDispatch, | 576 | localDispatch, |
| 589 | phase: "running", | 577 | phase: "running", |
| 590 | latestTurn: runningTurn, | 578 | latestRun: runningTurn, |
| 591 | latestUserMessageId: localDispatch.latestUserMessageId, | 579 | runtime: { |
| 592 | session: { | ||
| 593 | ...readySession, | 580 | ...readySession, |
| 594 | status: "running", | 581 | status: "running", |
| 595 | activeTurnId: TurnId.make("turn-other"), | 582 | activeRunId: RunId.make("turn-other"), |
| 596 | }, | 583 | }, |
| 597 | hasPendingApproval: false, | 584 | hasPendingApproval: false, |
| 598 | hasPendingUserInput: false, | 585 | hasPendingUserInput: false, |
| @@ -603,12 +590,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 4 unmodified lines | |||
| 603 | hasServerAcknowledgedLocalDispatch({ | 590 | hasServerAcknowledgedLocalDispatch({ |
| 604 | localDispatch, | 591 | localDispatch, |
| 605 | phase: "running", | 592 | phase: "running", |
| 606 | latestTurn: runningTurn, | 593 | latestRun: runningTurn, |
| 607 | latestUserMessageId: localDispatch.latestUserMessageId, | 594 | runtime: { |
| 608 | session: { | ||
| 609 | ...readySession, | 595 | ...readySession, |
| 610 | status: "running", | 596 | status: "running", |
| 611 | activeTurnId: runningTurn.turnId, | 597 | activeRunId: runningTurn.runId, |
| 612 | }, | 598 | }, |
| 613 | hasPendingApproval: false, | 599 | hasPendingApproval: false, |
| 614 | hasPendingUserInput: false, | 600 | hasPendingUserInput: false, |
| @@ -665,9 +651,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 50 unmodified lines | |||
| 665 | const common = { | 651 | const common = { |
| 666 | localDispatch, | 652 | localDispatch, |
| 667 | phase: "ready" as const, | 653 | phase: "ready" as const, |
| 668 | latestTurn: null, | 654 | latestRun: null, |
| 669 | latestUserMessageId: localDispatch.latestUserMessageId, | 655 | runtime: null, |
| 670 | session: null, | ||
| 671 | hasPendingApproval: false, | 656 | hasPendingApproval: false, |
| 672 | hasPendingUserInput: false, | 657 | hasPendingUserInput: false, |
| 673 | threadError: null, | 658 | threadError: null, |
| @@ -8,9 +8,11 @@ import { · 7 unmodified lines | |||
| 8 | type ScopedProjectRef, | 8 | type ScopedProjectRef, |
| 9 | type ScopedThreadRef, | 9 | type ScopedThreadRef, |
| 10 | type ThreadId, | 10 | type ThreadId, |
| 11 | type TurnId, | 11 | type RunId, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; | 13 | import * as DateTime from "effect/DateTime"; |
| 14 | import { presentThread } from "@t3tools/client-runtime/state/shell"; | ||
| 15 | import { type ChatMessage, type SessionPhase, type Thread } from "../types"; | ||
| 14 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 16 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 15 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| @@ -70,29 +72,46 @@ export function buildLocalDraftThread( · 53 unmodified lines | |||
| 70 | draftThread: DraftThreadState, | 72 | draftThread: DraftThreadState, |
| 71 | fallbackModelSelection: ModelSelection, | 73 | fallbackModelSelection: ModelSelection, |
| 72 | ): Thread { | 74 | ): Thread { |
| 73 | return { | 75 | const timestamp = DateTime.makeUnsafe(draftThread.createdAt); |
| 74 | id: threadId, | 76 | return presentThread(draftThread.environmentId, { |
| 75 | environmentId: draftThread.environmentId, | 77 | thread: { |
| 76 | projectId: draftThread.projectId, | 78 | id: threadId, |
| 77 | title: "New thread", | 79 | projectId: draftThread.projectId, |
| 78 | modelSelection: fallbackModelSelection, | 80 | title: "New thread", |
| 79 | runtimeMode: draftThread.runtimeMode, | 81 | providerInstanceId: fallbackModelSelection.instanceId, |
| 80 | interactionMode: draftThread.interactionMode, | 82 | modelSelection: fallbackModelSelection, |
| 81 | session: null, | 83 | runtimeMode: draftThread.runtimeMode, |
| 84 | interactionMode: draftThread.interactionMode, | ||
| 85 | branch: draftThread.branch, | ||
| 86 | worktreePath: draftThread.worktreePath, | ||
| 87 | activeProviderThreadId: null, | ||
| 88 | lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, | ||
| 89 | forkedFrom: null, | ||
| 90 | createdBy: "user", | ||
| 91 | creationSource: "web", | ||
| 92 | createdAt: timestamp, | ||
| 93 | updatedAt: timestamp, | ||
| 94 | archivedAt: null, | ||
| 95 | deletedAt: null, | ||
| 96 | }, | ||
| 97 | runs: [], | ||
| 98 | attempts: [], | ||
| 99 | nodes: [], | ||
| 100 | subagents: [], | ||
| 101 | providerSessions: [], | ||
| 102 | providerThreads: [], | ||
| 103 | providerTurns: [], | ||
| 104 | runtimeRequests: [], | ||
| 82 | messages: [], | 105 | messages: [], |
| 83 | createdAt: draftThread.createdAt, | 106 | plans: [], |
| 84 | updatedAt: draftThread.createdAt, | 107 | turnItems: [], |
| 85 | archivedAt: null, | 108 | checkpointScopes: [], |
| 86 | settledOverride: null, | ||
| 87 | settledAt: null, | ||
| 88 | deletedAt: null, | ||
| 89 | latestTurn: null, | ||
| 90 | branch: draftThread.branch, | ||
| 91 | worktreePath: draftThread.worktreePath, | ||
| 92 | checkpoints: [], | 109 | checkpoints: [], |
| 93 | activities: [], | 110 | contextHandoffs: [], |
| 94 | proposedPlans: [], | 111 | contextTransfers: [], |
| 95 | }; | 112 | visibleTurnItems: [], |
| 113 | updatedAt: timestamp, | ||
| 114 | }); | ||
| 96 | } | 115 | } |
| 97 | 116 | ||
| 98 | export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { | 117 | export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { |
| @@ -353,7 +372,7 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean · 254 unmodified lines | |||
| 353 | 372 | ||
| 354 | export function threadHasStarted(thread: Thread | null | undefined): boolean { | 373 | export function threadHasStarted(thread: Thread | null | undefined): boolean { |
| 355 | return Boolean( | 374 | return Boolean( |
| 356 | thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), | 375 | thread && (thread.latestRun !== null || thread.messages.length > 0 || thread.runtime !== null), |
| 357 | ); | 376 | ); |
| 358 | } | 377 | } |
| 359 | 378 | ||
| @@ -377,7 +396,7 @@ export function deriveLockedProvider(input: { · 17 unmodified lines | |||
| 377 | if (!threadHasStarted(input.thread)) { | 396 | if (!threadHasStarted(input.thread)) { |
| 378 | return null; | 397 | return null; |
| 379 | } | 398 | } |
| 380 | const sessionProvider = input.thread?.session?.providerName ?? null; | 399 | const sessionProvider = input.thread?.runtime?.providerName ?? null; |
| 381 | if (sessionProvider && isProviderDriverKind(sessionProvider)) { | 400 | if (sessionProvider && isProviderDriverKind(sessionProvider)) { |
| 382 | return sessionProvider; | 401 | return sessionProvider; |
| 383 | } | 402 | } |
| @@ -478,41 +497,37 @@ export async function waitForStartedServerThread( · 94 unmodified lines | |||
| 478 | export interface LocalDispatchSnapshot { | 497 | export interface LocalDispatchSnapshot { |
| 479 | startedAt: string; | 498 | startedAt: string; |
| 480 | preparingWorktree: boolean; | 499 | preparingWorktree: boolean; |
| 481 | latestUserMessageId: ChatMessage["id"] | null; | 500 | latestRunId: RunId | null; |
| 482 | latestTurnTurnId: TurnId | null; | 501 | latestRunRequestedAt: string | null; |
| 483 | latestTurnRequestedAt: string | null; | 502 | latestRunStartedAt: string | null; |
| 484 | latestTurnStartedAt: string | null; | 503 | latestRunCompletedAt: string | null; |
| 485 | latestTurnCompletedAt: string | null; | 504 | runtimeStatus: NonNullable<Thread["runtime"]>["status"] | null; |
| 486 | sessionStatus: NonNullable<Thread["session"]>["status"] | null; | 505 | runtimeUpdatedAt: string | null; |
| 487 | sessionUpdatedAt: string | null; | ||
| 488 | } | 506 | } |
| 489 | 507 | ||
| 490 | export function createLocalDispatchSnapshot( | 508 | export function createLocalDispatchSnapshot( |
| 491 | activeThread: Thread | undefined, | 509 | activeThread: Thread | undefined, |
| 492 | options?: { preparingWorktree?: boolean }, | 510 | options?: { preparingWorktree?: boolean }, |
| 493 | ): LocalDispatchSnapshot { | 511 | ): LocalDispatchSnapshot { |
| 494 | const latestTurn = activeThread?.latestTurn ?? null; | 512 | const latestRun = activeThread?.latestRun ?? null; |
| 495 | const session = activeThread?.session ?? null; | 513 | const runtime = activeThread?.runtime ?? null; |
| 496 | const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user"); | ||
| 497 | return { | 514 | return { |
| 498 | startedAt: new Date().toISOString(), | 515 | startedAt: new Date().toISOString(), |
| 499 | preparingWorktree: Boolean(options?.preparingWorktree), | 516 | preparingWorktree: Boolean(options?.preparingWorktree), |
| 500 | latestUserMessageId: latestUserMessage?.id ?? null, | 517 | latestRunId: latestRun?.runId ?? null, |
| 501 | latestTurnTurnId: latestTurn?.turnId ?? null, | 518 | latestRunRequestedAt: latestRun?.requestedAt ?? null, |
| 502 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, | 519 | latestRunStartedAt: latestRun?.startedAt ?? null, |
| 503 | latestTurnStartedAt: latestTurn?.startedAt ?? null, | 520 | latestRunCompletedAt: latestRun?.completedAt ?? null, |
| 504 | latestTurnCompletedAt: latestTurn?.completedAt ?? null, | 521 | runtimeStatus: runtime?.status ?? null, |
| 505 | sessionStatus: session?.status ?? null, | 522 | runtimeUpdatedAt: runtime?.updatedAt ?? null, |
| 506 | sessionUpdatedAt: session?.updatedAt ?? null, | ||
| 507 | }; | 523 | }; |
| 508 | } | 524 | } |
| 509 | 525 | ||
| 510 | export function hasServerAcknowledgedLocalDispatch(input: { | 526 | export function hasServerAcknowledgedLocalDispatch(input: { |
| 511 | localDispatch: LocalDispatchSnapshot | null; | 527 | localDispatch: LocalDispatchSnapshot | null; |
| 512 | phase: SessionPhase; | 528 | phase: SessionPhase; |
| 513 | latestTurn: Thread["latestTurn"] | null; | 529 | latestRun: Thread["latestRun"] | null; |
| 514 | latestUserMessageId: ChatMessage["id"] | null; | 530 | runtime: Thread["runtime"] | null; |
| 515 | session: Thread["session"] | null; | ||
| 516 | hasPendingApproval: boolean; | 531 | hasPendingApproval: boolean; |
| 517 | hasPendingUserInput: boolean; | 532 | hasPendingUserInput: boolean; |
| 518 | threadError: string | null | undefined; | 533 | threadError: string | null | undefined; |
| @@ -524,34 +539,25 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 5 unmodified lines | |||
| 524 | return true; | 539 | return true; |
| 525 | } | 540 | } |
| 526 | 541 | ||
| 527 | const latestTurn = input.latestTurn ?? null; | 542 | const latestRun = input.latestRun ?? null; |
| 528 | const session = input.session ?? null; | 543 | const runtime = input.runtime ?? null; |
| 529 | const latestUserMessageChanged = | 544 | const latestRunChanged = |
| 530 | input.localDispatch.latestUserMessageId !== input.latestUserMessageId; | 545 | input.localDispatch.latestRunId !== (latestRun?.runId ?? null) || |
| 531 | const latestTurnChanged = | 546 | input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) || |
| 532 | input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || | 547 | input.localDispatch.latestRunStartedAt !== (latestRun?.startedAt ?? null) || |
| 533 | input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || | 548 | input.localDispatch.latestRunCompletedAt !== (latestRun?.completedAt ?? null); |
| 534 | input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || | ||
| 535 | input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); | ||
| 536 | 549 | ||
| 537 | if (input.phase === "running") { | 550 | if (input.phase === "running") { |
| 538 | // Steering adds a user message to the current running turn without | 551 | if (!latestRunChanged) { |
| 539 | // necessarily changing any of the turn timestamps. Treat that projected | ||
| 540 | // message as the server acknowledgment so the composer does not remain | ||
| 541 | // stuck in its local "Sending" state until the turn settles. | ||
| 542 | if (latestUserMessageChanged) { | ||
| 543 | return true; | ||
| 544 | } | ||
| 545 | if (!latestTurnChanged) { | ||
| 546 | return false; | 552 | return false; |
| 547 | } | 553 | } |
| 548 | if (latestTurn?.startedAt === null || latestTurn === null) { | 554 | if (latestRun?.startedAt === null || latestRun === null) { |
| 549 | return false; | 555 | return false; |
| 550 | } | 556 | } |
| 551 | if ( | 557 | if ( |
| 552 | session?.activeTurnId !== null && | 558 | runtime?.activeRunId !== null && |
| 553 | session?.activeTurnId !== undefined && | 559 | runtime?.activeRunId !== undefined && |
| 554 | latestTurn?.turnId !== session.activeTurnId | 560 | latestRun?.runId !== runtime.activeRunId |
| 555 | ) { | 561 | ) { |
| 556 | return false; | 562 | return false; |
| 557 | } | 563 | } |
| @@ -559,8 +565,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 1 unmodified line | |||
| 559 | } | 565 | } |
| 560 | 566 | ||
| 561 | return ( | 567 | return ( |
| 562 | latestTurnChanged || | 568 | latestRunChanged || |
| 563 | input.localDispatch.sessionStatus !== (session?.status ?? null) || | 569 | input.localDispatch.runtimeStatus !== (runtime?.status ?? null) || |
| 564 | input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) | 570 | input.localDispatch.runtimeUpdatedAt !== (runtime?.updatedAt ?? null) |
| 565 | ); | 571 | ); |
| 566 | } | 572 | } |
| @@ -1,5 +1,4 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type ApprovalRequestId, | ||
| 3 | DEFAULT_MODEL, | 2 | DEFAULT_MODEL, |
| 4 | defaultInstanceIdForDriver, | 3 | defaultInstanceIdForDriver, |
| 5 | type EnvironmentId, | 4 | type EnvironmentId, |
| @@ -13,14 +12,15 @@ import { · 7 unmodified lines | |||
| 13 | type ResolvedKeybindingsConfig, | 12 | type ResolvedKeybindingsConfig, |
| 14 | type ScopedThreadRef, | 13 | type ScopedThreadRef, |
| 15 | type ThreadId, | 14 | type ThreadId, |
| 16 | type TurnId, | 15 | type RunId, |
| 16 | type RuntimeRequestId, | ||
| 17 | type KeybindingCommand, | 17 | type KeybindingCommand, |
| 18 | OrchestrationThreadActivity, | ||
| 19 | ProviderInteractionMode, | 18 | ProviderInteractionMode, |
| 20 | ProviderDriverKind, | 19 | ProviderDriverKind, |
| 21 | RuntimeMode, | 20 | RuntimeMode, |
| 22 | TerminalOpenInput, | 21 | TerminalOpenInput, |
| 23 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 23 | import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell"; | ||
| 24 | import { | 24 | import { |
| 25 | connectionStatusTitle, | 25 | connectionStatusTitle, |
| 26 | type EnvironmentConnectionPresentation, | 26 | type EnvironmentConnectionPresentation, |
| @@ -78,13 +78,14 @@ import { · 51 unmodified lines | |||
| 78 | derivePendingUserInputs, | 78 | derivePendingUserInputs, |
| 79 | derivePhase, | 79 | derivePhase, |
| 80 | deriveTimelineEntries, | 80 | deriveTimelineEntries, |
| 81 | deriveTimelineEntriesFromVisibleTurnItems, | ||
| 81 | deriveActiveWorkStartedAt, | 82 | deriveActiveWorkStartedAt, |
| 82 | deriveActivePlanState, | 83 | deriveActivePlanState, |
| 83 | findSidebarProposedPlan, | 84 | findSidebarProposedPlan, |
| 84 | findLatestProposedPlan, | 85 | findLatestProposedPlan, |
| 85 | deriveWorkLogEntries, | 86 | deriveWorkLogEntries, |
| 86 | hasActionableProposedPlan, | 87 | hasActionableProposedPlan, |
| 87 | isLatestTurnSettled, | 88 | isLatestRunSettled, |
| 88 | } from "../session-logic"; | 89 | } from "../session-logic"; |
| 89 | import { type LegendListRef } from "@legendapp/list/react"; | 90 | import { type LegendListRef } from "@legendapp/list/react"; |
| 90 | import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; | 91 | import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; |
| @@ -217,7 +218,7 @@ import { · 126 unmodified lines | |||
| 217 | useThread, | 218 | useThread, |
| 218 | useThreadProposedPlans, | 219 | useThreadProposedPlans, |
| 219 | useThreadRefs, | 220 | useThreadRefs, |
| 220 | useThreadShell, | 221 | useThreadVisibleTurnItems, |
| 221 | } from "../state/entities"; | 222 | } from "../state/entities"; |
| 222 | import { environmentShell } from "../state/shell"; | 223 | import { environmentShell } from "../state/shell"; |
| 223 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; | 224 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| @@ -309,7 +310,7 @@ import { useAssetUrls } from "../assets/assetUrls"; · 85 unmodified lines | |||
| 309 | 310 | ||
| 310 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 311 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 311 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 312 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 312 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 313 | const EMPTY_WORK_ENTRIES: ThreadWorkEntry[] = []; |
| 313 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 314 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 314 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | 315 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 315 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 316 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| @@ -494,10 +495,10 @@ type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "work · 178 unmodified lines | |||
| 494 | 495 | ||
| 495 | function useLocalDispatchState(input: { | 496 | function useLocalDispatchState(input: { |
| 496 | activeThread: Thread | undefined; | 497 | activeThread: Thread | undefined; |
| 497 | activeLatestTurn: Thread["latestTurn"] | null; | 498 | activeLatestRun: Thread["latestRun"] | null; |
| 498 | phase: SessionPhase; | 499 | phase: SessionPhase; |
| 499 | activePendingApproval: ApprovalRequestId | null; | 500 | activePendingApproval: RuntimeRequestId | null; |
| 500 | activePendingUserInput: ApprovalRequestId | null; | 501 | activePendingUserInput: RuntimeRequestId | null; |
| 501 | threadError: string | null | undefined; | 502 | threadError: string | null | undefined; |
| 502 | }) { | 503 | }) { |
| 503 | const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null); | 504 | const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null); |
| @@ -513,18 +514,17 @@ function useLocalDispatchState(input: { · 9 unmodified lines | |||
| 513 | hasServerAcknowledgedLocalDispatch({ | 514 | hasServerAcknowledgedLocalDispatch({ |
| 514 | localDispatch, | 515 | localDispatch, |
| 515 | phase: input.phase, | 516 | phase: input.phase, |
| 516 | latestTurn: input.activeLatestTurn, | 517 | latestRun: input.activeLatestRun, |
| 517 | latestUserMessageId, | 518 | runtime: input.activeThread?.runtime ?? null, |
| 518 | session: input.activeThread?.session ?? null, | ||
| 519 | hasPendingApproval: input.activePendingApproval !== null, | 519 | hasPendingApproval: input.activePendingApproval !== null, |
| 520 | hasPendingUserInput: input.activePendingUserInput !== null, | 520 | hasPendingUserInput: input.activePendingUserInput !== null, |
| 521 | threadError: input.threadError, | 521 | threadError: input.threadError, |
| 522 | }), | 522 | }), |
| 523 | [ | 523 | [ |
| 524 | input.activeLatestTurn, | 524 | input.activeLatestRun, |
| 525 | input.activePendingApproval, | 525 | input.activePendingApproval, |
| 526 | input.activePendingUserInput, | 526 | input.activePendingUserInput, |
| 527 | input.activeThread?.session, | 527 | input.activeThread?.runtime, |
| 528 | input.phase, | 528 | input.phase, |
| 529 | input.threadError, | 529 | input.threadError, |
| 530 | latestUserMessageId, | 530 | latestUserMessageId, |
| @@ -1202,23 +1202,10 @@ function ChatViewContent(props: ChatViewProps) { · 671 unmodified lines | |||
| 1202 | ); | 1202 | ); |
| 1203 | const composerDraftTarget: ScopedThreadRef | DraftId = | 1203 | const composerDraftTarget: ScopedThreadRef | DraftId = |
| 1204 | routeKind === "server" ? routeThreadRef : props.draftId; | 1204 | routeKind === "server" ? routeThreadRef : props.draftId; |
| 1205 | const draftThread = useComposerDraftStore((store) => | 1205 | const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); |
| 1206 | routeKind === "server" | 1206 | const serverVisibleTurnItems = useThreadVisibleTurnItems( |
| 1207 | ? store.getDraftSessionByRef(routeThreadRef) | 1207 | routeKind === "server" ? routeThreadRef : null, |
| 1208 | : draftId | ||
| 1209 | ? store.getDraftSession(draftId) | ||
| 1210 | : null, | ||
| 1211 | ); | ||
| 1212 | const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null); | ||
| 1213 | const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null }); | ||
| 1214 | const loadingServerThread = useMemo( | ||
| 1215 | () => | ||
| 1216 | threadDetailLoading && routeServerThreadShell | ||
| 1217 | ? buildLoadingThreadFromShell(routeServerThreadShell) | ||
| 1218 | : null, | ||
| 1219 | [routeServerThreadShell, threadDetailLoading], | ||
| 1220 | ); | 1208 | ); |
| 1221 | const activeServerThread = serverThread ?? loadingServerThread; | ||
| 1222 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); | 1209 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 1223 | const activeThreadLastVisitedAt = useUiStateStore( | 1210 | const activeThreadLastVisitedAt = useUiStateStore( |
| 1224 | (store) => store.threadLastVisitedAtById[routeThreadKey], | 1211 | (store) => store.threadLastVisitedAtById[routeThreadKey], |
| @@ -1293,9 +1280,9 @@ function ChatViewContent(props: ChatViewProps) { · 68 unmodified lines | |||
| 1293 | const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>( | 1280 | const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>( |
| 1294 | null, | 1281 | null, |
| 1295 | ); | 1282 | ); |
| 1296 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); | 1283 | const [respondingRequestIds, setRespondingRequestIds] = useState<RuntimeRequestId[]>([]); |
| 1297 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< | 1284 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< |
| 1298 | ApprovalRequestId[] | 1285 | RuntimeRequestId[] |
| 1299 | >([]); | 1286 | >([]); |
| 1300 | const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< | 1287 | const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< |
| 1301 | Record<string, Record<string, PendingUserInputDraftAnswer>> | 1288 | Record<string, Record<string, PendingUserInputDraftAnswer>> |
| @@ -1454,7 +1441,7 @@ function ChatViewContent(props: ChatViewProps) { · 152 unmodified lines | |||
| 1454 | const isServerThread = activeServerThread !== null; | 1441 | const isServerThread = activeServerThread !== null; |
| 1455 | const activeThread = activeServerThread ?? localDraftThread; | 1442 | const activeThread = activeServerThread ?? localDraftThread; |
| 1456 | const threadError = isServerThread | 1443 | const threadError = isServerThread |
| 1457 | ? (localServerError ?? activeServerThread?.session?.lastError ?? null) | 1444 | ? (localServerError ?? serverThread?.runtime?.lastError ?? null) |
| 1458 | : localDraftError; | 1445 | : localDraftError; |
| 1459 | const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 1446 | const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 1460 | const interactionMode = | 1447 | const interactionMode = |
| @@ -1576,14 +1563,14 @@ function ChatViewContent(props: ChatViewProps) { · 115 unmodified lines | |||
| 1576 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | 1563 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1577 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | 1564 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| 1578 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); | 1565 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); |
| 1579 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 1566 | const activeLatestRun = activeThread?.latestRun ?? null; |
| 1580 | const sourcePlanThreadRef = useMemo(() => { | 1567 | const sourcePlanThreadRef = useMemo(() => { |
| 1581 | const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; | 1568 | const sourceThreadId = activeLatestRun?.sourcePlanRef?.threadId; |
| 1582 | if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { | 1569 | if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { |
| 1583 | return null; | 1570 | return null; |
| 1584 | } | 1571 | } |
| 1585 | return scopeThreadRef(activeThread.environmentId, sourceThreadId); | 1572 | return scopeThreadRef(activeThread.environmentId, sourceThreadId); |
| 1586 | }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); | 1573 | }, [activeLatestRun?.sourcePlanRef?.threadId, activeThread]); |
| 1587 | const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); | 1574 | const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); |
| 1588 | const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => { | 1575 | const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => { |
| 1589 | if (!activeThread) { | 1576 | if (!activeThread) { |
| @@ -1615,7 +1602,7 @@ function ChatViewContent(props: ChatViewProps) { · 25 unmodified lines | |||
| 1615 | : nextThreadIds; | 1602 | : nextThreadIds; |
| 1616 | }); | 1603 | }); |
| 1617 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); | 1604 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); |
| 1618 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 1605 | const latestRunSettled = isLatestRunSettled(activeLatestRun, activeThread?.runtime ?? null); |
| 1619 | const activeProjectRef = activeThread | 1606 | const activeProjectRef = activeThread |
| 1620 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | 1607 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| 1621 | : null; | 1608 | : null; |
| @@ -2047,16 +2034,19 @@ function ChatViewContent(props: ChatViewProps) { · 425 unmodified lines | |||
| 2047 | selectedProviderByThreadId ?? threadProvider, | 2034 | selectedProviderByThreadId ?? threadProvider, |
| 2048 | ); | 2035 | ); |
| 2049 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; | 2036 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; |
| 2050 | const phase = derivePhase(activeThread?.session ?? null); | 2037 | const phase = derivePhase(activeThread?.runtime ?? null); |
| 2051 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; | 2038 | const threadWorkEntries = activeThread?.workEntries ?? EMPTY_WORK_ENTRIES; |
| 2052 | const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); | 2039 | const workLogEntries = useMemo( |
| 2040 | () => deriveWorkLogEntries(threadWorkEntries), | ||
| 2041 | [threadWorkEntries], | ||
| 2042 | ); | ||
| 2053 | const pendingApprovals = useMemo( | 2043 | const pendingApprovals = useMemo( |
| 2054 | () => derivePendingApprovals(threadActivities), | 2044 | () => derivePendingApprovals(activeThread?.pendingApprovals ?? []), |
| 2055 | [threadActivities], | 2045 | [activeThread?.pendingApprovals], |
| 2056 | ); | 2046 | ); |
| 2057 | const pendingUserInputs = useMemo( | 2047 | const pendingUserInputs = useMemo( |
| 2058 | () => derivePendingUserInputs(threadActivities), | 2048 | () => derivePendingUserInputs(activeThread?.pendingUserInputs ?? []), |
| 2059 | [threadActivities], | 2049 | [activeThread?.pendingUserInputs], |
| 2060 | ); | 2050 | ); |
| 2061 | const activePendingUserInput = pendingUserInputs[0] ?? null; | 2051 | const activePendingUserInput = pendingUserInputs[0] ?? null; |
| 2062 | const activePendingDraftAnswers = useMemo( | 2052 | const activePendingDraftAnswers = useMemo( |
| @@ -2089,36 +2079,37 @@ function ChatViewContent(props: ChatViewProps) { · 26 unmodified lines | |||
| 2089 | [activePendingDraftAnswers, activePendingUserInput], | 2079 | [activePendingDraftAnswers, activePendingUserInput], |
| 2090 | ); | 2080 | ); |
| 2091 | const activePendingIsResponding = activePendingUserInput | 2081 | const activePendingIsResponding = activePendingUserInput |
| 2092 | ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) | 2082 | ? activePendingUserInput.responseCapability !== "live" || |
| 2083 | respondingUserInputRequestIds.includes(activePendingUserInput.requestId) | ||
| 2093 | : false; | 2084 | : false; |
| 2094 | const activeProposedPlan = useMemo(() => { | 2085 | const activeProposedPlan = useMemo(() => { |
| 2095 | if (!latestTurnSettled) { | 2086 | if (!latestRunSettled) { |
| 2096 | return null; | 2087 | return null; |
| 2097 | } | 2088 | } |
| 2098 | return findLatestProposedPlan( | 2089 | return findLatestProposedPlan( |
| 2099 | activeThread?.proposedPlans ?? [], | 2090 | activeThread?.proposedPlans ?? [], |
| 2100 | activeLatestTurn?.turnId ?? null, | 2091 | activeLatestRun?.runId ?? null, |
| 2101 | ); | 2092 | ); |
| 2102 | }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); | 2093 | }, [activeLatestRun?.runId, activeThread?.proposedPlans, latestRunSettled]); |
| 2103 | const sidebarProposedPlan = useMemo( | 2094 | const sidebarProposedPlan = useMemo( |
| 2104 | () => | 2095 | () => |
| 2105 | findSidebarProposedPlan({ | 2096 | findSidebarProposedPlan({ |
| 2106 | threads: threadPlanCatalog, | 2097 | threads: threadPlanCatalog, |
| 2107 | latestTurn: activeLatestTurn, | 2098 | latestRun: activeLatestRun, |
| 2108 | latestTurnSettled, | 2099 | latestRunSettled, |
| 2109 | threadId: activeThread?.id ?? null, | 2100 | threadId: activeThread?.id ?? null, |
| 2110 | }), | 2101 | }), |
| 2111 | [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], | 2102 | [activeLatestRun, activeThread?.id, latestRunSettled, threadPlanCatalog], |
| 2112 | ); | 2103 | ); |
| 2113 | const activePlan = useMemo( | 2104 | const activePlan = useMemo( |
| 2114 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), | 2105 | () => deriveActivePlanState(activeThread?.todoPlans ?? [], activeLatestRun?.runId ?? undefined), |
| 2115 | [activeLatestTurn?.turnId, threadActivities], | 2106 | [activeLatestRun?.runId, activeThread?.todoPlans], |
| 2116 | ); | 2107 | ); |
| 2117 | const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; | 2108 | const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; |
| 2118 | const showPlanFollowUpPrompt = | 2109 | const showPlanFollowUpPrompt = |
| 2119 | pendingUserInputs.length === 0 && | 2110 | pendingUserInputs.length === 0 && |
| 2120 | interactionMode === "plan" && | 2111 | interactionMode === "plan" && |
| 2121 | latestTurnSettled && | 2112 | latestRunSettled && |
| 2122 | hasActionableProposedPlan(activeProposedPlan); | 2113 | hasActionableProposedPlan(activeProposedPlan); |
| 2123 | const activePendingApproval = pendingApprovals[0] ?? null; | 2114 | const activePendingApproval = pendingApprovals[0] ?? null; |
| 2124 | const { | 2115 | const { |
| @@ -2129,7 +2120,7 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines | |||
| 2129 | isSendBusy, | 2120 | isSendBusy, |
| 2130 | } = useLocalDispatchState({ | 2121 | } = useLocalDispatchState({ |
| 2131 | activeThread, | 2122 | activeThread, |
| 2132 | activeLatestTurn, | 2123 | activeLatestRun, |
| 2133 | phase, | 2124 | phase, |
| 2134 | activePendingApproval: activePendingApproval?.requestId ?? null, | 2125 | activePendingApproval: activePendingApproval?.requestId ?? null, |
| 2135 | activePendingUserInput: activePendingUserInput?.requestId ?? null, | 2126 | activePendingUserInput: activePendingUserInput?.requestId ?? null, |
| @@ -2137,8 +2128,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | |||
| 2137 | }); | 2128 | }); |
| 2138 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; | 2129 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; |
| 2139 | const activeWorkStartedAt = deriveActiveWorkStartedAt( | 2130 | const activeWorkStartedAt = deriveActiveWorkStartedAt( |
| 2140 | activeLatestTurn, | 2131 | activeLatestRun, |
| 2141 | activeThread?.session ?? null, | 2132 | activeThread?.runtime ?? null, |
| 2142 | localDispatchStartedAt, | 2133 | localDispatchStartedAt, |
| 2143 | ); | 2134 | ); |
| 2144 | useEffect(() => { | 2135 | useEffect(() => { |
| @@ -2202,7 +2193,17 @@ function ChatViewContent(props: ChatViewProps) { · 57 unmodified lines | |||
| 2202 | }); | 2193 | }); |
| 2203 | }, []); | 2194 | }, []); |
| 2204 | const serverMessages = activeThread?.messages; | 2195 | const serverMessages = activeThread?.messages; |
| 2205 | const serverAttachmentIds = useMemo(() => { | 2196 | const committedServerAttachmentIds = useMemo(() => { |
| 2197 | const attachmentIds = new Set<string>(); | ||
| 2198 | for (const row of serverVisibleTurnItems) { | ||
| 2199 | if (row.item.type !== "user_message") continue; | ||
| 2200 | for (const attachment of row.item.attachments) { | ||
| 2201 | attachmentIds.add(attachment.id); | ||
| 2202 | } | ||
| 2203 | } | ||
| 2204 | return [...attachmentIds]; | ||
| 2205 | }, [serverVisibleTurnItems]); | ||
| 2206 | const draftAttachmentIds = useMemo(() => { | ||
| 2206 | const attachmentIds = new Set<string>(); | 2207 | const attachmentIds = new Set<string>(); |
| 2207 | for (const message of serverMessages ?? []) { | 2208 | for (const message of serverMessages ?? []) { |
| 2208 | for (const attachment of message.attachments ?? []) { | 2209 | for (const attachment of message.attachments ?? []) { |
| @@ -2211,6 +2212,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines | |||
| 2211 | } | 2212 | } |
| 2212 | return [...attachmentIds]; | 2213 | return [...attachmentIds]; |
| 2213 | }, [serverMessages]); | 2214 | }, [serverMessages]); |
| 2215 | const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : draftAttachmentIds; | ||
| 2214 | const serverAttachmentResources = useMemo( | 2216 | const serverAttachmentResources = useMemo( |
| 2215 | () => | 2217 | () => |
| 2216 | serverAttachmentIds.map((attachmentId) => ({ | 2218 | serverAttachmentIds.map((attachmentId) => ({ |
| @@ -2381,22 +2383,22 @@ function ChatViewContent(props: ChatViewProps) { · 164 unmodified lines | |||
| 2381 | } | 2383 | } |
| 2382 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; | 2384 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 2383 | }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); | 2385 | }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); |
| 2384 | const timelineEntries = useMemo( | 2386 | const serverTimelineEntries = useMemo( |
| 2387 | () => | ||
| 2388 | deriveTimelineEntriesFromVisibleTurnItems({ | ||
| 2389 | visibleTurnItems: serverVisibleTurnItems, | ||
| 2390 | optimisticMessages: optimisticUserMessages, | ||
| 2391 | attachmentUrlById: serverAttachmentUrlById, | ||
| 2392 | }), | ||
| 2393 | [optimisticUserMessages, serverVisibleTurnItems, serverAttachmentUrlById], | ||
| 2394 | ); | ||
| 2395 | const draftTimelineEntries = useMemo( | ||
| 2385 | () => | 2396 | () => |
| 2386 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), | 2397 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| 2387 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], | 2398 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], |
| 2388 | ); | 2399 | ); |
| 2389 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); | 2400 | const timelineEntries = isServerThread ? serverTimelineEntries : draftTimelineEntries; |
| 2390 | const draftHeroDockRequested = | 2401 | const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } = |
| 2391 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; | ||
| 2392 | const isDraftHeroState = | ||
| 2393 | isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; | ||
| 2394 | const [ | ||
| 2395 | attachDraftHeroTransitionGroupRef, | ||
| 2396 | attachDraftHeroComposerAnchorRef, | ||
| 2397 | captureDraftHeroComposerRect, | ||
| 2398 | ] = useDraftHeroLayoutTransition(isDraftHeroState); | ||
| 2399 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | ||
| 2400 | useTurnDiffSummaries(activeThread); | 2402 | useTurnDiffSummaries(activeThread); |
| 2401 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { | 2403 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 2402 | const byMessageId = new Map<MessageId, TurnDiffSummary>(); | 2404 | const byMessageId = new Map<MessageId, TurnDiffSummary>(); |
| @@ -2427,7 +2429,7 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines | |||
| 2427 | continue; | 2429 | continue; |
| 2428 | } | 2430 | } |
| 2429 | const turnCount = | 2431 | const turnCount = |
| 2430 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; | 2432 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[summary.runId]; |
| 2431 | if (typeof turnCount !== "number") { | 2433 | if (typeof turnCount !== "number") { |
| 2432 | break; | 2434 | break; |
| 2433 | } | 2435 | } |
| @@ -2437,7 +2439,7 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | |||
| 2437 | } | 2439 | } |
| 2438 | 2440 | ||
| 2439 | return byUserMessageId; | 2441 | return byUserMessageId; |
| 2440 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); | 2442 | }, [inferredCheckpointTurnCountByRunId, timelineEntries, turnDiffSummaryByAssistantMessageId]); |
| 2441 | 2443 | ||
| 2442 | const gitCwd = activeProject | 2444 | const gitCwd = activeProject |
| 2443 | ? projectScriptCwd({ | 2445 | ? projectScriptCwd({ |
| @@ -2465,7 +2467,7 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines | |||
| 2465 | ?.instanceId ?? null; | 2467 | ?.instanceId ?? null; |
| 2466 | const activeProviderInstanceId = | 2468 | const activeProviderInstanceId = |
| 2467 | selectedProviderInstanceId ?? | 2469 | selectedProviderInstanceId ?? |
| 2468 | activeThread?.session?.providerInstanceId ?? | 2470 | activeThread?.runtime?.providerInstanceId ?? |
| 2469 | activeThread?.modelSelection.instanceId ?? | 2471 | activeThread?.modelSelection.instanceId ?? |
| 2470 | activeProject?.defaultModelSelection?.instanceId ?? | 2472 | activeProject?.defaultModelSelection?.instanceId ?? |
| 2471 | null; | 2473 | null; |
| @@ -2544,9 +2546,7 @@ function ChatViewContent(props: ChatViewProps) { · 72 unmodified lines | |||
| 2544 | }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); | 2546 | }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); |
| 2545 | 2547 | ||
| 2546 | const envLocked = Boolean( | 2548 | const envLocked = Boolean( |
| 2547 | activeThread && | 2549 | activeThread && (activeThread.messages.length > 0 || activeThread.runtime !== null), |
| 2548 | (activeThread.messages.length > 0 || | ||
| 2549 | (activeThread.session !== null && activeThread.session.status !== "stopped")), | ||
| 2550 | ); | 2550 | ); |
| 2551 | 2551 | ||
| 2552 | // Handle environment change for draft threads. When the user picks a | 2552 | // Handle environment change for draft threads. When the user picks a |
| @@ -3092,8 +3092,8 @@ function ChatViewContent(props: ChatViewProps) { · 539 unmodified lines | |||
| 3092 | }, [handleInteractionModeChange, interactionMode]); | 3092 | }, [handleInteractionModeChange, interactionMode]); |
| 3093 | const dismissPlanSidebarForCurrentTurn = useCallback(() => { | 3093 | const dismissPlanSidebarForCurrentTurn = useCallback(() => { |
| 3094 | planSidebarDismissedForTurnRef.current = | 3094 | planSidebarDismissedForTurnRef.current = |
| 3095 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | 3095 | activePlan?.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__"; |
| 3096 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | 3096 | }, [activePlan?.runId, sidebarProposedPlan?.runId]); |
| 3097 | const togglePlanSidebar = useCallback(() => { | 3097 | const togglePlanSidebar = useCallback(() => { |
| 3098 | if (!activeThreadRef) return; | 3098 | if (!activeThreadRef) return; |
| 3099 | if (planSidebarOpen) { | 3099 | if (planSidebarOpen) { |
| @@ -3842,20 +3842,20 @@ function ChatViewContent(props: ChatViewProps) { · 742 unmodified lines | |||
| 3842 | if (!autoOpenPlanSidebar) return; | 3842 | if (!autoOpenPlanSidebar) return; |
| 3843 | if (!activePlan) return; | 3843 | if (!activePlan) return; |
| 3844 | if (planSidebarOpen) return; | 3844 | if (planSidebarOpen) return; |
| 3845 | const latestTurnId = activeLatestTurn?.turnId ?? null; | 3845 | const latestRunId = activeLatestRun?.runId ?? null; |
| 3846 | if (latestTurnId && activePlan.turnId !== latestTurnId) return; | 3846 | if (latestRunId && activePlan.runId !== latestRunId) return; |
| 3847 | const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | 3847 | const turnKey = activePlan.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__"; |
| 3848 | if (planSidebarDismissedForTurnRef.current === turnKey) return; | 3848 | if (planSidebarDismissedForTurnRef.current === turnKey) return; |
| 3849 | if (activeThreadRef) { | 3849 | if (activeThreadRef) { |
| 3850 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | 3850 | useRightPanelStore.getState().open(activeThreadRef, "plan"); |
| 3851 | } | 3851 | } |
| 3852 | }, [ | 3852 | }, [ |
| 3853 | activePlan, | 3853 | activePlan, |
| 3854 | activeLatestTurn?.turnId, | 3854 | activeLatestRun?.runId, |
| 3855 | activeThreadRef, | 3855 | activeThreadRef, |
| 3856 | autoOpenPlanSidebar, | 3856 | autoOpenPlanSidebar, |
| 3857 | planSidebarOpen, | 3857 | planSidebarOpen, |
| 3858 | sidebarProposedPlan?.turnId, | 3858 | sidebarProposedPlan?.runId, |
| 3859 | ]); | 3859 | ]); |
| 3860 | 3860 | ||
| 3861 | useEffect(() => { | 3861 | useEffect(() => { |
| @@ -4753,7 +4753,7 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines | |||
| 4753 | role: "user", | 4753 | role: "user", |
| 4754 | text: outgoingMessageText, | 4754 | text: outgoingMessageText, |
| 4755 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), | 4755 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), |
| 4756 | turnId: null, | 4756 | runId: null, |
| 4757 | createdAt: messageCreatedAt, | 4757 | createdAt: messageCreatedAt, |
| 4758 | updatedAt: messageCreatedAt, | 4758 | updatedAt: messageCreatedAt, |
| 4759 | streaming: false, | 4759 | streaming: false, |
| @@ -4966,8 +4966,13 @@ function ChatViewContent(props: ChatViewProps) { · 206 unmodified lines | |||
| 4966 | }; | 4966 | }; |
| 4967 | 4967 | ||
| 4968 | const onRespondToApproval = useCallback( | 4968 | const onRespondToApproval = useCallback( |
| 4969 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | 4969 | async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { |
| 4970 | if (!activeThreadId) return; | 4970 | if (!activeThreadId) return; |
| 4971 | if ( | ||
| 4972 | pendingApprovals.find((approval) => approval.requestId === requestId) | ||
| 4973 | ?.responseCapability !== "live" | ||
| 4974 | ) | ||
| 4975 | return; | ||
| 4971 | 4976 | ||
| 4972 | setRespondingRequestIds((existing) => | 4977 | setRespondingRequestIds((existing) => |
| 4973 | existing.includes(requestId) ? existing : [...existing, requestId], | 4978 | existing.includes(requestId) ? existing : [...existing, requestId], |
| @@ -4990,12 +4995,17 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines | |||
| 4990 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 4995 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 4991 | return result; | 4996 | return result; |
| 4992 | }, | 4997 | }, |
| 4993 | [activeThreadId, environmentId, respondToThreadApproval, setThreadError], | 4998 | [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError], |
| 4994 | ); | 4999 | ); |
| 4995 | 5000 | ||
| 4996 | const onRespondToUserInput = useCallback( | 5001 | const onRespondToUserInput = useCallback( |
| 4997 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { | 5002 | async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => { |
| 4998 | if (!activeThreadId) return; | 5003 | if (!activeThreadId) return; |
| 5004 | if ( | ||
| 5005 | pendingUserInputs.find((input) => input.requestId === requestId)?.responseCapability !== | ||
| 5006 | "live" | ||
| 5007 | ) | ||
| 5008 | return; | ||
| 4999 | 5009 | ||
| 5000 | setRespondingUserInputRequestIds((existing) => | 5010 | setRespondingUserInputRequestIds((existing) => |
| 5001 | existing.includes(requestId) ? existing : [...existing, requestId], | 5011 | existing.includes(requestId) ? existing : [...existing, requestId], |
| @@ -5018,7 +5028,7 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines | |||
| 5018 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); | 5028 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 5019 | return result; | 5029 | return result; |
| 5020 | }, | 5030 | }, |
| 5021 | [activeThreadId, environmentId, respondToThreadUserInput, setThreadError], | 5031 | [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError], |
| 5022 | ); | 5032 | ); |
| 5023 | 5033 | ||
| 5024 | const setActivePendingUserInputQuestionIndex = useCallback( | 5034 | const setActivePendingUserInputQuestionIndex = useCallback( |
| @@ -5102,7 +5112,11 @@ function ChatViewContent(props: ChatViewProps) { · 77 unmodified lines | |||
| 5102 | ); | 5112 | ); |
| 5103 | 5113 | ||
| 5104 | const onAdvanceActivePendingUserInput = useCallback(() => { | 5114 | const onAdvanceActivePendingUserInput = useCallback(() => { |
| 5105 | if (!activePendingUserInput || !activePendingProgress) { | 5115 | if ( |
| 5116 | !activePendingUserInput || | ||
| 5117 | activePendingUserInput.responseCapability !== "live" || | ||
| 5118 | !activePendingProgress | ||
| 5119 | ) { | ||
| 5106 | return; | 5120 | return; |
| 5107 | } | 5121 | } |
| 5108 | if (activePendingProgress.isLastQuestion) { | 5122 | if (activePendingProgress.isLastQuestion) { |
| @@ -5196,7 +5210,7 @@ function ChatViewContent(props: ChatViewProps) { · 87 unmodified lines | |||
| 5196 | id: messageIdForSend, | 5210 | id: messageIdForSend, |
| 5197 | role: "user", | 5211 | role: "user", |
| 5198 | text: outgoingMessageText, | 5212 | text: outgoingMessageText, |
| 5199 | turnId: null, | 5213 | runId: null, |
| 5200 | createdAt: messageCreatedAt, | 5214 | createdAt: messageCreatedAt, |
| 5201 | updatedAt: messageCreatedAt, | 5215 | updatedAt: messageCreatedAt, |
| 5202 | streaming: false, | 5216 | streaming: false, |
| @@ -5466,9 +5480,9 @@ function ChatViewContent(props: ChatViewProps) { · 263 unmodified lines | |||
| 5466 | } | 5480 | } |
| 5467 | const reason = getStartedThreadModelChangeBlockReason({ | 5481 | const reason = getStartedThreadModelChangeBlockReason({ |
| 5468 | providers: providerStatuses, | 5482 | providers: providerStatuses, |
| 5469 | hasStartedSession: activeThread.session !== null, | 5483 | hasStartedSession: activeThread.runtime !== null, |
| 5470 | currentModelSelection: activeThread.modelSelection, | 5484 | currentModelSelection: activeThread.modelSelection, |
| 5471 | currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, | 5485 | currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null, |
| 5472 | nextModelSelection: { instanceId, model }, | 5486 | nextModelSelection: { instanceId, model }, |
| 5473 | }); | 5487 | }); |
| 5474 | return reason ? `${reason.description} Start a new thread to use this model.` : null; | 5488 | return reason ? `${reason.description} Start a new thread to use this model.` : null; |
| @@ -5492,9 +5506,9 @@ function ChatViewContent(props: ChatViewProps) { · 17 unmodified lines | |||
| 5492 | scheduleComposerFocus(); | 5506 | scheduleComposerFocus(); |
| 5493 | return; | 5507 | return; |
| 5494 | } | 5508 | } |
| 5495 | if (lockedProvider !== null && activeThread.session?.providerInstanceId) { | 5509 | if (lockedProvider !== null && activeThread.runtime?.providerInstanceId) { |
| 5496 | const currentEntry = providerStatuses.find( | 5510 | const currentEntry = providerStatuses.find( |
| 5497 | (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, | 5511 | (snapshot) => snapshot.instanceId === activeThread.runtime?.providerInstanceId, |
| 5498 | ); | 5512 | ); |
| 5499 | if ( | 5513 | if ( |
| 5500 | currentEntry?.continuation?.groupKey && | 5514 | currentEntry?.continuation?.groupKey && |
| @@ -5521,9 +5535,9 @@ function ChatViewContent(props: ChatViewProps) { · 20 unmodified lines | |||
| 5521 | }; | 5535 | }; |
| 5522 | const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ | 5536 | const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ |
| 5523 | providers: providerStatuses, | 5537 | providers: providerStatuses, |
| 5524 | hasStartedSession: activeThread.session !== null, | 5538 | hasStartedSession: activeThread.runtime !== null, |
| 5525 | currentModelSelection: activeThread.modelSelection, | 5539 | currentModelSelection: activeThread.modelSelection, |
| 5526 | currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, | 5540 | currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null, |
| 5527 | nextModelSelection, | 5541 | nextModelSelection, |
| 5528 | }); | 5542 | }); |
| 5529 | if (modelChangeBlockReason) { | 5543 | if (modelChangeBlockReason) { |
| @@ -5603,9 +5617,9 @@ function ChatViewContent(props: ChatViewProps) { · 73 unmodified lines | |||
| 5603 | setExpandedImage(preview); | 5617 | setExpandedImage(preview); |
| 5604 | }, []); | 5618 | }, []); |
| 5605 | const onOpenTurnDiff = useCallback( | 5619 | const onOpenTurnDiff = useCallback( |
| 5606 | (turnId: TurnId, filePath?: string) => { | 5620 | (runId: RunId, filePath?: string) => { |
| 5607 | if (!isServerThread || !activeThreadRef) return; | 5621 | if (!isServerThread || !activeThreadRef) return; |
| 5608 | useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); | 5622 | useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); |
| 5609 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | 5623 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 5610 | onDiffPanelOpen?.(); | 5624 | onDiffPanelOpen?.(); |
| 5611 | }, | 5625 | }, |
| @@ -5800,16 +5814,11 @@ function ChatViewContent(props: ChatViewProps) { · 188 unmodified lines | |||
| 5800 | <MessagesTimeline | 5814 | <MessagesTimeline |
| 5801 | key={activeThread.id} | 5815 | key={activeThread.id} |
| 5802 | isWorking={isWorking} | 5816 | isWorking={isWorking} |
| 5803 | activeTurnInProgress={isWorking || !latestTurnSettled} | 5817 | activeTurnInProgress={isWorking || !latestRunSettled} |
| 5804 | activeTurnStartedAt={activeWorkStartedAt} | 5818 | activeTurnStartedAt={activeWorkStartedAt} |
| 5805 | listRef={legendListRef} | 5819 | listRef={legendListRef} |
| 5806 | timelineEntries={timelineEntries} | 5820 | timelineEntries={timelineEntries} |
| 5807 | latestTurn={activeLatestTurn} | 5821 | latestRun={activeLatestRun} |
| 5808 | runningTurnId={ | ||
| 5809 | activeThread.session?.status === "running" | ||
| 5810 | ? activeThread.session.activeTurnId | ||
| 5811 | : null | ||
| 5812 | } | ||
| 5813 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} | 5822 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 5814 | activeThreadEnvironmentId={activeThread.environmentId} | 5823 | activeThreadEnvironmentId={activeThread.environmentId} |
| 5815 | routeThreadKey={routeThreadKey} | 5824 | routeThreadKey={routeThreadKey} |
| @@ -5867,168 +5876,86 @@ function ChatViewContent(props: ChatViewProps) { · 51 unmodified lines | |||
| 5867 | ref={attachDraftHeroTransitionGroupRef} | 5876 | ref={attachDraftHeroTransitionGroupRef} |
| 5868 | className="chat-composer-horizontal-inset w-full" | 5877 | className="chat-composer-horizontal-inset w-full" |
| 5869 | > | 5878 | > |
| 5870 | <div className="pointer-events-auto relative z-10"> | 5879 | <div className="relative mx-auto h-full w-full max-w-208 overflow-clip rounded-t-[20px]"> |
| 5871 | {isDraftHeroState ? ( | 5880 | <div className="chat-composer-shared-blur absolute -inset-8" /> |
| 5872 | <div className="absolute inset-x-0 bottom-full z-0"> | 5881 | </div> |
| 5873 | <div | 5882 | </div> |
| 5874 | className="pb-8" | 5883 | <div className="chat-composer-horizontal-inset"> |
| 5875 | style={ | 5884 | <div className="pointer-events-auto relative z-10 isolate"> |
| 5876 | forceExpandedMobileComposer | 5885 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> |
| 5877 | ? { | 5886 | <div className="relative z-10"> |
| 5878 | viewTransitionName: MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, | 5887 | <ChatComposer |
| 5879 | } | 5888 | composerRef={composerRef} |
| 5880 | : undefined | 5889 | composerDraftTarget={composerDraftTarget} |
| 5881 | } | 5890 | environmentId={environmentId} |
| 5882 | > | 5891 | routeKind={routeKind} |
| 5883 | <DraftHeroHeadline | 5892 | routeThreadRef={routeThreadRef} |
| 5884 | activeProjectRef={activeProjectRef} | 5893 | draftId={draftId} |
| 5885 | activeProjectTitle={activeProject?.title ?? null} | 5894 | activeThreadId={activeThreadId} |
| 5886 | /> | 5895 | activeThreadEnvironmentId={activeThread?.environmentId} |
| 5887 | </div> | 5896 | activeThread={activeThread} |
| 5888 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> | 5897 | isServerThread={isServerThread} |
| 5889 | </div> | 5898 | isLocalDraftThread={isLocalDraftThread} |
| 5890 | ) : ( | 5899 | phase={phase} |
| 5891 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> | 5900 | isConnecting={isConnecting} |
| 5892 | )} | 5901 | isSendBusy={isSendBusy} |
| 5893 | {threadSyncPhase && !activeEnvironmentUnavailable ? ( | 5902 | isPreparingWorktree={isPreparingWorktree} |
| 5894 | <ThreadSyncStatusPill phase={threadSyncPhase} /> | 5903 | environmentUnavailable={activeEnvironmentUnavailableState} |
| 5895 | ) : null} | 5904 | activePendingApproval={activePendingApproval} |
| 5896 | <div | 5905 | pendingApprovals={pendingApprovals} |
| 5897 | className="relative" | 5906 | pendingUserInputs={pendingUserInputs} |
| 5898 | style={ | 5907 | activePendingProgress={activePendingProgress} |
| 5899 | forceExpandedMobileComposer | 5908 | activePendingResolvedAnswers={activePendingResolvedAnswers} |
| 5900 | ? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME } | 5909 | activePendingIsResponding={activePendingIsResponding} |
| 5901 | : undefined | 5910 | activePendingDraftAnswers={activePendingDraftAnswers} |
| 5902 | } | 5911 | activePendingQuestionIndex={activePendingQuestionIndex} |
| 5903 | > | 5912 | respondingRequestIds={respondingRequestIds} |
| 5904 | <div | 5913 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} |
| 5905 | className={cn( | 5914 | activeProposedPlan={activeProposedPlan} |
| 5906 | "chat-composer-glass-shell relative mx-auto w-full max-w-3xl", | 5915 | activePlan={activePlan} |
| 5907 | showComposerContextStrip && "chat-composer-glass-shell-with-context", | 5916 | sidebarProposedPlan={sidebarProposedPlan} |
| 5908 | )} | 5917 | planSidebarLabel={planSidebarLabel} |
| 5909 | > | 5918 | planSidebarOpen={planSidebarOpen} |
| 5910 | <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]"> | 5919 | runtimeMode={runtimeMode} |
| 5911 | <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10"> | 5920 | interactionMode={interactionMode} |
| 5912 | <ChatComposer | 5921 | lockedProvider={lockedProvider} |
| 5913 | composerRef={composerRef} | 5922 | providerStatuses={providerStatuses as ServerProvider[]} |
| 5914 | composerDraftTarget={composerDraftTarget} | 5923 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} |
| 5915 | environmentId={environmentId} | 5924 | activeThreadModelSelection={activeThread?.modelSelection} |
| 5916 | routeKind={routeKind} | 5925 | activeThreadWorkEntries={activeThread?.workEntries} |
| 5917 | routeThreadRef={routeThreadRef} | 5926 | resolvedTheme={resolvedTheme} |
| 5918 | draftId={draftId} | 5927 | settings={settings} |
| 5919 | activeThreadId={activeThreadId} | 5928 | keybindings={keybindings} |
| 5920 | activeThreadEnvironmentId={activeThread?.environmentId} | 5929 | terminalOpen={Boolean(terminalUiState.terminalOpen)} |
| 5921 | activeThread={activeThread} | 5930 | gitCwd={gitCwd} |
| 5922 | isServerThread={isServerThread} | 5931 | promptRef={promptRef} |
| 5923 | isLocalDraftThread={isLocalDraftThread} | 5932 | composerImagesRef={composerImagesRef} |
| 5924 | forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} | 5933 | composerTerminalContextsRef={composerTerminalContextsRef} |
| 5925 | projectSelectionRequired={isLocalDraftThread && activeProject === null} | 5934 | composerElementContextsRef={composerElementContextsRef} |
| 5926 | phase={phase} | 5935 | shouldAutoScrollRef={isAtEndRef} |
| 5927 | isConnecting={isConnecting} | 5936 | scheduleStickToBottom={scrollToEnd} |
| 5928 | isSendBusy={isSendBusy} | 5937 | onSend={onSend} |
| 5929 | sendDisabledReason={threadDetailLoading ? "Messages loading" : null} | 5938 | onInterrupt={onInterrupt} |
| 5930 | isPreparingWorktree={isPreparingWorktree} | 5939 | onImplementPlanInNewThread={onImplementPlanInNewThread} |
| 5931 | environmentUnavailable={activeEnvironmentUnavailableState} | 5940 | onRespondToApproval={onRespondToApproval} |
| 5932 | activePendingApproval={activePendingApproval} | 5941 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} |
| 5933 | pendingApprovals={pendingApprovals} | 5942 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} |
| 5934 | pendingUserInputs={pendingUserInputs} | 5943 | onPreviousActivePendingUserInputQuestion={ |
| 5935 | activePendingProgress={activePendingProgress} | 5944 | onPreviousActivePendingUserInputQuestion |
| 5936 | activePendingResolvedAnswers={activePendingResolvedAnswers} | 5945 | } |
| 5937 | activePendingIsResponding={activePendingIsResponding} | 5946 | onChangeActivePendingUserInputCustomAnswer={ |
| 5938 | activePendingDraftAnswers={activePendingDraftAnswers} | 5947 | onChangeActivePendingUserInputCustomAnswer |
| 5939 | activePendingQuestionIndex={activePendingQuestionIndex} | 5948 | } |
| 5940 | respondingRequestIds={respondingRequestIds} | 5949 | onProviderModelSelect={onProviderModelSelect} |
| 5941 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} | 5950 | getModelDisabledReason={getModelDisabledReason} |
| 5942 | activeProposedPlan={activeProposedPlan} | 5951 | toggleInteractionMode={toggleInteractionMode} |
| 5943 | activePlan={activePlan as { turnId?: TurnId } | null} | 5952 | handleRuntimeModeChange={handleRuntimeModeChange} |
| 5944 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | 5953 | handleInteractionModeChange={handleInteractionModeChange} |
| 5945 | planSidebarLabel={planSidebarLabel} | 5954 | togglePlanSidebar={togglePlanSidebar} |
| 5946 | planSidebarOpen={planSidebarOpen} | 5955 | focusComposer={focusComposer} |
| 5947 | runtimeMode={runtimeMode} | 5956 | scheduleComposerFocus={scheduleComposerFocus} |
| 5948 | interactionMode={interactionMode} | 5957 | setThreadError={setThreadError} |
| 5949 | lockedProvider={lockedProvider} | 5958 | onExpandImage={onExpandTimelineImage} |
| 5950 | providerStatuses={providerStatuses as ServerProvider[]} | ||
| 5951 | activeProjectDefaultModelSelection={ | ||
| 5952 | activeProject?.defaultModelSelection | ||
| 5953 | } | ||
| 5954 | activeThreadModelSelection={activeThread?.modelSelection} | ||
| 5955 | activeThreadActivities={activeThread?.activities} | ||
| 5956 | resolvedTheme={resolvedTheme} | ||
| 5957 | settings={settings} | ||
| 5958 | keybindings={keybindings} | ||
| 5959 | terminalOpen={Boolean(terminalUiState.terminalOpen)} | ||
| 5960 | gitCwd={gitCwd} | ||
| 5961 | promptRef={promptRef} | ||
| 5962 | composerImagesRef={composerImagesRef} | ||
| 5963 | composerTerminalContextsRef={composerTerminalContextsRef} | ||
| 5964 | composerElementContextsRef={composerElementContextsRef} | ||
| 5965 | onSend={onSend} | ||
| 5966 | onInterrupt={onInterrupt} | ||
| 5967 | onImplementPlanInNewThread={onImplementPlanInNewThread} | ||
| 5968 | onRespondToApproval={onRespondToApproval} | ||
| 5969 | onSelectActivePendingUserInputOption={ | ||
| 5970 | onSelectActivePendingUserInputOption | ||
| 5971 | } | ||
| 5972 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} | ||
| 5973 | onPreviousActivePendingUserInputQuestion={ | ||
| 5974 | onPreviousActivePendingUserInputQuestion | ||
| 5975 | } | ||
| 5976 | onChangeActivePendingUserInputCustomAnswer={ | ||
| 5977 | onChangeActivePendingUserInputCustomAnswer | ||
| 5978 | } | ||
| 5979 | onProviderModelSelect={onProviderModelSelect} | ||
| 5980 | getModelDisabledReason={getModelDisabledReason} | ||
| 5981 | toggleInteractionMode={toggleInteractionMode} | ||
| 5982 | handleRuntimeModeChange={handleRuntimeModeChange} | ||
| 5983 | handleInteractionModeChange={handleInteractionModeChange} | ||
| 5984 | togglePlanSidebar={togglePlanSidebar} | ||
| 5985 | focusComposer={focusComposer} | ||
| 5986 | scheduleComposerFocus={scheduleComposerFocus} | ||
| 5987 | setThreadError={setThreadError} | ||
| 5988 | onExpandImage={onExpandTimelineImage} | ||
| 5989 | /> | ||
| 5990 | </div> | ||
| 5991 | </div> | ||
| 5992 | <div className="min-h-0"> | ||
| 5993 | <div | ||
| 5994 | data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined} | ||
| 5995 | className="relative z-0" | ||
| 5996 | > | ||
| 5997 | {showComposerContextStrip && ( | ||
| 5998 | <div className="pointer-events-auto"> | ||
| 5999 | <BranchToolbar | ||
| 6000 | environmentId={activeThread.environmentId} | ||
| 6001 | threadId={activeThread.id} | ||
| 6002 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | ||
| 6003 | onEnvModeChange={onEnvModeChange} | ||
| 6004 | startFromOrigin={startFromOrigin} | ||
| 6005 | onStartFromOriginChange={onStartFromOriginChange} | ||
| 6006 | {...(canOverrideServerThreadEnvMode | ||
| 6007 | ? { effectiveEnvModeOverride: envMode } | ||
| 6008 | : {})} | ||
| 6009 | {...(canOverrideServerThreadEnvMode | ||
| 6010 | ? { | ||
| 6011 | activeThreadBranchOverride: activeThreadBranch, | ||
| 6012 | onActiveThreadBranchOverrideChange: | ||
| 6013 | setPendingServerThreadBranch, | ||
| 6014 | } | ||
| 6015 | : {})} | ||
| 6016 | envLocked={envLocked} | ||
| 6017 | onComposerFocusRequest={scheduleComposerFocus} | ||
| 6018 | {...(canCheckoutPullRequestIntoThread | ||
| 6019 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | ||
| 6020 | : {})} | ||
| 6021 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} | ||
| 6022 | availableEnvironments={logicalProjectEnvironments} | ||
| 6023 | /> | ||
| 6024 | </div> | ||
| 6025 | )} | ||
| 6026 | </div> | ||
| 6027 | </div> | ||
| 6028 | </div> | ||
| 6029 | <div | ||
| 6030 | aria-hidden | ||
| 6031 | className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]" | ||
| 6032 | /> | 5959 | /> |
| 6033 | </div> | 5960 | </div> |
| 6034 | </div> | 5961 | </div> |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 3 | import type { Thread } from "../types"; | 3 | import type { Thread } from "../types"; |
| 4 | import { makeThreadFixture } from "../test-fixtures"; | ||
| 4 | import { | 5 | import { |
| 5 | buildBrowseGroups, | 6 | buildBrowseGroups, |
| 6 | buildThreadActionItems, | 7 | buildThreadActionItems, |
| @@ -112,7 +113,7 @@ const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); · 105 unmodified lines | |||
| 112 | const PROJECT_ID = ProjectId.make("project-1"); | 113 | const PROJECT_ID = ProjectId.make("project-1"); |
| 113 | 114 | ||
| 114 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 115 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 115 | return { | 116 | return makeThreadFixture({ |
| 116 | id: ThreadId.make("thread-1"), | 117 | id: ThreadId.make("thread-1"), |
| 117 | environmentId: LOCAL_ENVIRONMENT_ID, | 118 | environmentId: LOCAL_ENVIRONMENT_ID, |
| 118 | projectId: PROJECT_ID, | 119 | projectId: PROJECT_ID, |
| @@ -120,7 +121,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line | |||
| 120 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | 121 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 121 | runtimeMode: "full-access", | 122 | runtimeMode: "full-access", |
| 122 | interactionMode: "default", | 123 | interactionMode: "default", |
| 123 | session: null, | 124 | runtime: null, |
| 124 | messages: [], | 125 | messages: [], |
| 125 | proposedPlans: [], | 126 | proposedPlans: [], |
| 126 | createdAt: "2026-03-01T00:00:00.000Z", | 127 | createdAt: "2026-03-01T00:00:00.000Z", |
| @@ -129,13 +130,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines | |||
| 129 | settledAt: null, | 130 | settledAt: null, |
| 130 | deletedAt: null, | 131 | deletedAt: null, |
| 131 | updatedAt: "2026-03-01T00:00:00.000Z", | 132 | updatedAt: "2026-03-01T00:00:00.000Z", |
| 132 | latestTurn: null, | 133 | latestRun: null, |
| 133 | branch: null, | 134 | branch: null, |
| 134 | worktreePath: null, | 135 | worktreePath: null, |
| 135 | checkpoints: [], | ||
| 136 | activities: [], | ||
| 137 | ...overrides, | 136 | ...overrides, |
| 138 | }; | 137 | }); |
| 139 | } | 138 | } |
| 140 | 139 | ||
| 141 | describe("buildThreadActionItems", () => { | 140 | describe("buildThreadActionItems", () => { |
| @@ -5,7 +5,7 @@ import { · 4 unmodified lines | |||
| 5 | squashAtomCommandFailure, | 5 | squashAtomCommandFailure, |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 7 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 8 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; | 8 | import type { ScopedThreadRef, RunId } from "@t3tools/contracts"; |
| 9 | import { | 9 | import { |
| 10 | ArrowRightIcon, | 10 | ArrowRightIcon, |
| 11 | CheckIcon, | 11 | CheckIcon, |
| @@ -246,55 +246,55 @@ export default function DiffPanel({ · 234 unmodified lines | |||
| 246 | ), | 246 | ), |
| 247 | ); | 247 | ); |
| 248 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | 248 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 249 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 249 | const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } = |
| 250 | useTurnDiffSummaries(activeThread); | 250 | useTurnDiffSummaries(activeThread); |
| 251 | const orderedTurnDiffSummaries = useMemo( | 251 | const orderedTurnDiffSummaries = useMemo( |
| 252 | () => | 252 | () => |
| 253 | [...turnDiffSummaries].toSorted((left, right) => { | 253 | [...turnDiffSummaries].toSorted((left, right) => { |
| 254 | const leftTurnCount = | 254 | const leftTurnCount = |
| 255 | left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; | 255 | left.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[left.runId] ?? 0; |
| 256 | const rightTurnCount = | 256 | const rightTurnCount = |
| 257 | right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; | 257 | right.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[right.runId] ?? 0; |
| 258 | if (leftTurnCount !== rightTurnCount) { | 258 | if (leftTurnCount !== rightTurnCount) { |
| 259 | return rightTurnCount - leftTurnCount; | 259 | return rightTurnCount - leftTurnCount; |
| 260 | } | 260 | } |
| 261 | return right.completedAt.localeCompare(left.completedAt); | 261 | return right.completedAt.localeCompare(left.completedAt); |
| 262 | }), | 262 | }), |
| 263 | [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], | 263 | [inferredCheckpointTurnCountByRunId, turnDiffSummaries], |
| 264 | ); | 264 | ); |
| 265 | 265 | ||
| 266 | useEffect(() => { | 266 | useEffect(() => { |
| 267 | if (!routeThreadRef || diffSelection.kind !== "turn") return; | 267 | if (!routeThreadRef || diffSelection.kind !== "turn") return; |
| 268 | useDiffPanelStore.getState().reconcileTurnSelection( | 268 | useDiffPanelStore.getState().reconcileTurnSelection( |
| 269 | routeThreadRef, | 269 | routeThreadRef, |
| 270 | orderedTurnDiffSummaries.map((summary) => summary.turnId), | 270 | orderedTurnDiffSummaries.map((summary) => summary.runId), |
| 271 | ); | 271 | ); |
| 272 | }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); | 272 | }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); |
| 273 | 273 | ||
| 274 | const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; | 274 | const selectedRunId = diffSelection.kind === "turn" ? diffSelection.turnId : null; |
| 275 | const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; | 275 | const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; |
| 276 | const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; | 276 | const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; |
| 277 | const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; | 277 | const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; |
| 278 | const selectedFileRevealRequestId = | 278 | const selectedFileRevealRequestId = |
| 279 | diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; | 279 | diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; |
| 280 | const selectedTurn = | 280 | const selectedTurn = |
| 281 | selectedTurnId === null | 281 | selectedRunId === null |
| 282 | ? undefined | 282 | ? undefined |
| 283 | : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | 283 | : (orderedTurnDiffSummaries.find((summary) => summary.runId === selectedRunId) ?? |
| 284 | orderedTurnDiffSummaries[0]); | 284 | orderedTurnDiffSummaries[0]); |
| 285 | const selectedCheckpointTurnCount = | 285 | const selectedCheckpointTurnCount = |
| 286 | selectedTurn && | 286 | selectedTurn && |
| 287 | (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); | 287 | (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[selectedTurn.runId]); |
| 288 | const latestTurn = orderedTurnDiffSummaries[0]; | 288 | const latestTurn = orderedTurnDiffSummaries[0]; |
| 289 | const selectedScopeLabel = | 289 | const selectedScopeLabel = |
| 290 | selectedTurnId === null | 290 | selectedRunId === null |
| 291 | ? selectedGitScope === "unstaged" | 291 | ? selectedGitScope === "unstaged" |
| 292 | ? "Working tree" | 292 | ? "Working tree" |
| 293 | : "Branch changes" | 293 | : "Branch changes" |
| 294 | : selectedTurn?.turnId === latestTurn?.turnId | 294 | : selectedTurn?.runId === latestTurn?.runId |
| 295 | ? "Latest turn" | 295 | ? "Latest turn" |
| 296 | : `Turn ${selectedCheckpointTurnCount ?? "?"}`; | 296 | : `Turn ${selectedCheckpointTurnCount ?? "?"}`; |
| 297 | const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope; | 297 | const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope; |
| 298 | const collapseScopeKey = routeThreadRef | 298 | const collapseScopeKey = routeThreadRef |
| 299 | ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` | 299 | ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` |
| 300 | : null; | 300 | : null; |
| @@ -324,12 +324,12 @@ export default function DiffPanel({ · 23 unmodified lines | |||
| 324 | fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, | 324 | fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, |
| 325 | toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, | 325 | toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, |
| 326 | ignoreWhitespace: diffIgnoreWhitespace, | 326 | ignoreWhitespace: diffIgnoreWhitespace, |
| 327 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null, | 327 | cacheScope: selectedTurn ? `turn:${selectedTurn.runId}` : null, |
| 328 | }, | 328 | }, |
| 329 | { enabled: isGitRepo && selectedTurn !== undefined }, | 329 | { enabled: isGitRepo && selectedTurn !== undefined }, |
| 330 | ); | 330 | ); |
| 331 | const primaryBranchDiffPreview = useEnvironmentQuery( | 331 | const primaryBranchDiffPreview = useEnvironmentQuery( |
| 332 | selectedTurnId === null && activeThread && activeCwd | 332 | selectedRunId === null && activeThread && activeCwd |
| 333 | ? reviewEnvironment.diffPreview({ | 333 | ? reviewEnvironment.diffPreview({ |
| 334 | environmentId: activeThread.environmentId, | 334 | environmentId: activeThread.environmentId, |
| 335 | input: { | 335 | input: { |
| @@ -341,7 +341,7 @@ export default function DiffPanel({ · 5 unmodified lines | |||
| 341 | : null, | 341 | : null, |
| 342 | ); | 342 | ); |
| 343 | const shouldRetryBranchDiffAtEnvironmentCwd = | 343 | const shouldRetryBranchDiffAtEnvironmentCwd = |
| 344 | selectedTurnId === null && | 344 | selectedRunId === null && |
| 345 | primaryBranchDiffPreview.error?.includes("configured workspace root") === true && | 345 | primaryBranchDiffPreview.error?.includes("configured workspace root") === true && |
| 346 | serverConfig?.cwd !== undefined && | 346 | serverConfig?.cwd !== undefined && |
| 347 | serverConfig.cwd !== activeCwd; | 347 | serverConfig.cwd !== activeCwd; |
| @@ -364,7 +364,7 @@ export default function DiffPanel({ · 16 unmodified lines | |||
| 364 | (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), | 364 | (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), |
| 365 | ); | 365 | ); |
| 366 | const localBranchRefs = useEnvironmentQuery( | 366 | const localBranchRefs = useEnvironmentQuery( |
| 367 | selectedTurnId === null && | 367 | selectedRunId === null && |
| 368 | selectedGitScope === "branch" && | 368 | selectedGitScope === "branch" && |
| 369 | activeThread && | 369 | activeThread && |
| 370 | branchDiffPreview.data?.cwd | 370 | branchDiffPreview.data?.cwd |
| @@ -381,7 +381,7 @@ export default function DiffPanel({ · 10 unmodified lines | |||
| 381 | : null, | 381 | : null, |
| 382 | ); | 382 | ); |
| 383 | const remoteBranchRefs = useEnvironmentQuery( | 383 | const remoteBranchRefs = useEnvironmentQuery( |
| 384 | selectedTurnId === null && | 384 | selectedRunId === null && |
| 385 | selectedGitScope === "branch" && | 385 | selectedGitScope === "branch" && |
| 386 | activeThread && | 386 | activeThread && |
| 387 | branchDiffPreview.data?.cwd | 387 | branchDiffPreview.data?.cwd |
| @@ -424,9 +424,9 @@ export default function DiffPanel({ · 36 unmodified lines | |||
| 424 | const renderablePatch = useMemo( | 424 | const renderablePatch = useMemo( |
| 425 | () => | 425 | () => |
| 426 | getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, { | 426 | getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, { |
| 427 | compactPartialHunkOffsets: selectedTurnId === null, | 427 | compactPartialHunkOffsets: selectedRunId === null, |
| 428 | }), | 428 | }), |
| 429 | [resolvedTheme, selectedPatch, selectedTurnId], | 429 | [resolvedTheme, selectedPatch, selectedRunId], |
| 430 | ); | 430 | ); |
| 431 | const renderableFiles = useMemo(() => { | 431 | const renderableFiles = useMemo(() => { |
| 432 | if (!renderablePatch || renderablePatch.kind !== "files") { | 432 | if (!renderablePatch || renderablePatch.kind !== "files") { |
| @@ -505,21 +505,9 @@ export default function DiffPanel({ · 72 unmodified lines | |||
| 505 | [collapseScopeKey], | 505 | [collapseScopeKey], |
| 506 | ); | 506 | ); |
| 507 | 507 | ||
| 508 | const toggleDiffFileCollapse = useCallback(() => { | 508 | const selectTurn = (runId: RunId) => { |
| 509 | setCollapsedDiffFiles((current) => { | ||
| 510 | const currentKeys = | ||
| 511 | current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; | ||
| 512 | |||
| 513 | return { | ||
| 514 | scopeKey: collapseScopeKey, | ||
| 515 | fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys), | ||
| 516 | }; | ||
| 517 | }); | ||
| 518 | }, [collapseScopeKey, diffFileKeys]); | ||
| 519 | |||
| 520 | const selectTurn = (turnId: TurnId) => { | ||
| 521 | if (!routeThreadRef) return; | 509 | if (!routeThreadRef) return; |
| 522 | useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); | 510 | useDiffPanelStore.getState().selectTurn(routeThreadRef, runId); |
| 523 | }; | 511 | }; |
| 524 | const selectGitScope = (scope: "branch" | "unstaged") => { | 512 | const selectGitScope = (scope: "branch" | "unstaged") => { |
| 525 | if (!routeThreadRef) return; | 513 | if (!routeThreadRef) return; |
| @@ -551,6 +539,15 @@ export default function DiffPanel({ · 25 unmodified lines | |||
| 551 | onClick={() => selectGitScope("unstaged")} | 539 | onClick={() => selectGitScope("unstaged")} |
| 552 | > | 540 | > |
| 553 | <span>Working tree</span> | 541 | <span>Working tree</span> |
| 542 | {selectedRunId === null && selectedGitScope === "unstaged" && ( | ||
| 543 | <CheckIcon className="ml-auto" /> | ||
| 544 | )} | ||
| 545 | </DropdownMenuItem> | ||
| 546 | <DropdownMenuItem onClick={() => selectGitScope("branch")}> | ||
| 547 | <span>Branch changes</span> | ||
| 548 | {selectedRunId === null && selectedGitScope === "branch" && ( | ||
| 549 | <CheckIcon className="ml-auto" /> | ||
| 550 | )} | ||
| 554 | </DropdownMenuItem> | 551 | </DropdownMenuItem> |
| 555 | <DropdownMenuItem | 552 | <DropdownMenuItem |
| 556 | className={ | 553 | className={ |
| @@ -569,10 +566,13 @@ export default function DiffPanel({ · 12 unmodified lines | |||
| 569 | : undefined | 566 | : undefined |
| 570 | } | 567 | } |
| 571 | onClick={() => { | 568 | onClick={() => { |
| 572 | if (latestTurn) selectTurn(latestTurn.turnId); | 569 | if (latestTurn) selectTurn(latestTurn.runId); |
| 573 | }} | 570 | }} |
| 574 | > | 571 | > |
| 575 | <span>Latest turn</span> | 572 | <span>Latest turn</span> |
| 573 | {selectedRunId !== null && selectedTurn?.runId === latestTurn?.runId && ( | ||
| 574 | <CheckIcon className="ml-auto" /> | ||
| 575 | )} | ||
| 576 | </DropdownMenuItem> | 576 | </DropdownMenuItem> |
| 577 | <DropdownMenuSub> | 577 | <DropdownMenuSub> |
| 578 | <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger> | 578 | <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger> |
| @@ -580,20 +580,15 @@ export default function DiffPanel({ · 1 unmodified line | |||
| 580 | {orderedTurnDiffSummaries.map((summary) => { | 580 | {orderedTurnDiffSummaries.map((summary) => { |
| 581 | const turnCount = | 581 | const turnCount = |
| 582 | summary.checkpointTurnCount ?? | 582 | summary.checkpointTurnCount ?? |
| 583 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | 583 | inferredCheckpointTurnCountByRunId[summary.runId] ?? |
| 584 | "?"; | 584 | "?"; |
| 585 | return ( | 585 | return ( |
| 586 | <DropdownMenuItem | 586 | <DropdownMenuItem key={summary.runId} onClick={() => selectTurn(summary.runId)}> |
| 587 | key={summary.turnId} | ||
| 588 | className={ | ||
| 589 | summary.turnId === selectedTurn?.turnId ? "bg-foreground/[0.08]" : undefined | ||
| 590 | } | ||
| 591 | onClick={() => selectTurn(summary.turnId)} | ||
| 592 | > | ||
| 593 | <span>Turn {turnCount}</span> | 587 | <span>Turn {turnCount}</span> |
| 594 | <span className="ml-auto text-xs tabular-nums text-muted-foreground"> | 588 | <span className="ml-auto text-xs tabular-nums text-muted-foreground"> |
| 595 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} | 589 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} |
| 596 | </span> | 590 | </span> |
| 591 | {summary.runId === selectedTurn?.runId && <CheckIcon className="ml-1" />} | ||
| 597 | </DropdownMenuItem> | 592 | </DropdownMenuItem> |
| 598 | ); | 593 | ); |
| 599 | })} | 594 | })} |
| @@ -601,7 +596,7 @@ export default function DiffPanel({ · 1 unmodified line | |||
| 601 | </DropdownMenuSub> | 596 | </DropdownMenuSub> |
| 602 | </DropdownMenuContent> | 597 | </DropdownMenuContent> |
| 603 | </DropdownMenu> | 598 | </DropdownMenu> |
| 604 | {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( | 599 | {selectedRunId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( |
| 605 | <div | 600 | <div |
| 606 | className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground" | 601 | className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground" |
| 607 | title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} | 602 | title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} |
| @@ -823,7 +818,7 @@ export default function DiffPanel({ · 215 unmodified lines | |||
| 823 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> | 818 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 824 | Turn diffs are unavailable because this project is not a git repository. | 819 | Turn diffs are unavailable because this project is not a git repository. |
| 825 | </div> | 820 | </div> |
| 826 | ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? ( | 821 | ) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? ( |
| 827 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> | 822 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 828 | No completed turns yet. | 823 | No completed turns yet. |
| 829 | </div> | 824 | </div> |
| @@ -31,20 +31,14 @@ import { · 30 unmodified lines | |||
| 31 | sortScopedProjectsForSidebar, | 31 | sortScopedProjectsForSidebar, |
| 32 | THREAD_JUMP_HINT_SHOW_DELAY_MS, | 32 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 33 | } from "./Sidebar.logic"; | 33 | } from "./Sidebar.logic"; |
| 34 | import { | 34 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 35 | EnvironmentId, | ||
| 36 | OrchestrationLatestTurn, | ||
| 37 | ProjectId, | ||
| 38 | ProviderInstanceId, | ||
| 39 | ThreadId, | ||
| 40 | } from "@t3tools/contracts"; | ||
| 41 | |||
| 42 | import { | 35 | import { |
| 43 | DEFAULT_INTERACTION_MODE, | 36 | DEFAULT_INTERACTION_MODE, |
| 44 | DEFAULT_RUNTIME_MODE, | 37 | DEFAULT_RUNTIME_MODE, |
| 45 | type Project, | 38 | type Project, |
| 46 | type Thread, | 39 | type Thread, |
| 47 | } from "../types"; | 40 | } from "../types"; |
| 41 | import { makeThreadFixture } from "../test-fixtures"; | ||
| 48 | 42 | ||
| 49 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 43 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 50 | 44 | ||
| @@ -239,13 +233,13 @@ describe("resolveSidebarStageBadgeLabel", () => { · 188 unmodified lines | |||
| 239 | }); | 233 | }); |
| 240 | }); | 234 | }); |
| 241 | 235 | ||
| 242 | function makeLatestTurn(overrides?: { | 236 | function makeLatestRun(overrides?: { |
| 243 | completedAt?: string | null; | 237 | completedAt?: string | null; |
| 244 | startedAt?: string | null; | 238 | startedAt?: string | null; |
| 245 | }): OrchestrationLatestTurn { | 239 | }): NonNullable<Thread["latestRun"]> { |
| 246 | return { | 240 | return { |
| 247 | turnId: "turn-1" as never, | 241 | runId: "turn-1" as never, |
| 248 | state: "completed", | 242 | status: "completed", |
| 249 | assistantMessageId: null, | 243 | assistantMessageId: null, |
| 250 | requestedAt: "2026-03-09T10:00:00.000Z", | 244 | requestedAt: "2026-03-09T10:00:00.000Z", |
| 251 | startedAt: | 245 | startedAt: |
| @@ -263,9 +257,9 @@ describe("hasUnseenCompletion", () => { · 11 unmodified lines | |||
| 263 | hasPendingApprovals: false, | 257 | hasPendingApprovals: false, |
| 264 | hasPendingUserInput: false, | 258 | hasPendingUserInput: false, |
| 265 | interactionMode: "default", | 259 | interactionMode: "default", |
| 266 | latestTurn: makeLatestTurn(), | 260 | latestRun: makeLatestRun(), |
| 267 | lastVisitedAt: "2026-03-09T10:04:00.000Z", | 261 | lastVisitedAt: "2026-03-09T10:04:00.000Z", |
| 268 | session: null, | 262 | runtime: null, |
| 269 | }), | 263 | }), |
| 270 | ).toBe(true); | 264 | ).toBe(true); |
| 271 | }); | 265 | }); |
| @@ -277,9 +271,9 @@ describe("hasUnseenCompletion", () => { · 5 unmodified lines | |||
| 277 | hasPendingApprovals: false, | 271 | hasPendingApprovals: false, |
| 278 | hasPendingUserInput: false, | 272 | hasPendingUserInput: false, |
| 279 | interactionMode: "default", | 273 | interactionMode: "default", |
| 280 | latestTurn: makeLatestTurn(), | 274 | latestRun: makeLatestRun(), |
| 281 | lastVisitedAt: undefined, | 275 | lastVisitedAt: undefined, |
| 282 | session: null, | 276 | runtime: null, |
| 283 | }), | 277 | }), |
| 284 | ).toBe(false); | 278 | ).toBe(false); |
| 285 | }); | 279 | }); |
| @@ -879,15 +873,13 @@ describe("resolveThreadStatusPill", () => { · 593 unmodified lines | |||
| 879 | hasPendingApprovals: false, | 873 | hasPendingApprovals: false, |
| 880 | hasPendingUserInput: false, | 874 | hasPendingUserInput: false, |
| 881 | interactionMode: "plan" as const, | 875 | interactionMode: "plan" as const, |
| 882 | latestTurn: null, | 876 | latestRun: null, |
| 883 | lastVisitedAt: undefined, | 877 | lastVisitedAt: undefined, |
| 884 | session: { | 878 | runtime: { |
| 885 | threadId: ThreadId.make("thread-1"), | ||
| 886 | status: "running" as const, | 879 | status: "running" as const, |
| 887 | providerName: "Codex", | 880 | providerName: "Codex", |
| 888 | providerInstanceId: ProviderInstanceId.make("codex"), | 881 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 889 | runtimeMode: DEFAULT_RUNTIME_MODE, | 882 | activeRunId: "turn-1" as never, |
| 890 | activeTurnId: "turn-1" as never, | ||
| 891 | lastError: null, | 883 | lastError: null, |
| 892 | updatedAt: "2026-03-09T10:00:00.000Z", | 884 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 893 | }, | 885 | }, |
| @@ -930,11 +922,11 @@ describe("resolveThreadStatusPill", () => { · 36 unmodified lines | |||
| 930 | thread: { | 922 | thread: { |
| 931 | ...baseThread, | 923 | ...baseThread, |
| 932 | hasActionableProposedPlan: true, | 924 | hasActionableProposedPlan: true, |
| 933 | latestTurn: makeLatestTurn(), | 925 | latestRun: makeLatestRun(), |
| 934 | session: { | 926 | runtime: { |
| 935 | ...baseThread.session, | 927 | ...baseThread.runtime, |
| 936 | status: "ready", | 928 | status: "completed", |
| 937 | activeTurnId: null, | 929 | activeRunId: null, |
| 938 | }, | 930 | }, |
| 939 | }, | 931 | }, |
| 940 | }), | 932 | }), |
| @@ -946,11 +938,11 @@ describe("resolveThreadStatusPill", () => { · 5 unmodified lines | |||
| 946 | resolveThreadStatusPill({ | 938 | resolveThreadStatusPill({ |
| 947 | thread: { | 939 | thread: { |
| 948 | ...baseThread, | 940 | ...baseThread, |
| 949 | latestTurn: makeLatestTurn(), | 941 | latestRun: makeLatestRun(), |
| 950 | session: { | 942 | runtime: { |
| 951 | ...baseThread.session, | 943 | ...baseThread.runtime, |
| 952 | status: "ready", | 944 | status: "completed", |
| 953 | activeTurnId: null, | 945 | activeRunId: null, |
| 954 | }, | 946 | }, |
| 955 | }, | 947 | }, |
| 956 | }), | 948 | }), |
| @@ -963,12 +955,12 @@ describe("resolveThreadStatusPill", () => { · 6 unmodified lines | |||
| 963 | thread: { | 955 | thread: { |
| 964 | ...baseThread, | 956 | ...baseThread, |
| 965 | interactionMode: "default", | 957 | interactionMode: "default", |
| 966 | latestTurn: makeLatestTurn(), | 958 | latestRun: makeLatestRun(), |
| 967 | lastVisitedAt: "2026-03-09T10:04:00.000Z", | 959 | lastVisitedAt: "2026-03-09T10:04:00.000Z", |
| 968 | session: { | 960 | runtime: { |
| 969 | ...baseThread.session, | 961 | ...baseThread.runtime, |
| 970 | status: "ready", | 962 | status: "completed", |
| 971 | activeTurnId: null, | 963 | activeRunId: null, |
| 972 | }, | 964 | }, |
| 973 | }, | 965 | }, |
| 974 | }), | 966 | }), |
| @@ -1120,7 +1112,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 145 unmodified lines | |||
| 1120 | } | 1112 | } |
| 1121 | 1113 | ||
| 1122 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 1114 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 1123 | return { | 1115 | return makeThreadFixture({ |
| 1124 | id: ThreadId.make("thread-1"), | 1116 | id: ThreadId.make("thread-1"), |
| 1125 | environmentId: localEnvironmentId, | 1117 | environmentId: localEnvironmentId, |
| 1126 | projectId: ProjectId.make("project-1"), | 1118 | projectId: ProjectId.make("project-1"), |
| @@ -1132,7 +1124,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 5 unmodified lines | |||
| 1132 | }, | 1124 | }, |
| 1133 | runtimeMode: DEFAULT_RUNTIME_MODE, | 1125 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 1134 | interactionMode: DEFAULT_INTERACTION_MODE, | 1126 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 1135 | session: null, | 1127 | runtime: null, |
| 1136 | messages: [], | 1128 | messages: [], |
| 1137 | proposedPlans: [], | 1129 | proposedPlans: [], |
| 1138 | createdAt: "2026-03-09T10:00:00.000Z", | 1130 | createdAt: "2026-03-09T10:00:00.000Z", |
| @@ -1141,13 +1133,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines | |||
| 1141 | settledAt: null, | 1133 | settledAt: null, |
| 1142 | deletedAt: null, | 1134 | deletedAt: null, |
| 1143 | updatedAt: "2026-03-09T10:00:00.000Z", | 1135 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 1144 | latestTurn: null, | 1136 | latestRun: null, |
| 1145 | branch: null, | 1137 | branch: null, |
| 1146 | worktreePath: null, | 1138 | worktreePath: null, |
| 1147 | checkpoints: [], | ||
| 1148 | activities: [], | ||
| 1149 | ...overrides, | 1139 | ...overrides, |
| 1150 | }; | 1140 | }); |
| 1151 | } | 1141 | } |
| 1152 | 1142 | ||
| 1153 | describe("getFallbackThreadIdAfterDelete", () => { | 1143 | describe("getFallbackThreadIdAfterDelete", () => { |
| @@ -1231,7 +1221,7 @@ describe("sortProjectsForSidebar", () => { · 77 unmodified lines | |||
| 1231 | id: "message-1" as never, | 1221 | id: "message-1" as never, |
| 1232 | role: "user", | 1222 | role: "user", |
| 1233 | text: "older project user message", | 1223 | text: "older project user message", |
| 1234 | turnId: null, | 1224 | runId: null, |
| 1235 | createdAt: "2026-03-09T10:01:00.000Z", | 1225 | createdAt: "2026-03-09T10:01:00.000Z", |
| 1236 | updatedAt: "2026-03-09T10:01:00.000Z", | 1226 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 1237 | streaming: false, | 1227 | streaming: false, |
| @@ -1247,7 +1237,7 @@ describe("sortProjectsForSidebar", () => { · 9 unmodified lines | |||
| 1247 | id: "message-2" as never, | 1237 | id: "message-2" as never, |
| 1248 | role: "user", | 1238 | role: "user", |
| 1249 | text: "newer project user message", | 1239 | text: "newer project user message", |
| 1250 | turnId: null, | 1240 | runId: null, |
| 1251 | createdAt: "2026-03-09T10:05:00.000Z", | 1241 | createdAt: "2026-03-09T10:05:00.000Z", |
| 1252 | updatedAt: "2026-03-09T10:05:00.000Z", | 1242 | updatedAt: "2026-03-09T10:05:00.000Z", |
| 1253 | streaming: false, | 1243 | streaming: false, |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | import type { SidebarThreadSummary, Thread } from "../types"; | 10 | import type { SidebarThreadSummary, Thread } from "../types"; |
| 11 | import type { ThreadRouteTarget } from "../threadRoutes"; | 11 | import type { ThreadRouteTarget } from "../threadRoutes"; |
| 12 | import { cn } from "../lib/utils"; | 12 | import { cn } from "../lib/utils"; |
| 13 | import { isLatestTurnSettled } from "../session-logic"; | 13 | import { isLatestRunSettled } from "../session-logic"; |
| 14 | import { resolveServerBackedAppStageLabel } from "../branding.logic"; | 14 | import { resolveServerBackedAppStageLabel } from "../branding.logic"; |
| 15 | 15 | ||
| 16 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | 16 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| @@ -141,8 +141,8 @@ type ThreadStatusInput = Pick< · 124 unmodified lines | |||
| 141 | | "hasPendingApprovals" | 141 | | "hasPendingApprovals" |
| 142 | | "hasPendingUserInput" | 142 | | "hasPendingUserInput" |
| 143 | | "interactionMode" | 143 | | "interactionMode" |
| 144 | | "latestTurn" | 144 | | "latestRun" |
| 145 | | "session" | 145 | | "runtime" |
| 146 | > & { | 146 | > & { |
| 147 | lastVisitedAt?: string | undefined; | 147 | lastVisitedAt?: string | undefined; |
| 148 | }; | 148 | }; |
| @@ -240,8 +240,8 @@ export function useThreadJumpHintVisibility(): { · 91 unmodified lines | |||
| 240 | } | 240 | } |
| 241 | 241 | ||
| 242 | export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { | 242 | export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { |
| 243 | if (!thread.latestTurn?.completedAt) return false; | 243 | if (!thread.latestRun?.completedAt) return false; |
| 244 | const completedAt = Date.parse(thread.latestTurn.completedAt); | 244 | const completedAt = Date.parse(thread.latestRun.completedAt); |
| 245 | if (Number.isNaN(completedAt)) return false; | 245 | if (Number.isNaN(completedAt)) return false; |
| 246 | if (!thread.lastVisitedAt) return false; | 246 | if (!thread.lastVisitedAt) return false; |
| 247 | 247 | ||
| @@ -594,7 +594,7 @@ export function resolveThreadStatusPill(input: { · 346 unmodified lines | |||
| 594 | }; | 594 | }; |
| 595 | } | 595 | } |
| 596 | 596 | ||
| 597 | if (thread.session?.status === "running") { | 597 | if (thread.runtime?.status === "running" || thread.runtime?.status === "waiting") { |
| 598 | return { | 598 | return { |
| 599 | label: "Working", | 599 | label: "Working", |
| 600 | colorClass: "text-sky-600 dark:text-sky-300/80", | 600 | colorClass: "text-sky-600 dark:text-sky-300/80", |
| @@ -603,7 +603,7 @@ export function resolveThreadStatusPill(input: { · 2 unmodified lines | |||
| 603 | }; | 603 | }; |
| 604 | } | 604 | } |
| 605 | 605 | ||
| 606 | if (thread.session?.status === "starting") { | 606 | if (thread.runtime?.status === "starting" || thread.runtime?.status === "queued") { |
| 607 | return { | 607 | return { |
| 608 | label: "Connecting", | 608 | label: "Connecting", |
| 609 | colorClass: "text-sky-600 dark:text-sky-300/80", | 609 | colorClass: "text-sky-600 dark:text-sky-300/80", |
| @@ -615,7 +615,7 @@ export function resolveThreadStatusPill(input: { · 5 unmodified lines | |||
| 615 | const hasPlanReadyPrompt = | 615 | const hasPlanReadyPrompt = |
| 616 | !thread.hasPendingUserInput && | 616 | !thread.hasPendingUserInput && |
| 617 | thread.interactionMode === "plan" && | 617 | thread.interactionMode === "plan" && |
| 618 | isLatestTurnSettled(thread.latestTurn, thread.session) && | 618 | isLatestRunSettled(thread.latestRun, thread.runtime) && |
| 619 | thread.hasActionableProposedPlan; | 619 | thread.hasActionableProposedPlan; |
| 620 | if (hasPlanReadyPrompt) { | 620 | if (hasPlanReadyPrompt) { |
| 621 | return { | 621 | return { |
| @@ -442,7 +442,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 441 unmodified lines | |||
| 442 | [discoveredPorts, navigateToThread, openPreview, threadRef], | 442 | [discoveredPorts, navigateToThread, openPreview, threadRef], |
| 443 | ); | 443 | ); |
| 444 | const isThreadRunning = | 444 | const isThreadRunning = |
| 445 | thread.session?.status === "running" && thread.session.activeTurnId != null; | 445 | thread.runtime?.status === "running" && thread.runtime.activeRunId != null; |
| 446 | const threadStatus = resolveThreadStatusPill({ | 446 | const threadStatus = resolveThreadStatusPill({ |
| 447 | thread: { | 447 | thread: { |
| 448 | ...thread, | 448 | ...thread, |
| @@ -1777,8 +1777,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1328 unmodified lines | |||
| 1777 | ); | 1777 | ); |
| 1778 | 1778 | ||
| 1779 | if (clicked === "mark-unread") { | 1779 | if (clicked === "mark-unread") { |
| 1780 | for (const { threadKey, thread } of selectedThreadEntries) { | 1780 | for (const threadKey of threadKeys) { |
| 1781 | markThreadUnread(threadKey, thread.latestTurn?.completedAt); | 1781 | const thread = sidebarThreadByKeyRef.current.get(threadKey); |
| 1782 | markThreadUnread(threadKey, thread?.latestRun?.completedAt); | ||
| 1782 | } | 1783 | } |
| 1783 | clearSelection(); | 1784 | clearSelection(); |
| 1784 | return; | 1785 | return; |
| @@ -2155,7 +2156,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 370 unmodified lines | |||
| 2155 | } | 2156 | } |
| 2156 | 2157 | ||
| 2157 | if (clicked === "mark-unread") { | 2158 | if (clicked === "mark-unread") { |
| 2158 | markThreadUnread(threadKey, thread.latestTurn?.completedAt); | 2159 | markThreadUnread(threadKey, thread.latestRun?.completedAt); |
| 2159 | return; | 2160 | return; |
| 2160 | } | 2161 | } |
| 2161 | if (clicked === "copy-path") { | 2162 | if (clicked === "copy-path") { |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { TurnId } from "@t3tools/contracts"; | 1 | import { RunId } from "@t3tools/contracts"; |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| @@ -142,7 +142,7 @@ describe("ChangedFilesTree", () => { · 137 unmodified lines | |||
| 142 | ({ files, visibleLabels, hiddenLabels }) => { | 142 | ({ files, visibleLabels, hiddenLabels }) => { |
| 143 | const markup = renderToStaticMarkup( | 143 | const markup = renderToStaticMarkup( |
| 144 | <ChangedFilesTree | 144 | <ChangedFilesTree |
| 145 | turnId={TurnId.make("turn-1")} | 145 | runId={RunId.make("turn-1")} |
| 146 | files={files} | 146 | files={files} |
| 147 | allDirectoriesExpanded={false} | 147 | allDirectoriesExpanded={false} |
| 148 | resolvedTheme="light" | 148 | resolvedTheme="light" |
| @@ -218,7 +218,7 @@ describe("ChangedFilesTree", () => { · 69 unmodified lines | |||
| 218 | ({ files, visibleLabels }) => { | 218 | ({ files, visibleLabels }) => { |
| 219 | const markup = renderToStaticMarkup( | 219 | const markup = renderToStaticMarkup( |
| 220 | <ChangedFilesTree | 220 | <ChangedFilesTree |
| 221 | turnId={TurnId.make("turn-1")} | 221 | runId={RunId.make("turn-1")} |
| 222 | files={files} | 222 | files={files} |
| 223 | allDirectoriesExpanded | 223 | allDirectoriesExpanded |
| 224 | resolvedTheme="light" | 224 | resolvedTheme="light" |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { type TurnId } from "@t3tools/contracts"; | 1 | import { type RunId } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useMemo, useState } from "react"; | 2 | import { memo, useCallback, useMemo, useState } from "react"; |
| 3 | import { type TurnDiffFileChange } from "../../types"; | 3 | import { type TurnDiffFileChange } from "../../types"; |
| 4 | import { | 4 | import { |
| @@ -28,7 +28,7 @@ import { · 23 unmodified lines | |||
| 28 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | 28 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 29 | 29 | ||
| 30 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | 30 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 31 | turnId: TurnId; | 31 | runId: RunId; |
| 32 | files: ReadonlyArray<TurnDiffFileChange>; | 32 | files: ReadonlyArray<TurnDiffFileChange>; |
| 33 | expanded: boolean; | 33 | expanded: boolean; |
| 34 | showCompactPreview: boolean; | 34 | showCompactPreview: boolean; |
| @@ -36,10 +36,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 1 unmodified line | |||
| 36 | resolvedTheme: "light" | "dark"; | 36 | resolvedTheme: "light" | "dark"; |
| 37 | onExpandedChange: (expanded: boolean) => void; | 37 | onExpandedChange: (expanded: boolean) => void; |
| 38 | onToggleAllDirectories: () => void; | 38 | onToggleAllDirectories: () => void; |
| 39 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 39 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 40 | }) { | 40 | }) { |
| 41 | const { | 41 | const { |
| 42 | turnId, | 42 | runId, |
| 43 | files, | 43 | files, |
| 44 | expanded, | 44 | expanded, |
| 45 | showCompactPreview, | 45 | showCompactPreview, |
| @@ -100,57 +100,29 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 54 unmodified lines | |||
| 100 | </span> | 100 | </span> |
| 101 | </button> | 101 | </button> |
| 102 | <div className="flex items-center gap-1.5"> | 102 | <div className="flex items-center gap-1.5"> |
| 103 | {expanded ? ( | 103 | <Button |
| 104 | <Tooltip> | 104 | type="button" |
| 105 | <TooltipTrigger | 105 | size="xs" |
| 106 | render={ | 106 | variant="outline" |
| 107 | <Button | 107 | data-scroll-anchor-ignore |
| 108 | type="button" | 108 | onClick={onToggleAllDirectories} |
| 109 | size="icon-xs" | 109 | > |
| 110 | variant="outline" | 110 | {allDirectoriesExpanded ? "Collapse all" : "Expand all"} |
| 111 | className="!size-[22px]" | 111 | </Button> |
| 112 | aria-label={ | 112 | <Button |
| 113 | allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" | 113 | type="button" |
| 114 | } | 114 | size="xs" |
| 115 | data-scroll-anchor-ignore | 115 | variant="outline" |
| 116 | onClick={onToggleAllDirectories} | 116 | onClick={() => onOpenTurnDiff(runId, files[0]?.path)} |
| 117 | /> | 117 | > |
| 118 | } | 118 | View diff |
| 119 | > | 119 | </Button> |
| 120 | {allDirectoriesExpanded ? ( | ||
| 121 | <ChevronsDownUpIcon className="size-3" /> | ||
| 122 | ) : ( | ||
| 123 | <ChevronsUpDownIcon className="size-3" /> | ||
| 124 | )} | ||
| 125 | </TooltipTrigger> | ||
| 126 | <TooltipPopup side="top"> | ||
| 127 | {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} | ||
| 128 | </TooltipPopup> | ||
| 129 | </Tooltip> | ||
| 130 | ) : null} | ||
| 131 | <Tooltip> | ||
| 132 | <TooltipTrigger | ||
| 133 | render={ | ||
| 134 | <Button | ||
| 135 | type="button" | ||
| 136 | size="xs" | ||
| 137 | variant="outline" | ||
| 138 | aria-label="Open diff" | ||
| 139 | onClick={() => onOpenTurnDiff(turnId, files[0]?.path)} | ||
| 140 | /> | ||
| 141 | } | ||
| 142 | > | ||
| 143 | <FileDiffIcon className="size-3" /> | ||
| 144 | <span className="hidden sm:inline">Open diff</span> | ||
| 145 | </TooltipTrigger> | ||
| 146 | <TooltipPopup side="top">Open the full diff</TooltipPopup> | ||
| 147 | </Tooltip> | ||
| 148 | </div> | 120 | </div> |
| 149 | </div> | 121 | </div> |
| 150 | {expanded ? ( | 122 | {expanded ? ( |
| 151 | <ChangedFilesTree | 123 | <ChangedFilesTree |
| 152 | key={`changed-files-tree:${turnId}`} | 124 | key={`changed-files-tree:${runId}`} |
| 153 | turnId={turnId} | 125 | runId={runId} |
| 154 | files={files} | 126 | files={files} |
| 155 | allDirectoriesExpanded={allDirectoriesExpanded} | 127 | allDirectoriesExpanded={allDirectoriesExpanded} |
| 156 | resolvedTheme={resolvedTheme} | 128 | resolvedTheme={resolvedTheme} |
| @@ -202,13 +174,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 45 unmodified lines | |||
| 202 | }); | 174 | }); |
| 203 | 175 | ||
| 204 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | 176 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 205 | turnId: TurnId; | 177 | runId: RunId; |
| 206 | files: ReadonlyArray<TurnDiffFileChange>; | 178 | files: ReadonlyArray<TurnDiffFileChange>; |
| 207 | allDirectoriesExpanded: boolean; | 179 | allDirectoriesExpanded: boolean; |
| 208 | resolvedTheme: "light" | "dark"; | 180 | resolvedTheme: "light" | "dark"; |
| 209 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 181 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 210 | }) { | 182 | }) { |
| 211 | const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; | 183 | const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, runId } = props; |
| 212 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); | 184 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); |
| 213 | const directoryPathsKey = useMemo( | 185 | const directoryPathsKey = useMemo( |
| 214 | () => collectDirectoryPaths(treeNodes).join("\u0000"), | 186 | () => collectDirectoryPaths(treeNodes).join("\u0000"), |
| @@ -293,7 +265,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 78 unmodified lines | |||
| 293 | type="button" | 265 | type="button" |
| 294 | className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" | 266 | className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" |
| 295 | style={{ paddingLeft: `${leftPadding}px` }} | 267 | style={{ paddingLeft: `${leftPadding}px` }} |
| 296 | onClick={() => onOpenTurnDiff(turnId, node.path)} | 268 | onClick={() => onOpenTurnDiff(runId, node.path)} |
| 297 | > | 269 | > |
| 298 | {hasDirectoryNodes || depth > 0 ? ( | 270 | {hasDirectoryNodes || depth > 0 ? ( |
| 299 | <span aria-hidden="true" className="size-3.5 shrink-0" /> | 271 | <span aria-hidden="true" className="size-3.5 shrink-0" /> |
| @@ -1,5 +1,4 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ApprovalRequestId, | ||
| 3 | EnvironmentId, | 2 | EnvironmentId, |
| 4 | ModelSelection, | 3 | ModelSelection, |
| 5 | PreviewAnnotationPayload, | 4 | PreviewAnnotationPayload, |
| @@ -10,7 +9,8 @@ import type { · 4 unmodified lines | |||
| 10 | ScopedThreadRef, | 9 | ScopedThreadRef, |
| 11 | ServerProvider, | 10 | ServerProvider, |
| 12 | ThreadId, | 11 | ThreadId, |
| 13 | TurnId, | 12 | RunId, |
| 13 | RuntimeRequestId, | ||
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { | 15 | import { |
| 16 | ProviderDriverKind, | 16 | ProviderDriverKind, |
| @@ -548,13 +548,13 @@ export interface ChatComposerProps { · 531 unmodified lines | |||
| 548 | activePendingIsResponding: boolean; | 548 | activePendingIsResponding: boolean; |
| 549 | activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>; | 549 | activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>; |
| 550 | activePendingQuestionIndex: number; | 550 | activePendingQuestionIndex: number; |
| 551 | respondingRequestIds: ApprovalRequestId[]; | 551 | respondingRequestIds: RuntimeRequestId[]; |
| 552 | 552 | ||
| 553 | // Plan | 553 | // Plan |
| 554 | showPlanFollowUpPrompt: boolean; | 554 | showPlanFollowUpPrompt: boolean; |
| 555 | activeProposedPlan: Thread["proposedPlans"][number] | null; | 555 | activeProposedPlan: Thread["proposedPlans"][number] | null; |
| 556 | activePlan: { turnId?: TurnId } | null; | 556 | activePlan: { runId?: RunId | null } | null; |
| 557 | sidebarProposedPlan: { turnId?: TurnId } | null; | 557 | sidebarProposedPlan: { runId?: RunId | null } | null; |
| 558 | planSidebarLabel: string; | 558 | planSidebarLabel: string; |
| 559 | planSidebarOpen: boolean; | 559 | planSidebarOpen: boolean; |
| 560 | 560 | ||
| @@ -569,7 +569,7 @@ export interface ChatComposerProps { · 8 unmodified lines | |||
| 569 | activeThreadModelSelection: ModelSelection | null | undefined; | 569 | activeThreadModelSelection: ModelSelection | null | undefined; |
| 570 | 570 | ||
| 571 | // Context window | 571 | // Context window |
| 572 | activeThreadActivities: Thread["activities"] | undefined; | 572 | activeThreadWorkEntries: Thread["workEntries"] | undefined; |
| 573 | 573 | ||
| 574 | // Misc | 574 | // Misc |
| 575 | resolvedTheme: "light" | "dark"; | 575 | resolvedTheme: "light" | "dark"; |
| @@ -585,12 +585,16 @@ export interface ChatComposerProps { · 9 unmodified lines | |||
| 585 | composerElementContextsRef: React.RefObject<ElementContextDraft[]>; | 585 | composerElementContextsRef: React.RefObject<ElementContextDraft[]>; |
| 586 | composerRef: React.RefObject<ChatComposerHandle | null>; | 586 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| 587 | 587 | ||
| 588 | // Scroll | ||
| 589 | shouldAutoScrollRef: React.RefObject<boolean>; | ||
| 590 | scheduleStickToBottom: () => void; | ||
| 591 | |||
| 588 | // Callbacks | 592 | // Callbacks |
| 589 | onSend: (e?: { preventDefault: () => void }) => void; | 593 | onSend: (e?: { preventDefault: () => void }) => void; |
| 590 | onInterrupt: () => void; | 594 | onInterrupt: () => void; |
| 591 | onImplementPlanInNewThread: () => void; | 595 | onImplementPlanInNewThread: () => void; |
| 592 | onRespondToApproval: ( | 596 | onRespondToApproval: ( |
| 593 | requestId: ApprovalRequestId, | 597 | requestId: RuntimeRequestId, |
| 594 | decision: ProviderApprovalDecision, | 598 | decision: ProviderApprovalDecision, |
| 595 | ) => Promise<unknown>; | 599 | ) => Promise<unknown>; |
| 596 | onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; | 600 | onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; |
| @@ -662,7 +666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 65 unmodified lines | |||
| 662 | providerStatuses, | 666 | providerStatuses, |
| 663 | activeProjectDefaultModelSelection, | 667 | activeProjectDefaultModelSelection, |
| 664 | activeThreadModelSelection, | 668 | activeThreadModelSelection, |
| 665 | activeThreadActivities, | 669 | activeThreadWorkEntries, |
| 666 | resolvedTheme, | 670 | resolvedTheme, |
| 667 | settings, | 671 | settings, |
| 668 | keybindings, | 672 | keybindings, |
| @@ -673,6 +677,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 4 unmodified lines | |||
| 673 | composerImagesRef, | 677 | composerImagesRef, |
| 674 | composerTerminalContextsRef, | 678 | composerTerminalContextsRef, |
| 675 | composerElementContextsRef, | 679 | composerElementContextsRef, |
| 680 | shouldAutoScrollRef, | ||
| 681 | scheduleStickToBottom, | ||
| 676 | onSend, | 682 | onSend, |
| 677 | onInterrupt, | 683 | onInterrupt, |
| 678 | onImplementPlanInNewThread, | 684 | onImplementPlanInNewThread, |
| @@ -754,7 +760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 75 unmodified lines | |||
| 754 | ); | 760 | ); |
| 755 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; | 761 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 756 | const threadProvider = | 762 | const threadProvider = |
| 757 | activeThread?.session?.providerInstanceId ?? | 763 | activeThread?.runtime?.providerInstanceId ?? |
| 758 | activeThreadModelSelection?.instanceId ?? | 764 | activeThreadModelSelection?.instanceId ?? |
| 759 | activeProjectDefaultModelSelection?.instanceId ?? | 765 | activeProjectDefaultModelSelection?.instanceId ?? |
| 760 | null; | 766 | null; |
| @@ -772,7 +778,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines | |||
| 772 | const lockedContinuationGroupKey = useMemo((): string | null => { | 778 | const lockedContinuationGroupKey = useMemo((): string | null => { |
| 773 | if (!lockedProvider || !activeThread) return null; | 779 | if (!lockedProvider || !activeThread) return null; |
| 774 | const lockedInstanceId = | 780 | const lockedInstanceId = |
| 775 | activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; | 781 | activeThread.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId; |
| 776 | if (!lockedInstanceId) return null; | 782 | if (!lockedInstanceId) return null; |
| 777 | return ( | 783 | return ( |
| 778 | providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) | 784 | providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) |
| @@ -798,7 +804,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines | |||
| 798 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { | 804 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { |
| 799 | const candidates: Array<string | null | undefined> = [ | 805 | const candidates: Array<string | null | undefined> = [ |
| 800 | composerDraft.activeProvider, | 806 | composerDraft.activeProvider, |
| 801 | activeThread?.session?.providerInstanceId, | 807 | activeThread?.runtime?.providerInstanceId, |
| 802 | activeThreadModelSelection?.instanceId, | 808 | activeThreadModelSelection?.instanceId, |
| 803 | activeProjectDefaultModelSelection?.instanceId, | 809 | activeProjectDefaultModelSelection?.instanceId, |
| 804 | ]; | 810 | ]; |
| @@ -835,7 +841,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 30 unmodified lines | |||
| 835 | ); | 841 | ); |
| 836 | }, [ | 842 | }, [ |
| 837 | activeProjectDefaultModelSelection?.instanceId, | 843 | activeProjectDefaultModelSelection?.instanceId, |
| 838 | activeThread?.session?.providerInstanceId, | 844 | activeThread?.runtime?.providerInstanceId, |
| 839 | activeThreadModelSelection?.instanceId, | 845 | activeThreadModelSelection?.instanceId, |
| 840 | composerDraft.activeProvider, | 846 | composerDraft.activeProvider, |
| 841 | lockedContinuationGroupKey, | 847 | lockedContinuationGroupKey, |
| @@ -939,8 +945,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 97 unmodified lines | |||
| 939 | // Context window | 945 | // Context window |
| 940 | // ------------------------------------------------------------------ | 946 | // ------------------------------------------------------------------ |
| 941 | const activeContextWindow = useMemo( | 947 | const activeContextWindow = useMemo( |
| 942 | () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), | 948 | () => deriveLatestContextWindowSnapshot(activeThreadWorkEntries ?? []), |
| 943 | [activeThreadActivities], | 949 | [activeThreadWorkEntries], |
| 944 | ); | 950 | ); |
| 945 | const activeThreadProviderDisplayName = useMemo(() => { | 951 | const activeThreadProviderDisplayName = useMemo(() => { |
| 946 | if (!activeThreadModelSelection) return null; | 952 | if (!activeThreadModelSelection) return null; |
| @@ -987,6 +993,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 40 unmodified lines | |||
| 987 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | 993 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 988 | const composerFormRef = useRef<HTMLFormElement>(null); | 994 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 989 | const composerSurfaceRef = useRef<HTMLDivElement>(null); | 995 | const composerSurfaceRef = useRef<HTMLDivElement>(null); |
| 996 | const composerFormHeightRef = useRef(0); | ||
| 990 | const composerSelectLockRef = useRef(false); | 997 | const composerSelectLockRef = useRef(false); |
| 991 | const composerMenuOpenRef = useRef(false); | 998 | const composerMenuOpenRef = useRef(false); |
| 992 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); | 999 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); |
| @@ -1441,12 +1448,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 448 unmodified lines | |||
| 1441 | }; | 1448 | }; |
| 1442 | }; | 1449 | }; |
| 1443 | 1450 | ||
| 1451 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; | ||
| 1444 | const initialCompactness = measureFooterCompactness(); | 1452 | const initialCompactness = measureFooterCompactness(); |
| 1445 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); | 1453 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); |
| 1446 | setIsComposerFooterCompact(initialCompactness.footerCompact); | 1454 | setIsComposerFooterCompact(initialCompactness.footerCompact); |
| 1447 | if (typeof ResizeObserver === "undefined") return; | 1455 | if (typeof ResizeObserver === "undefined") return; |
| 1448 | 1456 | ||
| 1449 | const observer = new ResizeObserver(() => { | 1457 | const observer = new ResizeObserver((entries) => { |
| 1458 | const [entry] = entries; | ||
| 1459 | if (!entry) return; | ||
| 1450 | const nextCompactness = measureFooterCompactness(); | 1460 | const nextCompactness = measureFooterCompactness(); |
| 1451 | setIsComposerPrimaryActionsCompact((previous) => | 1461 | setIsComposerPrimaryActionsCompact((previous) => |
| 1452 | previous === nextCompactness.primaryActionsCompact | 1462 | previous === nextCompactness.primaryActionsCompact |
| @@ -1456,13 +1466,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines | |||
| 1456 | setIsComposerFooterCompact((previous) => | 1466 | setIsComposerFooterCompact((previous) => |
| 1457 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | 1467 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, |
| 1458 | ); | 1468 | ); |
| 1469 | const nextHeight = entry.contentRect.height; | ||
| 1470 | const previousHeight = composerFormHeightRef.current; | ||
| 1471 | composerFormHeightRef.current = nextHeight; | ||
| 1472 | if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; | ||
| 1473 | if (!shouldAutoScrollRef.current) return; | ||
| 1474 | scheduleStickToBottom(); | ||
| 1459 | }); | 1475 | }); |
| 1460 | 1476 | ||
| 1461 | observer.observe(composerForm); | 1477 | observer.observe(composerForm); |
| 1462 | return () => { | 1478 | return () => { |
| 1463 | observer.disconnect(); | 1479 | observer.disconnect(); |
| 1464 | }; | 1480 | }; |
| 1465 | }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]); | 1481 | }, [ |
| 1482 | activeThreadId, | ||
| 1483 | composerFooterActionLayoutKey, | ||
| 1484 | composerFooterHasWideActions, | ||
| 1485 | scheduleStickToBottom, | ||
| 1486 | shouldAutoScrollRef, | ||
| 1487 | ]); | ||
| 1466 | 1488 | ||
| 1467 | // ------------------------------------------------------------------ | 1489 | // ------------------------------------------------------------------ |
| 1468 | // Image persist effect | 1490 | // Image persist effect |
| @@ -2752,6 +2774,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1283 unmodified lines | |||
| 2752 | <ComposerPendingApprovalActions | 2774 | <ComposerPendingApprovalActions |
| 2753 | requestId={activePendingApproval.requestId} | 2775 | requestId={activePendingApproval.requestId} |
| 2754 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} | 2776 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| 2777 | canRespond={activePendingApproval.responseCapability === "live"} | ||
| 2755 | onRespondToApproval={onRespondToApproval} | 2778 | onRespondToApproval={onRespondToApproval} |
| 2756 | /> | 2779 | /> |
| 2757 | </div> | 2780 | </div> |
| @@ -3108,6 +3131,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 350 unmodified lines | |||
| 3108 | <ComposerPendingApprovalActions | 3131 | <ComposerPendingApprovalActions |
| 3109 | requestId={activePendingApproval.requestId} | 3132 | requestId={activePendingApproval.requestId} |
| 3110 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} | 3133 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| 3134 | canRespond={activePendingApproval.responseCapability === "live"} | ||
| 3111 | onRespondToApproval={onRespondToApproval} | 3135 | onRespondToApproval={onRespondToApproval} |
| 3112 | /> | 3136 | /> |
| 3113 | </div> | 3137 | </div> |
| @@ -1,12 +1,13 @@ | |||
| 1 | import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; | 1 | import { type RuntimeRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { Button } from "../ui/button"; | 3 | import { Button } from "../ui/button"; |
| 4 | 4 | ||
| 5 | interface ComposerPendingApprovalActionsProps { | 5 | interface ComposerPendingApprovalActionsProps { |
| 6 | requestId: ApprovalRequestId; | 6 | requestId: RuntimeRequestId; |
| 7 | isResponding: boolean; | 7 | isResponding: boolean; |
| 8 | canRespond: boolean; | ||
| 8 | onRespondToApproval: ( | 9 | onRespondToApproval: ( |
| 9 | requestId: ApprovalRequestId, | 10 | requestId: RuntimeRequestId, |
| 10 | decision: ProviderApprovalDecision, | 11 | decision: ProviderApprovalDecision, |
| 11 | ) => Promise<unknown>; | 12 | ) => Promise<unknown>; |
| 12 | } | 13 | } |
| @@ -14,6 +15,7 @@ interface ComposerPendingApprovalActionsProps { · 1 unmodified line | |||
| 14 | export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ | 15 | export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ |
| 15 | requestId, | 16 | requestId, |
| 16 | isResponding, | 17 | isResponding, |
| 18 | canRespond, | ||
| 17 | onRespondToApproval, | 19 | onRespondToApproval, |
| 18 | }: ComposerPendingApprovalActionsProps) { | 20 | }: ComposerPendingApprovalActionsProps) { |
| 19 | return ( | 21 | return ( |
| @@ -21,7 +23,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | |||
| 21 | <Button | 23 | <Button |
| 22 | size="sm" | 24 | size="sm" |
| 23 | variant="ghost" | 25 | variant="ghost" |
| 24 | disabled={isResponding} | 26 | disabled={isResponding || !canRespond} |
| 25 | onClick={() => void onRespondToApproval(requestId, "cancel")} | 27 | onClick={() => void onRespondToApproval(requestId, "cancel")} |
| 26 | > | 28 | > |
| 27 | Cancel turn | 29 | Cancel turn |
| @@ -29,7 +31,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | |||
| 29 | <Button | 31 | <Button |
| 30 | size="sm" | 32 | size="sm" |
| 31 | variant="destructive-outline" | 33 | variant="destructive-outline" |
| 32 | disabled={isResponding} | 34 | disabled={isResponding || !canRespond} |
| 33 | onClick={() => void onRespondToApproval(requestId, "decline")} | 35 | onClick={() => void onRespondToApproval(requestId, "decline")} |
| 34 | > | 36 | > |
| 35 | Decline | 37 | Decline |
| @@ -37,7 +39,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | |||
| 37 | <Button | 39 | <Button |
| 38 | size="sm" | 40 | size="sm" |
| 39 | variant="outline" | 41 | variant="outline" |
| 40 | disabled={isResponding} | 42 | disabled={isResponding || !canRespond} |
| 41 | onClick={() => void onRespondToApproval(requestId, "acceptForSession")} | 43 | onClick={() => void onRespondToApproval(requestId, "acceptForSession")} |
| 42 | > | 44 | > |
| 43 | Always allow this session | 45 | Always allow this session |
| @@ -45,7 +47,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | |||
| 45 | <Button | 47 | <Button |
| 46 | size="sm" | 48 | size="sm" |
| 47 | variant="default" | 49 | variant="default" |
| 48 | disabled={isResponding} | 50 | disabled={isResponding || !canRespond} |
| 49 | onClick={() => void onRespondToApproval(requestId, "accept")} | 51 | onClick={() => void onRespondToApproval(requestId, "accept")} |
| 50 | > | 52 | > |
| 51 | Approve once | 53 | Approve once |
| @@ -32,17 +32,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova · 31 unmodified lines | |||
| 32 | <span className="text-xs text-muted-foreground">1/{pendingCount}</span> | 32 | <span className="text-xs text-muted-foreground">1/{pendingCount}</span> |
| 33 | ) : null} | 33 | ) : null} |
| 34 | </div> | 34 | </div> |
| 35 | {approval.detail ? ( | 35 | {approval.responseCapability === "not_resumable" ? ( |
| 36 | <div className="mt-3 rounded-lg border border-border/65 bg-background/70 p-3"> | 36 | <p className="mt-2 text-xs text-muted-foreground"> |
| 37 | <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p> | 37 | This request belonged to a provider process that is no longer available. Interrupt or |
| 38 | <pre | 38 | restart the run to continue. |
| 39 | aria-label={detailLabel} | 39 | </p> |
| 40 | className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-foreground" | ||
| 41 | data-approval-detail="complete" | ||
| 42 | > | ||
| 43 | {approval.detail} | ||
| 44 | </pre> | ||
| 45 | </div> | ||
| 46 | ) : null} | 40 | ) : null} |
| 47 | </div> | 41 | </div> |
| 48 | ); | 42 | ); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { type ApprovalRequestId } from "@t3tools/contracts"; | 1 | import { type RuntimeRequestId } from "@t3tools/contracts"; |
| 2 | import { memo, useEffect, useEffectEvent, useRef, useState } from "react"; | 2 | import { memo, useEffect, useEffectEvent, useRef, useState } from "react"; |
| 3 | import { type PendingUserInput } from "../../session-logic"; | 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | import { | 4 | import { |
| @@ -10,7 +10,7 @@ import { cn } from "~/lib/utils"; · 5 unmodified lines | |||
| 10 | 10 | ||
| 11 | interface PendingUserInputPanelProps { | 11 | interface PendingUserInputPanelProps { |
| 12 | pendingUserInputs: PendingUserInput[]; | 12 | pendingUserInputs: PendingUserInput[]; |
| 13 | respondingRequestIds: ApprovalRequestId[]; | 13 | respondingRequestIds: RuntimeRequestId[]; |
| 14 | answers: Record<string, PendingUserInputDraftAnswer>; | 14 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 15 | questionIndex: number; | 15 | questionIndex: number; |
| 16 | onToggleOption: (questionId: string, optionLabel: string) => void; | 16 | onToggleOption: (questionId: string, optionLabel: string) => void; |
| @@ -59,6 +59,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 42 unmodified lines | |||
| 59 | }) { | 59 | }) { |
| 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); | 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 61 | const activeQuestion = progress.activeQuestion; | 61 | const activeQuestion = progress.activeQuestion; |
| 62 | const canRespond = prompt.responseCapability === "live"; | ||
| 63 | const responseDisabled = isResponding || !canRespond; | ||
| 62 | const autoAdvanceTimerRef = useRef<number | null>(null); | 64 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| 63 | const onAdvanceRef = useRef(onAdvance); | 65 | const onAdvanceRef = useRef(onAdvance); |
| 64 | const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ | 66 | const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ |
| @@ -120,7 +122,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 55 unmodified lines | |||
| 120 | // outside editable fields. Multi-select prompts toggle options in place; single- | 122 | // outside editable fields. Multi-select prompts toggle options in place; single- |
| 121 | // select prompts keep the existing auto-advance behavior. | 123 | // select prompts keep the existing auto-advance behavior. |
| 122 | useEffect(() => { | 124 | useEffect(() => { |
| 123 | if (!activeQuestion || isResponding) return; | 125 | if (!activeQuestion || responseDisabled) return; |
| 124 | const handler = (event: globalThis.KeyboardEvent) => { | 126 | const handler = (event: globalThis.KeyboardEvent) => { |
| 125 | if (event.metaKey || event.ctrlKey || event.altKey) return; | 127 | if (event.metaKey || event.ctrlKey || event.altKey) return; |
| 126 | const target = event.target; | 128 | const target = event.target; |
| @@ -144,7 +146,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 17 unmodified lines | |||
| 144 | }; | 146 | }; |
| 145 | document.addEventListener("keydown", handler); | 147 | document.addEventListener("keydown", handler); |
| 146 | return () => document.removeEventListener("keydown", handler); | 148 | return () => document.removeEventListener("keydown", handler); |
| 147 | }, [activeQuestion, isResponding]); | 149 | }, [activeQuestion, responseDisabled]); |
| 148 | 150 | ||
| 149 | if (!activeQuestion) { | 151 | if (!activeQuestion) { |
| 150 | return null; | 152 | return null; |
| @@ -165,6 +167,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 14 unmodified lines | |||
| 165 | ) : null} | 167 | ) : null} |
| 166 | </div> | 168 | </div> |
| 167 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> | 169 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> |
| 170 | {!canRespond ? ( | ||
| 171 | <p className="mt-1 text-xs text-muted-foreground"> | ||
| 172 | This request belonged to a provider process that is no longer available. Interrupt or | ||
| 173 | restart the run to continue. | ||
| 174 | </p> | ||
| 175 | ) : null} | ||
| 168 | {activeQuestion.multiSelect ? ( | 176 | {activeQuestion.multiSelect ? ( |
| 169 | <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p> | 177 | <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p> |
| 170 | ) : null} | 178 | ) : null} |
| @@ -182,8 +190,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 11 unmodified lines | |||
| 182 | isSelected | 190 | isSelected |
| 183 | ? "border-primary/30 bg-primary/8 text-foreground" | 191 | ? "border-primary/30 bg-primary/8 text-foreground" |
| 184 | : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", | 192 | : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", |
| 185 | isResponding && "opacity-50 cursor-not-allowed", | 193 | responseDisabled && "opacity-50 cursor-not-allowed", |
| 186 | !isResponding && "cursor-pointer", | 194 | !responseDisabled && "cursor-pointer", |
| 187 | ); | 195 | ); |
| 188 | const content = ( | 196 | const content = ( |
| 189 | <> | 197 | <> |
| @@ -211,8 +219,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 21 unmodified lines | |||
| 211 | <button | 219 | <button |
| 212 | key={`${activeQuestion.id}:${option.label}`} | 220 | key={`${activeQuestion.id}:${option.label}`} |
| 213 | type="button" | 221 | type="button" |
| 214 | disabled={isResponding} | 222 | disabled={responseDisabled} |
| 223 | aria-disabled={responseDisabled} | ||
| 215 | onClick={() => { | 224 | onClick={() => { |
| 225 | if (responseDisabled) return; | ||
| 216 | handleOptionSelection(activeQuestion.id, option.label); | 226 | handleOptionSelection(activeQuestion.id, option.label); |
| 217 | }} | 227 | }} |
| 218 | className={className} | 228 | className={className} |
| @@ -272,7 +272,7 @@ describe("deriveMessagesTimelineRows", () => { · 271 unmodified lines | |||
| 272 | id: "user-1" as never, | 272 | id: "user-1" as never, |
| 273 | role: "user", | 273 | role: "user", |
| 274 | text: "Write a poem", | 274 | text: "Write a poem", |
| 275 | turnId: null, | 275 | runId: null, |
| 276 | createdAt: "2026-01-01T00:00:00Z", | 276 | createdAt: "2026-01-01T00:00:00Z", |
| 277 | updatedAt: "2026-01-01T00:00:00Z", | 277 | updatedAt: "2026-01-01T00:00:00Z", |
| 278 | streaming: false, | 278 | streaming: false, |
| @@ -286,7 +286,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 286 | id: "assistant-thought" as never, | 286 | id: "assistant-thought" as never, |
| 287 | role: "assistant", | 287 | role: "assistant", |
| 288 | text: "I should ground this first.", | 288 | text: "I should ground this first.", |
| 289 | turnId: "turn-1" as never, | 289 | runId: "turn-1" as never, |
| 290 | createdAt: "2026-01-01T00:00:10Z", | 290 | createdAt: "2026-01-01T00:00:10Z", |
| 291 | updatedAt: "2026-01-01T00:00:11Z", | 291 | updatedAt: "2026-01-01T00:00:11Z", |
| 292 | streaming: false, | 292 | streaming: false, |
| @@ -300,14 +300,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 300 | id: "assistant-final" as never, | 300 | id: "assistant-final" as never, |
| 301 | role: "assistant", | 301 | role: "assistant", |
| 302 | text: "Here is the poem.", | 302 | text: "Here is the poem.", |
| 303 | turnId: "turn-1" as never, | 303 | runId: "turn-1" as never, |
| 304 | createdAt: "2026-01-01T00:00:20Z", | 304 | createdAt: "2026-01-01T00:00:20Z", |
| 305 | updatedAt: "2026-01-01T00:00:30Z", | 305 | updatedAt: "2026-01-01T00:00:30Z", |
| 306 | streaming: false, | 306 | streaming: false, |
| 307 | }, | 307 | }, |
| 308 | }, | 308 | }, |
| 309 | ], | 309 | ], |
| 310 | expandedTurnIds: new Set(["turn-1" as never]), | 310 | expandedRunIds: new Set(["turn-1" as never]), |
| 311 | isWorking: false, | 311 | isWorking: false, |
| 312 | activeTurnStartedAt: null, | 312 | activeTurnStartedAt: null, |
| 313 | turnDiffSummaryByAssistantMessageId: new Map(), | 313 | turnDiffSummaryByAssistantMessageId: new Map(), |
| @@ -335,7 +335,7 @@ describe("deriveMessagesTimelineRows", () => { · 21 unmodified lines | |||
| 335 | id: "assistant-one" as never, | 335 | id: "assistant-one" as never, |
| 336 | role: "assistant", | 336 | role: "assistant", |
| 337 | text: "Earlier response.", | 337 | text: "Earlier response.", |
| 338 | turnId: "turn-1" as never, | 338 | runId: "turn-1" as never, |
| 339 | createdAt: "2026-01-01T00:00:10Z", | 339 | createdAt: "2026-01-01T00:00:10Z", |
| 340 | updatedAt: "2026-01-01T00:00:11Z", | 340 | updatedAt: "2026-01-01T00:00:11Z", |
| 341 | streaming: false, | 341 | streaming: false, |
| @@ -349,16 +349,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 349 | id: "assistant-two" as never, | 349 | id: "assistant-two" as never, |
| 350 | role: "assistant", | 350 | role: "assistant", |
| 351 | text: "Active response.", | 351 | text: "Active response.", |
| 352 | turnId: "turn-2" as never, | 352 | runId: "turn-2" as never, |
| 353 | createdAt: "2026-01-01T00:00:20Z", | 353 | createdAt: "2026-01-01T00:00:20Z", |
| 354 | updatedAt: "2026-01-01T00:00:30Z", | 354 | updatedAt: "2026-01-01T00:00:30Z", |
| 355 | streaming: false, | 355 | streaming: false, |
| 356 | }, | 356 | }, |
| 357 | }, | 357 | }, |
| 358 | ], | 358 | ], |
| 359 | latestTurn: { | 359 | latestRun: { |
| 360 | turnId: "turn-2" as never, | 360 | runId: "turn-2" as never, |
| 361 | state: "running", | 361 | status: "running", |
| 362 | startedAt: "2026-01-01T00:00:19Z", | 362 | startedAt: "2026-01-01T00:00:19Z", |
| 363 | completedAt: null, | 363 | completedAt: null, |
| 364 | }, | 364 | }, |
| @@ -379,7 +379,7 @@ describe("deriveMessagesTimelineRows", () => { · 14 unmodified lines | |||
| 379 | 379 | ||
| 380 | it("projects assistant diff summaries and user revert counts onto the affected rows", () => { | 380 | it("projects assistant diff summaries and user revert counts onto the affected rows", () => { |
| 381 | const assistantTurnDiffSummary = { | 381 | const assistantTurnDiffSummary = { |
| 382 | turnId: "turn-1" as never, | 382 | runId: "turn-1" as never, |
| 383 | completedAt: "2026-01-01T00:00:30Z", | 383 | completedAt: "2026-01-01T00:00:30Z", |
| 384 | assistantMessageId: "assistant-1" as never, | 384 | assistantMessageId: "assistant-1" as never, |
| 385 | checkpointTurnCount: 2, | 385 | checkpointTurnCount: 2, |
| @@ -398,7 +398,7 @@ describe("deriveMessagesTimelineRows", () => { · 12 unmodified lines | |||
| 398 | id: "user-1" as never, | 398 | id: "user-1" as never, |
| 399 | role: "user", | 399 | role: "user", |
| 400 | text: "Do the thing", | 400 | text: "Do the thing", |
| 401 | turnId: null, | 401 | runId: null, |
| 402 | createdAt: "2026-01-01T00:00:00Z", | 402 | createdAt: "2026-01-01T00:00:00Z", |
| 403 | updatedAt: "2026-01-01T00:00:00Z", | 403 | updatedAt: "2026-01-01T00:00:00Z", |
| 404 | streaming: false, | 404 | streaming: false, |
| @@ -412,7 +412,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 412 | id: "assistant-1" as never, | 412 | id: "assistant-1" as never, |
| 413 | role: "assistant", | 413 | role: "assistant", |
| 414 | text: "Done", | 414 | text: "Done", |
| 415 | turnId: "turn-1" as never, | 415 | runId: "turn-1" as never, |
| 416 | createdAt: "2026-01-01T00:00:20Z", | 416 | createdAt: "2026-01-01T00:00:20Z", |
| 417 | updatedAt: "2026-01-01T00:00:30Z", | 417 | updatedAt: "2026-01-01T00:00:30Z", |
| 418 | streaming: false, | 418 | streaming: false, |
| @@ -450,7 +450,7 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines | |||
| 450 | id: "user-1" as never, | 450 | id: "user-1" as never, |
| 451 | role: "user" as const, | 451 | role: "user" as const, |
| 452 | text: "Build it", | 452 | text: "Build it", |
| 453 | turnId: null, | 453 | runId: null, |
| 454 | createdAt: "2026-01-01T00:00:00Z", | 454 | createdAt: "2026-01-01T00:00:00Z", |
| 455 | updatedAt: "2026-01-01T00:00:00Z", | 455 | updatedAt: "2026-01-01T00:00:00Z", |
| 456 | streaming: false, | 456 | streaming: false, |
| @@ -464,7 +464,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 464 | id: "assistant-thought" as never, | 464 | id: "assistant-thought" as never, |
| 465 | role: "assistant" as const, | 465 | role: "assistant" as const, |
| 466 | text: "Looking around first.", | 466 | text: "Looking around first.", |
| 467 | turnId: "turn-1" as never, | 467 | runId: "turn-1" as never, |
| 468 | createdAt: "2026-01-01T00:00:05Z", | 468 | createdAt: "2026-01-01T00:00:05Z", |
| 469 | updatedAt: "2026-01-01T00:00:06Z", | 469 | updatedAt: "2026-01-01T00:00:06Z", |
| 470 | streaming: false, | 470 | streaming: false, |
| @@ -477,7 +477,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | |||
| 477 | entry: { | 477 | entry: { |
| 478 | id: "work-1", | 478 | id: "work-1", |
| 479 | createdAt: "2026-01-01T00:00:08Z", | 479 | createdAt: "2026-01-01T00:00:08Z", |
| 480 | turnId: "turn-1" as never, | 480 | runId: "turn-1" as never, |
| 481 | label: "Ran command", | 481 | label: "Ran command", |
| 482 | tone: "tool" as const, | 482 | tone: "tool" as const, |
| 483 | }, | 483 | }, |
| @@ -490,7 +490,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | |||
| 490 | id: "assistant-final" as never, | 490 | id: "assistant-final" as never, |
| 491 | role: "assistant" as const, | 491 | role: "assistant" as const, |
| 492 | text: "Done", | 492 | text: "Done", |
| 493 | turnId: "turn-1" as never, | 493 | runId: "turn-1" as never, |
| 494 | createdAt: "2026-01-01T00:00:20Z", | 494 | createdAt: "2026-01-01T00:00:20Z", |
| 495 | updatedAt: "2026-01-01T00:00:22Z", | 495 | updatedAt: "2026-01-01T00:00:22Z", |
| 496 | streaming: false, | 496 | streaming: false, |
| @@ -510,7 +510,7 @@ describe("deriveMessagesTimelineRows", () => { · 13 unmodified lines | |||
| 510 | (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> => | 510 | (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> => |
| 511 | row.kind === "turn-fold", | 511 | row.kind === "turn-fold", |
| 512 | ); | 512 | ); |
| 513 | expect(foldRow?.turnId).toBe("turn-1"); | 513 | expect(foldRow?.runId).toBe("turn-1"); |
| 514 | expect(foldRow?.expanded).toBe(false); | 514 | expect(foldRow?.expanded).toBe(false); |
| 515 | // User message boundary (00:00:00) → terminal message updatedAt (00:00:22). | 515 | // User message boundary (00:00:00) → terminal message updatedAt (00:00:22). |
| 516 | expect(foldRow?.label).toBe("Worked for 22s"); | 516 | expect(foldRow?.label).toBe("Worked for 22s"); |
| @@ -522,7 +522,7 @@ describe("deriveMessagesTimelineRows", () => { · 5 unmodified lines | |||
| 522 | 522 | ||
| 523 | const expandedRows = deriveMessagesTimelineRows({ | 523 | const expandedRows = deriveMessagesTimelineRows({ |
| 524 | timelineEntries, | 524 | timelineEntries, |
| 525 | expandedTurnIds: new Set(["turn-1" as never]), | 525 | expandedRunIds: new Set(["turn-1" as never]), |
| 526 | isWorking: false, | 526 | isWorking: false, |
| 527 | activeTurnStartedAt: null, | 527 | activeTurnStartedAt: null, |
| 528 | turnDiffSummaryByAssistantMessageId: new Map(), | 528 | turnDiffSummaryByAssistantMessageId: new Map(), |
| @@ -556,7 +556,7 @@ describe("deriveMessagesTimelineRows", () => { · 27 unmodified lines | |||
| 556 | id: "user-1" as never, | 556 | id: "user-1" as never, |
| 557 | role: "user" as const, | 557 | role: "user" as const, |
| 558 | text: "do it once more", | 558 | text: "do it once more", |
| 559 | turnId: null, | 559 | runId: null, |
| 560 | createdAt: "2026-01-01T00:00:00Z", | 560 | createdAt: "2026-01-01T00:00:00Z", |
| 561 | updatedAt: "2026-01-01T00:00:00Z", | 561 | updatedAt: "2026-01-01T00:00:00Z", |
| 562 | streaming: false, | 562 | streaming: false, |
| @@ -570,7 +570,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 570 | id: "assistant-commentary" as never, | 570 | id: "assistant-commentary" as never, |
| 571 | role: "assistant" as const, | 571 | role: "assistant" as const, |
| 572 | text: "Kicking off call 1.", | 572 | text: "Kicking off call 1.", |
| 573 | turnId: "turn-1" as never, | 573 | runId: "turn-1" as never, |
| 574 | createdAt: "2026-01-01T00:00:09Z", | 574 | createdAt: "2026-01-01T00:00:09Z", |
| 575 | updatedAt: "2026-01-01T00:00:09Z", | 575 | updatedAt: "2026-01-01T00:00:09Z", |
| 576 | streaming: false, | 576 | streaming: false, |
| @@ -583,7 +583,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | |||
| 583 | entry: { | 583 | entry: { |
| 584 | id: "work-1", | 584 | id: "work-1", |
| 585 | createdAt: "2026-01-01T00:00:12Z", | 585 | createdAt: "2026-01-01T00:00:12Z", |
| 586 | turnId: "turn-1" as never, | 586 | runId: "turn-1" as never, |
| 587 | label: "Ran command", | 587 | label: "Ran command", |
| 588 | tone: "tool" as const, | 588 | tone: "tool" as const, |
| 589 | }, | 589 | }, |
| @@ -596,7 +596,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | |||
| 596 | id: "user-2" as never, | 596 | id: "user-2" as never, |
| 597 | role: "user" as const, | 597 | role: "user" as const, |
| 598 | text: "actually do 15", | 598 | text: "actually do 15", |
| 599 | turnId: null, | 599 | runId: null, |
| 600 | createdAt: "2026-01-01T00:00:14Z", | 600 | createdAt: "2026-01-01T00:00:14Z", |
| 601 | updatedAt: "2026-01-01T00:00:14Z", | 601 | updatedAt: "2026-01-01T00:00:14Z", |
| 602 | streaming: false, | 602 | streaming: false, |
| @@ -610,16 +610,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 610 | id: "assistant-next" as never, | 610 | id: "assistant-next" as never, |
| 611 | role: "assistant" as const, | 611 | role: "assistant" as const, |
| 612 | text: "One down — adjusting.", | 612 | text: "One down — adjusting.", |
| 613 | turnId: "turn-2" as never, | 613 | runId: "turn-2" as never, |
| 614 | createdAt: "2026-01-01T00:00:17Z", | 614 | createdAt: "2026-01-01T00:00:17Z", |
| 615 | updatedAt: "2026-01-01T00:00:17Z", | 615 | updatedAt: "2026-01-01T00:00:17Z", |
| 616 | streaming: true, | 616 | streaming: true, |
| 617 | }, | 617 | }, |
| 618 | }, | 618 | }, |
| 619 | ], | 619 | ], |
| 620 | latestTurn: { | 620 | latestRun: { |
| 621 | turnId: "turn-2" as never, | 621 | runId: "turn-2" as never, |
| 622 | state: "running", | 622 | status: "running", |
| 623 | startedAt: "2026-01-01T00:00:14Z", | 623 | startedAt: "2026-01-01T00:00:14Z", |
| 624 | completedAt: null, | 624 | completedAt: null, |
| 625 | }, | 625 | }, |
| @@ -634,7 +634,7 @@ describe("deriveMessagesTimelineRows", () => { · 8 unmodified lines | |||
| 634 | row.kind === "turn-fold", | 634 | row.kind === "turn-fold", |
| 635 | ); | 635 | ); |
| 636 | // User message (00:00:00) → trailing work entry (00:00:12). | 636 | // User message (00:00:00) → trailing work entry (00:00:12). |
| 637 | expect(foldRow?.turnId).toBe("turn-1"); | 637 | expect(foldRow?.runId).toBe("turn-1"); |
| 638 | expect(foldRow?.label).toBe("Worked for 12s"); | 638 | expect(foldRow?.label).toBe("Worked for 12s"); |
| 639 | }); | 639 | }); |
| 640 | 640 | ||
| @@ -648,15 +648,15 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 648 | entry: { | 648 | entry: { |
| 649 | id: "work-1", | 649 | id: "work-1", |
| 650 | createdAt: "2026-01-01T00:00:05Z", | 650 | createdAt: "2026-01-01T00:00:05Z", |
| 651 | turnId: "turn-1" as never, | 651 | runId: "turn-1" as never, |
| 652 | label: "Ran command", | 652 | label: "Ran command", |
| 653 | tone: "tool" as const, | 653 | tone: "tool" as const, |
| 654 | }, | 654 | }, |
| 655 | }, | 655 | }, |
| 656 | ], | 656 | ], |
| 657 | latestTurn: { | 657 | latestRun: { |
| 658 | turnId: "turn-1" as never, | 658 | runId: "turn-1" as never, |
| 659 | state: "interrupted", | 659 | status: "interrupted", |
| 660 | startedAt: "2026-01-01T00:00:00Z", | 660 | startedAt: "2026-01-01T00:00:00Z", |
| 661 | completedAt: "2026-01-01T00:00:47Z", | 661 | completedAt: "2026-01-01T00:00:47Z", |
| 662 | }, | 662 | }, |
| @@ -669,15 +669,88 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | |||
| 669 | expect(rows).toEqual([ | 669 | expect(rows).toEqual([ |
| 670 | expect.objectContaining({ | 670 | expect.objectContaining({ |
| 671 | kind: "turn-fold", | 671 | kind: "turn-fold", |
| 672 | turnId: "turn-1", | 672 | runId: "turn-1", |
| 673 | label: "You stopped after 47s", | 673 | label: "You stopped after 47s", |
| 674 | expanded: false, | 674 | expanded: false, |
| 675 | }), | 675 | }), |
| 676 | ]); | 676 | ]); |
| 677 | }); | 677 | }); |
| 678 | 678 | ||
| 679 | it("keeps interruption request, intervening work, and result visible in order", () => { | ||
| 680 | const runId = "turn-1" as never; | ||
| 681 | const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({ | ||
| 682 | position: type === "run_interrupt_request" ? 0 : 2, | ||
| 683 | visibility: "local" as const, | ||
| 684 | sourceThreadId: "thread-1" as never, | ||
| 685 | sourceItemId: `item-${type}` as never, | ||
| 686 | item: { | ||
| 687 | id: `item-${type}`, | ||
| 688 | threadId: "thread-1", | ||
| 689 | runId, | ||
| 690 | nodeId: null, | ||
| 691 | providerThreadId: null, | ||
| 692 | providerTurnId: null, | ||
| 693 | nativeItemRef: null, | ||
| 694 | parentItemId: null, | ||
| 695 | ordinal: type === "run_interrupt_request" ? 0 : 2, | ||
| 696 | status: "completed", | ||
| 697 | title: null, | ||
| 698 | startedAt: null, | ||
| 699 | completedAt: null, | ||
| 700 | updatedAt: {}, | ||
| 701 | type, | ||
| 702 | message: type === "run_interrupt_request" ? "Stopping" : "Stopped", | ||
| 703 | }, | ||
| 704 | }); | ||
| 705 | const rows = deriveMessagesTimelineRows({ | ||
| 706 | timelineEntries: [ | ||
| 707 | { | ||
| 708 | id: "interrupt-request", | ||
| 709 | kind: "event", | ||
| 710 | createdAt: "2026-01-01T00:00:01Z", | ||
| 711 | projectedItem: interruptEvent("run_interrupt_request") as never, | ||
| 712 | }, | ||
| 713 | { | ||
| 714 | id: "work-entry", | ||
| 715 | kind: "work", | ||
| 716 | createdAt: "2026-01-01T00:00:02Z", | ||
| 717 | entry: { | ||
| 718 | id: "work-1", | ||
| 719 | createdAt: "2026-01-01T00:00:02Z", | ||
| 720 | runId, | ||
| 721 | label: "Finishing tool output", | ||
| 722 | tone: "tool", | ||
| 723 | }, | ||
| 724 | }, | ||
| 725 | { | ||
| 726 | id: "interrupt-result", | ||
| 727 | kind: "event", | ||
| 728 | createdAt: "2026-01-01T00:00:03Z", | ||
| 729 | projectedItem: interruptEvent("run_interrupt_result") as never, | ||
| 730 | }, | ||
| 731 | ], | ||
| 732 | latestRun: { | ||
| 733 | runId, | ||
| 734 | status: "interrupted", | ||
| 735 | startedAt: "2026-01-01T00:00:00Z", | ||
| 736 | completedAt: "2026-01-01T00:00:03Z", | ||
| 737 | }, | ||
| 738 | isWorking: false, | ||
| 739 | activeTurnStartedAt: null, | ||
| 740 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 741 | revertTurnCountByUserMessageId: new Map(), | ||
| 742 | }); | ||
| 743 | |||
| 744 | expect(rows.map((row) => row.id)).toEqual([ | ||
| 745 | "interrupt-request", | ||
| 746 | "work-entry", | ||
| 747 | "interrupt-result", | ||
| 748 | ]); | ||
| 749 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); | ||
| 750 | }); | ||
| 751 | |||
| 679 | it("keeps the previous turn folded while a newly sent message awaits its turn", () => { | 752 | it("keeps the previous turn folded while a newly sent message awaits its turn", () => { |
| 680 | // Right after send, isWorking is true but latestTurn still points at the | 753 | // Right after send, isWorking is true but latestRun still points at the |
| 681 | // previous, settled turn — it must stay folded through that window. | 754 | // previous, settled turn — it must stay folded through that window. |
| 682 | const rows = deriveMessagesTimelineRows({ | 755 | const rows = deriveMessagesTimelineRows({ |
| 683 | timelineEntries: [ | 756 | timelineEntries: [ |
| @@ -688,7 +761,7 @@ describe("deriveMessagesTimelineRows", () => { · 4 unmodified lines | |||
| 688 | entry: { | 761 | entry: { |
| 689 | id: "work-1", | 762 | id: "work-1", |
| 690 | createdAt: "2026-01-01T00:00:05Z", | 763 | createdAt: "2026-01-01T00:00:05Z", |
| 691 | turnId: "turn-1" as never, | 764 | runId: "turn-1" as never, |
| 692 | label: "Ran command", | 765 | label: "Ran command", |
| 693 | tone: "tool" as const, | 766 | tone: "tool" as const, |
| 694 | }, | 767 | }, |
| @@ -701,7 +774,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | |||
| 701 | id: "assistant-final" as never, | 774 | id: "assistant-final" as never, |
| 702 | role: "assistant", | 775 | role: "assistant", |
| 703 | text: "Done", | 776 | text: "Done", |
| 704 | turnId: "turn-1" as never, | 777 | runId: "turn-1" as never, |
| 705 | createdAt: "2026-01-01T00:00:20Z", | 778 | createdAt: "2026-01-01T00:00:20Z", |
| 706 | updatedAt: "2026-01-01T00:00:22Z", | 779 | updatedAt: "2026-01-01T00:00:22Z", |
| 707 | streaming: false, | 780 | streaming: false, |
| @@ -715,16 +788,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 715 | id: "user-followup" as never, | 788 | id: "user-followup" as never, |
| 716 | role: "user", | 789 | role: "user", |
| 717 | text: "yooo", | 790 | text: "yooo", |
| 718 | turnId: null, | 791 | runId: null, |
| 719 | createdAt: "2026-01-01T00:01:00Z", | 792 | createdAt: "2026-01-01T00:01:00Z", |
| 720 | updatedAt: "2026-01-01T00:01:00Z", | 793 | updatedAt: "2026-01-01T00:01:00Z", |
| 721 | streaming: false, | 794 | streaming: false, |
| 722 | }, | 795 | }, |
| 723 | }, | 796 | }, |
| 724 | ], | 797 | ], |
| 725 | latestTurn: { | 798 | latestRun: { |
| 726 | turnId: "turn-1" as never, | 799 | runId: "turn-1" as never, |
| 727 | state: "completed", | 800 | status: "completed", |
| 728 | startedAt: "2026-01-01T00:00:00Z", | 801 | startedAt: "2026-01-01T00:00:00Z", |
| 729 | completedAt: "2026-01-01T00:00:22Z", | 802 | completedAt: "2026-01-01T00:00:22Z", |
| 730 | }, | 803 | }, |
| @@ -755,7 +828,7 @@ describe("deriveMessagesTimelineRows", () => { · 24 unmodified lines | |||
| 755 | id: "assistant-thought" as never, | 828 | id: "assistant-thought" as never, |
| 756 | role: "assistant", | 829 | role: "assistant", |
| 757 | text: "Working on it.", | 830 | text: "Working on it.", |
| 758 | turnId: "turn-1" as never, | 831 | runId: "turn-1" as never, |
| 759 | createdAt: "2026-01-01T00:00:05Z", | 832 | createdAt: "2026-01-01T00:00:05Z", |
| 760 | updatedAt: "2026-01-01T00:00:06Z", | 833 | updatedAt: "2026-01-01T00:00:06Z", |
| 761 | streaming: false, | 834 | streaming: false, |
| @@ -768,15 +841,15 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | |||
| 768 | entry: { | 841 | entry: { |
| 769 | id: "work-1", | 842 | id: "work-1", |
| 770 | createdAt: "2026-01-01T00:00:08Z", | 843 | createdAt: "2026-01-01T00:00:08Z", |
| 771 | turnId: "turn-1" as never, | 844 | runId: "turn-1" as never, |
| 772 | label: "Ran command", | 845 | label: "Ran command", |
| 773 | tone: "tool" as const, | 846 | tone: "tool" as const, |
| 774 | }, | 847 | }, |
| 775 | }, | 848 | }, |
| 776 | ], | 849 | ], |
| 777 | latestTurn: { | 850 | latestRun: { |
| 778 | turnId: "turn-1" as never, | 851 | runId: "turn-1" as never, |
| 779 | state: "running", | 852 | status: "running", |
| 780 | startedAt: "2026-01-01T00:00:00Z", | 853 | startedAt: "2026-01-01T00:00:00Z", |
| 781 | completedAt: null, | 854 | completedAt: null, |
| 782 | }, | 855 | }, |
| @@ -866,7 +939,7 @@ describe("deriveMessagesTimelineRows", () => { · 83 unmodified lines | |||
| 866 | id: "assistant-thought" as never, | 939 | id: "assistant-thought" as never, |
| 867 | role: "assistant", | 940 | role: "assistant", |
| 868 | text: "Checking first.", | 941 | text: "Checking first.", |
| 869 | turnId: "turn-1" as never, | 942 | runId: "turn-1" as never, |
| 870 | createdAt: "2026-01-01T00:00:10Z", | 943 | createdAt: "2026-01-01T00:00:10Z", |
| 871 | updatedAt: "2026-01-01T00:00:11Z", | 944 | updatedAt: "2026-01-01T00:00:11Z", |
| 872 | streaming: false, | 945 | streaming: false, |
| @@ -880,14 +953,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | |||
| 880 | id: "assistant-final" as never, | 953 | id: "assistant-final" as never, |
| 881 | role: "assistant", | 954 | role: "assistant", |
| 882 | text: "Done.", | 955 | text: "Done.", |
| 883 | turnId: "turn-1" as never, | 956 | runId: "turn-1" as never, |
| 884 | createdAt: "2026-01-01T00:00:20Z", | 957 | createdAt: "2026-01-01T00:00:20Z", |
| 885 | updatedAt: "2026-01-01T00:00:30Z", | 958 | updatedAt: "2026-01-01T00:00:30Z", |
| 886 | streaming: false, | 959 | streaming: false, |
| 887 | }, | 960 | }, |
| 888 | }, | 961 | }, |
| 889 | ], | 962 | ], |
| 890 | expandedTurnIds: new Set(["turn-1" as never]), | 963 | expandedRunIds: new Set(["turn-1" as never]), |
| 891 | isWorking: false, | 964 | isWorking: false, |
| 892 | activeTurnStartedAt: null, | 965 | activeTurnStartedAt: null, |
| 893 | turnDiffSummaryByAssistantMessageId: new Map(), | 966 | turnDiffSummaryByAssistantMessageId: new Map(), |
| @@ -913,16 +986,16 @@ describe("deriveMessagesTimelineRows", () => { · 19 unmodified lines | |||
| 913 | id: "assistant-thought" as never, | 986 | id: "assistant-thought" as never, |
| 914 | role: "assistant", | 987 | role: "assistant", |
| 915 | text: "Working on it.", | 988 | text: "Working on it.", |
| 916 | turnId: "turn-1" as never, | 989 | runId: "turn-1" as never, |
| 917 | createdAt: "2026-01-01T00:00:10Z", | 990 | createdAt: "2026-01-01T00:00:10Z", |
| 918 | updatedAt: "2026-01-01T00:00:11Z", | 991 | updatedAt: "2026-01-01T00:00:11Z", |
| 919 | streaming: false, | 992 | streaming: false, |
| 920 | }, | 993 | }, |
| 921 | }, | 994 | }, |
| 922 | ], | 995 | ], |
| 923 | latestTurn: { | 996 | latestRun: { |
| 924 | turnId: "turn-1" as never, | 997 | runId: "turn-1" as never, |
| 925 | state: "running", | 998 | status: "running", |
| 926 | startedAt: "2026-01-01T00:00:00Z", | 999 | startedAt: "2026-01-01T00:00:00Z", |
| 927 | completedAt: null, | 1000 | completedAt: null, |
| 928 | }, | 1001 | }, |
| @@ -1019,7 +1092,7 @@ describe("computeStableMessagesTimelineRows", () => { · 90 unmodified lines | |||
| 1019 | id: "user-1" as never, | 1092 | id: "user-1" as never, |
| 1020 | role: "user" as const, | 1093 | role: "user" as const, |
| 1021 | text: "First", | 1094 | text: "First", |
| 1022 | turnId: null, | 1095 | runId: null, |
| 1023 | createdAt: "2026-01-01T00:00:00Z", | 1096 | createdAt: "2026-01-01T00:00:00Z", |
| 1024 | updatedAt: "2026-01-01T00:00:00Z", | 1097 | updatedAt: "2026-01-01T00:00:00Z", |
| 1025 | streaming: false, | 1098 | streaming: false, |
| @@ -1028,7 +1101,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines | |||
| 1028 | id: "user-2" as never, | 1101 | id: "user-2" as never, |
| 1029 | role: "user" as const, | 1102 | role: "user" as const, |
| 1030 | text: "Second", | 1103 | text: "Second", |
| 1031 | turnId: null, | 1104 | runId: null, |
| 1032 | createdAt: "2026-01-01T00:00:10Z", | 1105 | createdAt: "2026-01-01T00:00:10Z", |
| 1033 | updatedAt: "2026-01-01T00:00:10Z", | 1106 | updatedAt: "2026-01-01T00:00:10Z", |
| 1034 | streaming: false, | 1107 | streaming: false, |
| @@ -1124,7 +1197,7 @@ describe("computeStableMessagesTimelineRows", () => { · 89 unmodified lines | |||
| 1124 | id: "user-1" as never, | 1197 | id: "user-1" as never, |
| 1125 | role: "user" as const, | 1198 | role: "user" as const, |
| 1126 | text: "First", | 1199 | text: "First", |
| 1127 | turnId: null, | 1200 | runId: null, |
| 1128 | createdAt: "2026-01-01T00:00:00Z", | 1201 | createdAt: "2026-01-01T00:00:00Z", |
| 1129 | updatedAt: "2026-01-01T00:00:00Z", | 1202 | updatedAt: "2026-01-01T00:00:00Z", |
| 1130 | streaming: false, | 1203 | streaming: false, |
| @@ -1133,7 +1206,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines | |||
| 1133 | id: "user-2" as never, | 1206 | id: "user-2" as never, |
| 1134 | role: "user" as const, | 1207 | role: "user" as const, |
| 1135 | text: "Second", | 1208 | text: "Second", |
| 1136 | turnId: null, | 1209 | runId: null, |
| 1137 | createdAt: "2026-01-01T00:00:10Z", | 1210 | createdAt: "2026-01-01T00:00:10Z", |
| 1138 | updatedAt: "2026-01-01T00:00:10Z", | 1211 | updatedAt: "2026-01-01T00:00:10Z", |
| 1139 | streaming: false, | 1212 | streaming: false, |
| @@ -7,7 +7,12 @@ import { · 6 unmodified lines | |||
| 7 | type WorkLogEntry, | 7 | type WorkLogEntry, |
| 8 | } from "../../session-logic"; | 8 | } from "../../session-logic"; |
| 9 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; | 9 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| 10 | import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; | 10 | import { |
| 11 | type MessageId, | ||
| 12 | type OrchestrationV2ProjectedTurnItem, | ||
| 13 | type RunId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell"; | ||
| 11 | 16 | ||
| 12 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | 17 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; |
| 13 | export const TIMELINE_MINIMAP_ITEM_SPACING = 8; | 18 | export const TIMELINE_MINIMAP_ITEM_SPACING = 8; |
| @@ -128,9 +133,9 @@ export interface TimelineDurationMessage { · 114 unmodified lines | |||
| 128 | streaming: boolean; | 133 | streaming: boolean; |
| 129 | } | 134 | } |
| 130 | 135 | ||
| 131 | export type TimelineLatestTurn = Pick< | 136 | export type TimelineLatestRun = Pick< |
| 132 | OrchestrationLatestTurn, | 137 | ThreadRunSummary, |
| 133 | "turnId" | "state" | "startedAt" | "completedAt" | 138 | "runId" | "status" | "startedAt" | "completedAt" |
| 134 | >; | 139 | >; |
| 135 | 140 | ||
| 136 | export type MessagesTimelineRow = | 141 | export type MessagesTimelineRow = |
| @@ -153,7 +158,7 @@ export type MessagesTimelineRow = · 16 unmodified lines | |||
| 153 | kind: "turn-fold"; | 158 | kind: "turn-fold"; |
| 154 | id: string; | 159 | id: string; |
| 155 | createdAt: string; | 160 | createdAt: string; |
| 156 | turnId: TurnId; | 161 | runId: RunId; |
| 157 | label: string; | 162 | label: string; |
| 158 | expanded: boolean; | 163 | expanded: boolean; |
| 159 | } | 164 | } |
| @@ -169,6 +174,12 @@ export type MessagesTimelineRow = · 9 unmodified lines | |||
| 169 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; | 174 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; |
| 170 | revertTurnCount?: number | undefined; | 175 | revertTurnCount?: number | undefined; |
| 171 | } | 176 | } |
| 177 | | { | ||
| 178 | kind: "event"; | ||
| 179 | id: string; | ||
| 180 | createdAt: string; | ||
| 181 | projectedItem: OrchestrationV2ProjectedTurnItem; | ||
| 182 | } | ||
| 172 | | { | 183 | | { |
| 173 | kind: "proposed-plan"; | 184 | kind: "proposed-plan"; |
| 174 | id: string; | 185 | id: string; |
| @@ -238,8 +249,8 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli · 63 unmodified lines | |||
| 238 | continue; | 249 | continue; |
| 239 | } | 250 | } |
| 240 | 251 | ||
| 241 | const responseKey = message.turnId | 252 | const responseKey = message.runId |
| 242 | ? `turn:${message.turnId}` | 253 | ? `turn:${message.runId}` |
| 243 | : `unkeyed:${nullTurnResponseIndex}`; | 254 | : `unkeyed:${nullTurnResponseIndex}`; |
| 244 | lastAssistantMessageIdByResponseKey.set(responseKey, message.id); | 255 | lastAssistantMessageIdByResponseKey.set(responseKey, message.id); |
| 245 | } | 256 | } |
| @@ -248,7 +259,7 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli · 2 unmodified lines | |||
| 248 | } | 259 | } |
| 249 | 260 | ||
| 250 | interface TurnFold { | 261 | interface TurnFold { |
| 251 | turnId: TurnId; | 262 | runId: RunId; |
| 252 | anchorEntryId: string; | 263 | anchorEntryId: string; |
| 253 | createdAt: string; | 264 | createdAt: string; |
| 254 | hiddenEntryIds: ReadonlySet<string>; | 265 | hiddenEntryIds: ReadonlySet<string>; |
| @@ -263,18 +274,16 @@ interface TurnFold { · 8 unmodified lines | |||
| 263 | * user sends a message, the previous turn is still the "active" one until the | 274 | * user sends a message, the previous turn is still the "active" one until the |
| 264 | * server creates the new turn, and folding must not flicker through that window. | 275 | * server creates the new turn, and folding must not flicker through that window. |
| 265 | */ | 276 | */ |
| 266 | function deriveUnsettledTurnId( | 277 | function deriveUnsettledRunId(latestRun: TimelineLatestRun | null): RunId | null { |
| 267 | latestTurn: TimelineLatestTurn | null, | 278 | if (!latestRun) { |
| 268 | runningTurnId: TurnId | null, | ||
| 269 | ): TurnId | null { | ||
| 270 | if (runningTurnId !== null) { | ||
| 271 | return runningTurnId; | ||
| 272 | } | ||
| 273 | if (!latestTurn) { | ||
| 274 | return null; | 279 | return null; |
| 275 | } | 280 | } |
| 276 | const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running"; | 281 | const isSettled = |
| 277 | return isSettled ? null : latestTurn.turnId; | 282 | latestRun.completedAt !== null && |
| 283 | latestRun.status !== "running" && | ||
| 284 | latestRun.status !== "starting" && | ||
| 285 | latestRun.status !== "waiting"; | ||
| 286 | return isSettled ? null : latestRun.runId; | ||
| 278 | } | 287 | } |
| 279 | 288 | ||
| 280 | /** | 289 | /** |
| @@ -285,9 +294,21 @@ function deriveUnsettledTurnId( · 4 unmodified lines | |||
| 285 | function deriveTurnFolds(input: { | 294 | function deriveTurnFolds(input: { |
| 286 | timelineEntries: ReadonlyArray<TimelineEntry>; | 295 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 287 | terminalAssistantMessageIds: ReadonlySet<string>; | 296 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 288 | latestTurn: TimelineLatestTurn | null; | 297 | latestRun: TimelineLatestRun | null; |
| 289 | unsettledTurnId: TurnId | null; | 298 | unsettledRunId: RunId | null; |
| 290 | }): ReadonlyMap<string, TurnFold> { | 299 | }): ReadonlyMap<string, TurnFold> { |
| 300 | const interruptedRunIds = new Set<RunId>(); | ||
| 301 | for (const entry of input.timelineEntries) { | ||
| 302 | if ( | ||
| 303 | entry.kind === "event" && | ||
| 304 | entry.projectedItem.item.runId !== null && | ||
| 305 | (entry.projectedItem.item.type === "run_interrupt_request" || | ||
| 306 | entry.projectedItem.item.type === "run_interrupt_result") | ||
| 307 | ) { | ||
| 308 | interruptedRunIds.add(entry.projectedItem.item.runId); | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 291 | interface TurnGroup { | 312 | interface TurnGroup { |
| 292 | entries: Array<TimelineEntry>; | 313 | entries: Array<TimelineEntry>; |
| 293 | terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null; | 314 | terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null; |
| @@ -300,7 +321,7 @@ function deriveTurnFolds(input: { · 6 unmodified lines | |||
| 300 | */ | 321 | */ |
| 301 | startBoundary: string | null; | 322 | startBoundary: string | null; |
| 302 | } | 323 | } |
| 303 | const groupsByTurnId = new Map<TurnId, TurnGroup>(); | 324 | const groupsByRunId = new Map<RunId, TurnGroup>(); |
| 304 | 325 | ||
| 305 | let pendingUserBoundary: string | null = null; | 326 | let pendingUserBoundary: string | null = null; |
| 306 | for (const entry of input.timelineEntries) { | 327 | for (const entry of input.timelineEntries) { |
| @@ -308,16 +329,16 @@ function deriveTurnFolds(input: { · 1 unmodified line | |||
| 308 | pendingUserBoundary = entry.message.createdAt; | 329 | pendingUserBoundary = entry.message.createdAt; |
| 309 | continue; | 330 | continue; |
| 310 | } | 331 | } |
| 311 | const turnId = | 332 | const runId = |
| 312 | entry.kind === "message" && entry.message.role === "assistant" | 333 | entry.kind === "message" && entry.message.role === "assistant" |
| 313 | ? (entry.message.turnId ?? null) | 334 | ? (entry.message.runId ?? null) |
| 314 | : entry.kind === "work" | 335 | : entry.kind === "work" |
| 315 | ? (entry.entry.turnId ?? null) | 336 | ? (entry.entry.runId ?? null) |
| 316 | : null; | 337 | : null; |
| 317 | if (!turnId) { | 338 | if (!runId) { |
| 318 | continue; | 339 | continue; |
| 319 | } | 340 | } |
| 320 | let group = groupsByTurnId.get(turnId); | 341 | let group = groupsByRunId.get(runId); |
| 321 | if (!group) { | 342 | if (!group) { |
| 322 | group = { | 343 | group = { |
| 323 | entries: [], | 344 | entries: [], |
| @@ -329,7 +350,7 @@ function deriveTurnFolds(input: { · 5 unmodified lines | |||
| 329 | startBoundary: pendingUserBoundary, | 350 | startBoundary: pendingUserBoundary, |
| 330 | }; | 351 | }; |
| 331 | pendingUserBoundary = null; | 352 | pendingUserBoundary = null; |
| 332 | groupsByTurnId.set(turnId, group); | 353 | groupsByRunId.set(runId, group); |
| 333 | } | 354 | } |
| 334 | group.entries.push(entry); | 355 | group.entries.push(entry); |
| 335 | if (entry.kind === "message") { | 356 | if (entry.kind === "message") { |
| @@ -343,8 +364,8 @@ function deriveTurnFolds(input: { · 7 unmodified lines | |||
| 343 | } | 364 | } |
| 344 | 365 | ||
| 345 | const foldsByAnchorEntryId = new Map<string, TurnFold>(); | 366 | const foldsByAnchorEntryId = new Map<string, TurnFold>(); |
| 346 | for (const [turnId, group] of groupsByTurnId) { | 367 | for (const [runId, group] of groupsByRunId) { |
| 347 | if (turnId === input.unsettledTurnId) { | 368 | if (runId === input.unsettledRunId || interruptedRunIds.has(runId)) { |
| 348 | continue; | 369 | continue; |
| 349 | } | 370 | } |
| 350 | if (group.hasStreamingMessage) { | 371 | if (group.hasStreamingMessage) { |
| @@ -367,16 +388,14 @@ function deriveTurnFolds(input: { · 16 unmodified lines | |||
| 367 | } | 388 | } |
| 368 | 389 | ||
| 369 | const isLatestInterruptedTurn = | 390 | const isLatestInterruptedTurn = |
| 370 | input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; | 391 | input.latestRun?.runId === runId && input.latestRun.status === "interrupted"; |
| 371 | // A turn cut short by a steer leaves trailing work entries behind its | 392 | // A turn cut short by a steer leaves trailing work entries behind its |
| 372 | // terminal message — take whichever ended last. | 393 | // terminal message — take whichever ended last. |
| 373 | const lastEntryEnd = | 394 | const lastEntryEnd = |
| 374 | lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; | 395 | lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; |
| 375 | const elapsedMs = | 396 | const elapsedMs = |
| 376 | input.latestTurn?.turnId === turnId && | 397 | input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt |
| 377 | input.latestTurn.startedAt && | 398 | ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) |
| 378 | input.latestTurn.completedAt | ||
| 379 | ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt) | ||
| 380 | : computeElapsedMs( | 399 | : computeElapsedMs( |
| 381 | group.startBoundary ?? firstEntry.createdAt, | 400 | group.startBoundary ?? firstEntry.createdAt, |
| 382 | maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? | 401 | maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? |
| @@ -392,7 +411,7 @@ function deriveTurnFolds(input: { · 9 unmodified lines | |||
| 392 | : "Worked"; | 411 | : "Worked"; |
| 393 | 412 | ||
| 394 | foldsByAnchorEntryId.set(firstEntry.id, { | 413 | foldsByAnchorEntryId.set(firstEntry.id, { |
| 395 | turnId, | 414 | runId, |
| 396 | anchorEntryId: firstEntry.id, | 415 | anchorEntryId: firstEntry.id, |
| 397 | createdAt: firstEntry.createdAt, | 416 | createdAt: firstEntry.createdAt, |
| 398 | hiddenEntryIds, | 417 | hiddenEntryIds, |
| @@ -404,10 +423,8 @@ function deriveTurnFolds(input: { · 5 unmodified lines | |||
| 404 | 423 | ||
| 405 | export function deriveMessagesTimelineRows(input: { | 424 | export function deriveMessagesTimelineRows(input: { |
| 406 | timelineEntries: ReadonlyArray<TimelineEntry>; | 425 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 407 | latestTurn?: TimelineLatestTurn | null; | 426 | latestRun?: TimelineLatestRun | null; |
| 408 | runningTurnId?: TurnId | null; | 427 | expandedRunIds?: ReadonlySet<RunId>; |
| 409 | expandedTurnIds?: ReadonlySet<TurnId>; | ||
| 410 | expandedWorkGroupIds?: ReadonlySet<string>; | ||
| 411 | isWorking: boolean; | 428 | isWorking: boolean; |
| 412 | activeTurnStartedAt: string | null; | 429 | activeTurnStartedAt: string | null; |
| 413 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; | 430 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; |
| @@ -418,19 +435,16 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines | |||
| 418 | input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), | 435 | input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), |
| 419 | ); | 436 | ); |
| 420 | const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); | 437 | const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); |
| 421 | const unsettledTurnId = deriveUnsettledTurnId( | 438 | const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null); |
| 422 | input.latestTurn ?? null, | ||
| 423 | input.runningTurnId ?? null, | ||
| 424 | ); | ||
| 425 | const foldsByAnchorEntryId = deriveTurnFolds({ | 439 | const foldsByAnchorEntryId = deriveTurnFolds({ |
| 426 | timelineEntries: input.timelineEntries, | 440 | timelineEntries: input.timelineEntries, |
| 427 | terminalAssistantMessageIds, | 441 | terminalAssistantMessageIds, |
| 428 | latestTurn: input.latestTurn ?? null, | 442 | latestRun: input.latestRun ?? null, |
| 429 | unsettledTurnId, | 443 | unsettledRunId, |
| 430 | }); | 444 | }); |
| 431 | const collapsedEntryIds = new Set<string>(); | 445 | const collapsedEntryIds = new Set<string>(); |
| 432 | for (const fold of foldsByAnchorEntryId.values()) { | 446 | for (const fold of foldsByAnchorEntryId.values()) { |
| 433 | if (!input.expandedTurnIds?.has(fold.turnId)) { | 447 | if (!input.expandedRunIds?.has(fold.runId)) { |
| 434 | for (const entryId of fold.hiddenEntryIds) { | 448 | for (const entryId of fold.hiddenEntryIds) { |
| 435 | collapsedEntryIds.add(entryId); | 449 | collapsedEntryIds.add(entryId); |
| 436 | } | 450 | } |
| @@ -447,11 +461,11 @@ export function deriveMessagesTimelineRows(input: { · 10 unmodified lines | |||
| 447 | if (turnFold) { | 461 | if (turnFold) { |
| 448 | nextRows.push({ | 462 | nextRows.push({ |
| 449 | kind: "turn-fold", | 463 | kind: "turn-fold", |
| 450 | id: `turn-fold:${turnFold.turnId}`, | 464 | id: `turn-fold:${turnFold.runId}`, |
| 451 | createdAt: turnFold.createdAt, | 465 | createdAt: turnFold.createdAt, |
| 452 | turnId: turnFold.turnId, | 466 | runId: turnFold.runId, |
| 453 | label: turnFold.label, | 467 | label: turnFold.label, |
| 454 | expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, | 468 | expanded: input.expandedRunIds?.has(turnFold.runId) ?? false, |
| 455 | }); | 469 | }); |
| 456 | } | 470 | } |
| 457 | 471 | ||
| @@ -527,10 +541,20 @@ export function deriveMessagesTimelineRows(input: { · 69 unmodified lines | |||
| 527 | continue; | 541 | continue; |
| 528 | } | 542 | } |
| 529 | 543 | ||
| 544 | if (timelineEntry.kind === "event") { | ||
| 545 | nextRows.push({ | ||
| 546 | kind: "event", | ||
| 547 | id: timelineEntry.id, | ||
| 548 | createdAt: timelineEntry.createdAt, | ||
| 549 | projectedItem: timelineEntry.projectedItem, | ||
| 550 | }); | ||
| 551 | continue; | ||
| 552 | } | ||
| 553 | |||
| 530 | const assistantTurnStillInProgress = | 554 | const assistantTurnStillInProgress = |
| 531 | timelineEntry.message.role === "assistant" && | 555 | timelineEntry.message.role === "assistant" && |
| 532 | unsettledTurnId !== null && | 556 | unsettledRunId !== null && |
| 533 | timelineEntry.message.turnId === unsettledTurnId; | 557 | timelineEntry.message.runId === unsettledRunId; |
| 534 | 558 | ||
| 535 | const durationStart = | 559 | const durationStart = |
| 536 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt; | 560 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt; |
| @@ -610,6 +634,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 73 unmodified lines | |||
| 610 | case "proposed-plan": | 634 | case "proposed-plan": |
| 611 | return a.proposedPlan === (b as typeof a).proposedPlan; | 635 | return a.proposedPlan === (b as typeof a).proposedPlan; |
| 612 | 636 | ||
| 637 | case "event": | ||
| 638 | return a.projectedItem === (b as typeof a).projectedItem; | ||
| 639 | |||
| 613 | case "work": | 640 | case "work": |
| 614 | return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); | 641 | return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); |
| 615 | 642 | ||
| @@ -176,8 +176,7 @@ function buildProps() { · 175 unmodified lines | |||
| 176 | activeTurnInProgress: false, | 176 | activeTurnInProgress: false, |
| 177 | activeTurnStartedAt: null, | 177 | activeTurnStartedAt: null, |
| 178 | listRef: createRef<LegendListRef | null>(), | 178 | listRef: createRef<LegendListRef | null>(), |
| 179 | latestTurn: null, | 179 | latestRun: null, |
| 180 | runningTurnId: null, | ||
| 181 | turnDiffSummaryByAssistantMessageId: new Map(), | 180 | turnDiffSummaryByAssistantMessageId: new Map(), |
| 182 | routeThreadKey: "environment-local:thread-1", | 181 | routeThreadKey: "environment-local:thread-1", |
| 183 | onOpenTurnDiff: () => {}, | 182 | onOpenTurnDiff: () => {}, |
| @@ -214,7 +213,7 @@ function buildUserTimelineEntry(text: string) { · 30 unmodified lines | |||
| 214 | id: MessageId.make("message-1"), | 213 | id: MessageId.make("message-1"), |
| 215 | role: "user" as const, | 214 | role: "user" as const, |
| 216 | text, | 215 | text, |
| 217 | turnId: null, | 216 | runId: null, |
| 218 | createdAt: MESSAGE_CREATED_AT, | 217 | createdAt: MESSAGE_CREATED_AT, |
| 219 | updatedAt: MESSAGE_CREATED_AT, | 218 | updatedAt: MESSAGE_CREATED_AT, |
| 220 | streaming: false, | 219 | streaming: false, |
| @@ -527,7 +526,116 @@ describe("MessagesTimeline", () => { · 306 unmodified lines | |||
| 527 | expect(markup).toContain("Work Log"); | 526 | expect(markup).toContain("Work Log"); |
| 528 | }); | 527 | }); |
| 529 | 528 | ||
| 530 | it("formats changed file paths from the workspace root", () => { | 529 | it("renders V2 interruption lifecycle entries as standalone rows", async () => { |
| 530 | const { MessagesTimeline } = await import("./MessagesTimeline"); | ||
| 531 | const markup = renderToStaticMarkup( | ||
| 532 | <MessagesTimeline | ||
| 533 | {...buildProps()} | ||
| 534 | timelineEntries={[ | ||
| 535 | { | ||
| 536 | id: "interrupt-request", | ||
| 537 | kind: "event", | ||
| 538 | createdAt: MESSAGE_CREATED_AT, | ||
| 539 | projectedItem: { | ||
| 540 | position: 0, | ||
| 541 | visibility: "local", | ||
| 542 | sourceThreadId: "thread-1", | ||
| 543 | sourceItemId: "interrupt-request", | ||
| 544 | item: { | ||
| 545 | id: "interrupt-request", | ||
| 546 | threadId: "thread-1", | ||
| 547 | runId: "run-1", | ||
| 548 | nodeId: null, | ||
| 549 | providerThreadId: null, | ||
| 550 | providerTurnId: null, | ||
| 551 | nativeItemRef: null, | ||
| 552 | parentItemId: null, | ||
| 553 | ordinal: 0, | ||
| 554 | status: "completed", | ||
| 555 | title: null, | ||
| 556 | startedAt: null, | ||
| 557 | completedAt: null, | ||
| 558 | updatedAt: {}, | ||
| 559 | type: "run_interrupt_request", | ||
| 560 | message: "Waiting for the provider to stop.", | ||
| 561 | }, | ||
| 562 | } as never, | ||
| 563 | }, | ||
| 564 | ]} | ||
| 565 | />, | ||
| 566 | ); | ||
| 567 | |||
| 568 | expect(markup).toContain('data-v2-item-type="run_interrupt_request"'); | ||
| 569 | expect(markup).toContain("Interrupt requested"); | ||
| 570 | expect(markup).toContain("Waiting for the provider to stop."); | ||
| 571 | expect(markup).toContain('data-v2-structured-details="true"'); | ||
| 572 | expect(markup).toContain("Structured details"); | ||
| 573 | expect(markup).toContain(""type": "run_interrupt_request""); | ||
| 574 | }); | ||
| 575 | |||
| 576 | it("keeps inherited V2 work provenance on the rendered row", async () => { | ||
| 577 | const { MessagesTimeline } = await import("./MessagesTimeline"); | ||
| 578 | const item = { | ||
| 579 | id: "command-inherited", | ||
| 580 | threadId: "thread-source", | ||
| 581 | runId: null, | ||
| 582 | nodeId: null, | ||
| 583 | providerThreadId: null, | ||
| 584 | providerTurnId: null, | ||
| 585 | nativeItemRef: null, | ||
| 586 | parentItemId: null, | ||
| 587 | ordinal: 0, | ||
| 588 | status: "completed", | ||
| 589 | title: null, | ||
| 590 | startedAt: null, | ||
| 591 | completedAt: null, | ||
| 592 | updatedAt: {}, | ||
| 593 | type: "command_execution", | ||
| 594 | input: "pwd", | ||
| 595 | output: "/workspace", | ||
| 596 | exitCode: 0, | ||
| 597 | } as const; | ||
| 598 | const projectedItem = { | ||
| 599 | position: 0, | ||
| 600 | visibility: "inherited", | ||
| 601 | sourceThreadId: "thread-source", | ||
| 602 | sourceItemId: item.id, | ||
| 603 | item, | ||
| 604 | } as const; | ||
| 605 | const markup = renderToStaticMarkup( | ||
| 606 | <MessagesTimeline | ||
| 607 | {...buildProps()} | ||
| 608 | timelineEntries={ | ||
| 609 | [ | ||
| 610 | { | ||
| 611 | id: item.id, | ||
| 612 | kind: "work", | ||
| 613 | createdAt: MESSAGE_CREATED_AT, | ||
| 614 | entry: { | ||
| 615 | id: item.id, | ||
| 616 | createdAt: MESSAGE_CREATED_AT, | ||
| 617 | runId: null, | ||
| 618 | label: "Ran command", | ||
| 619 | command: item.input, | ||
| 620 | tone: "tool", | ||
| 621 | itemType: item.type, | ||
| 622 | toolLifecycleStatus: "completed", | ||
| 623 | structuredPayload: item, | ||
| 624 | projectedItem, | ||
| 625 | }, | ||
| 626 | }, | ||
| 627 | ] as never | ||
| 628 | } | ||
| 629 | />, | ||
| 630 | ); | ||
| 631 | |||
| 632 | expect(markup).toContain('data-v2-item-type="command_execution"'); | ||
| 633 | expect(markup).toContain('data-v2-item-visibility="inherited"'); | ||
| 634 | expect(markup).toContain("Inherited"); | ||
| 635 | }); | ||
| 636 | |||
| 637 | it("formats changed file paths from the workspace root", async () => { | ||
| 638 | const { MessagesTimeline } = await import("./MessagesTimeline"); | ||
| 531 | const markup = renderToStaticMarkup( | 639 | const markup = renderToStaticMarkup( |
| 532 | <MessagesTimeline | 640 | <MessagesTimeline |
| 533 | {...buildProps()} | 641 | {...buildProps()} |
| @@ -541,6 +649,8 @@ describe("MessagesTimeline", () => { · 7 unmodified lines | |||
| 541 | createdAt: "2026-03-17T19:12:28.000Z", | 649 | createdAt: "2026-03-17T19:12:28.000Z", |
| 542 | label: "Updated files", | 650 | label: "Updated files", |
| 543 | tone: "tool", | 651 | tone: "tool", |
| 652 | itemType: "file_change", | ||
| 653 | toolLifecycleStatus: "completed", | ||
| 544 | changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"], | 654 | changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"], |
| 545 | }, | 655 | }, |
| 546 | }, | 656 | }, |
| @@ -575,7 +685,7 @@ describe("MessagesTimeline", () => { · 28 unmodified lines | |||
| 575 | "```", | 685 | "```", |
| 576 | "</review_comment>", | 686 | "</review_comment>", |
| 577 | ].join("\n"), | 687 | ].join("\n"), |
| 578 | turnId: null, | 688 | runId: null, |
| 579 | createdAt: "2026-03-17T19:12:28.000Z", | 689 | createdAt: "2026-03-17T19:12:28.000Z", |
| 580 | updatedAt: "2026-03-17T19:12:28.000Z", | 690 | updatedAt: "2026-03-17T19:12:28.000Z", |
| 581 | streaming: false, | 691 | streaming: false, |
| @@ -614,7 +724,7 @@ describe("MessagesTimeline", () => { · 32 unmodified lines | |||
| 614 | "```", | 724 | "```", |
| 615 | "</review_comment>", | 725 | "</review_comment>", |
| 616 | ].join("\n"), | 726 | ].join("\n"), |
| 617 | turnId: null, | 727 | runId: null, |
| 618 | createdAt: "2026-03-17T19:12:28.000Z", | 728 | createdAt: "2026-03-17T19:12:28.000Z", |
| 619 | updatedAt: "2026-03-17T19:12:28.000Z", | 729 | updatedAt: "2026-03-17T19:12:28.000Z", |
| 620 | streaming: false, | 730 | streaming: false, |
| @@ -1,9 +1,10 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | type MessageId, | 3 | type MessageId, |
| 4 | type OrchestrationV2TurnItem, | ||
| 4 | type ScopedThreadRef, | 5 | type ScopedThreadRef, |
| 5 | type ServerProviderSkill, | 6 | type ServerProviderSkill, |
| 6 | type TurnId, | 7 | type RunId, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 9 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 10 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| @@ -78,8 +79,7 @@ import { · 68 unmodified lines | |||
| 78 | resolveTimelineMinimapTopPercent, | 79 | resolveTimelineMinimapTopPercent, |
| 79 | type StableMessagesTimelineRowsState, | 80 | type StableMessagesTimelineRowsState, |
| 80 | type MessagesTimelineRow, | 81 | type MessagesTimelineRow, |
| 81 | TIMELINE_MINIMAP_MIN_ITEMS, | 82 | type TimelineLatestRun, |
| 82 | type TimelineLatestTurn, | ||
| 83 | } from "./MessagesTimeline.logic"; | 83 | } from "./MessagesTimeline.logic"; |
| 84 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | 84 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 85 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 85 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| @@ -132,9 +132,8 @@ interface TimelineRowSharedState { · 46 unmodified lines | |||
| 132 | activeThreadEnvironmentId: EnvironmentId; | 132 | activeThreadEnvironmentId: EnvironmentId; |
| 133 | onRevertUserMessage: (messageId: MessageId) => void; | 133 | onRevertUserMessage: (messageId: MessageId) => void; |
| 134 | onImageExpand: (preview: ExpandedImagePreview) => void; | 134 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 135 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 135 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 136 | onToggleTurnFold: (turnId: TurnId) => void; | 136 | onToggleTurnFold: (runId: RunId) => void; |
| 137 | onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; | ||
| 138 | } | 137 | } |
| 139 | 138 | ||
| 140 | interface TimelineRowActivityState { | 139 | interface TimelineRowActivityState { |
| @@ -161,11 +160,10 @@ interface MessagesTimelineProps { · 20 unmodified lines | |||
| 161 | activeTurnStartedAt: string | null; | 160 | activeTurnStartedAt: string | null; |
| 162 | listRef: React.RefObject<LegendListRef | null>; | 161 | listRef: React.RefObject<LegendListRef | null>; |
| 163 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | 162 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 164 | latestTurn: TimelineLatestTurn | null; | 163 | latestRun: TimelineLatestRun | null; |
| 165 | runningTurnId: TurnId | null; | ||
| 166 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; | 164 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; |
| 167 | routeThreadKey: string; | 165 | routeThreadKey: string; |
| 168 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 166 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 169 | revertTurnCountByUserMessageId: Map<MessageId, number>; | 167 | revertTurnCountByUserMessageId: Map<MessageId, number>; |
| 170 | onRevertUserMessage: (messageId: MessageId) => void; | 168 | onRevertUserMessage: (messageId: MessageId) => void; |
| 171 | isRevertingCheckpoint: boolean; | 169 | isRevertingCheckpoint: boolean; |
| @@ -196,8 +194,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 24 unmodified lines | |||
| 196 | activeTurnStartedAt, | 194 | activeTurnStartedAt, |
| 197 | listRef, | 195 | listRef, |
| 198 | timelineEntries, | 196 | timelineEntries, |
| 199 | latestTurn, | 197 | latestRun, |
| 200 | runningTurnId, | ||
| 201 | turnDiffSummaryByAssistantMessageId, | 198 | turnDiffSummaryByAssistantMessageId, |
| 202 | routeThreadKey, | 199 | routeThreadKey, |
| 203 | onOpenTurnDiff, | 200 | onOpenTurnDiff, |
| @@ -220,17 +217,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines | |||
| 220 | hideEmptyPlaceholder = false, | 217 | hideEmptyPlaceholder = false, |
| 221 | topFadeEnabled = false, | 218 | topFadeEnabled = false, |
| 222 | }: MessagesTimelineProps) { | 219 | }: MessagesTimelineProps) { |
| 223 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); | 220 | const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set()); |
| 224 | const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); | 221 | |
| 225 | const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); | 222 | // Toggling a fold inserts/removes rows between the fold row and the final |
| 226 | 223 | // message — everything above the trigger is unchanged, so the trigger stays | |
| 227 | const onToggleTurnFold = useCallback((turnId: TurnId) => { | 224 | // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do |
| 228 | setExpandedTurnIds((existing) => { | 225 | // exactly that (pin the bottom content when row data changes while scrolled |
| 226 | // to the end), yanking the trigger out of view. Suppress it for the frames | ||
| 227 | // in which the toggle's data change and item measurements settle. | ||
| 228 | const [foldToggleSettling, setFoldToggleSettling] = useState(false); | ||
| 229 | const onToggleTurnFold = useCallback((runId: RunId) => { | ||
| 230 | setFoldToggleSettling(true); | ||
| 231 | setExpandedRunIds((existing) => { | ||
| 229 | const next = new Set(existing); | 232 | const next = new Set(existing); |
| 230 | if (next.has(turnId)) { | 233 | if (next.has(runId)) { |
| 231 | next.delete(turnId); | 234 | next.delete(runId); |
| 232 | } else { | 235 | } else { |
| 233 | next.add(turnId); | 236 | next.add(runId); |
| 234 | } | 237 | } |
| 235 | return next; | 238 | return next; |
| 236 | }); | 239 | }); |
| @@ -271,41 +274,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 34 unmodified lines | |||
| 271 | 274 | ||
| 272 | // An in-session interrupt leaves its turn expanded so the user keeps their | 275 | // An in-session interrupt leaves its turn expanded so the user keeps their |
| 273 | // place; the next turn (or a reload, since this is local state) folds it. | 276 | // place; the next turn (or a reload, since this is local state) folds it. |
| 274 | const previousLatestTurnRef = useRef(latestTurn); | 277 | const previousLatestRunRef = useRef(latestRun); |
| 275 | useEffect(() => { | 278 | useEffect(() => { |
| 276 | const previous = previousLatestTurnRef.current; | 279 | const previous = previousLatestRunRef.current; |
| 277 | previousLatestTurnRef.current = latestTurn; | 280 | previousLatestRunRef.current = latestRun; |
| 278 | if (!latestTurn || previous?.turnId === undefined) { | 281 | if (!latestRun || previous?.runId === undefined) { |
| 279 | return; | 282 | return; |
| 280 | } | 283 | } |
| 281 | if (latestTurn.turnId === previous.turnId) { | 284 | if (latestRun.runId === previous.runId) { |
| 282 | if (previous.state === "running" && latestTurn.state === "interrupted") { | 285 | if (previous.status === "running" && latestRun.status === "interrupted") { |
| 283 | setExpandedTurnIds((existing) => { | 286 | setExpandedRunIds((existing) => { |
| 284 | const next = new Set(existing); | 287 | const next = new Set(existing); |
| 285 | next.add(latestTurn.turnId); | 288 | next.add(latestRun.runId); |
| 286 | return next; | 289 | return next; |
| 287 | }); | 290 | }); |
| 288 | } | 291 | } |
| 289 | return; | 292 | return; |
| 290 | } | 293 | } |
| 291 | setExpandedTurnIds((existing) => { | 294 | setExpandedRunIds((existing) => { |
| 292 | if (!existing.has(previous.turnId)) { | 295 | if (!existing.has(previous.runId)) { |
| 293 | return existing; | 296 | return existing; |
| 294 | } | 297 | } |
| 295 | const next = new Set(existing); | 298 | const next = new Set(existing); |
| 296 | next.delete(previous.turnId); | 299 | next.delete(previous.runId); |
| 297 | return next; | 300 | return next; |
| 298 | }); | 301 | }); |
| 299 | }, [latestTurn]); | 302 | }, [latestRun]); |
| 300 | 303 | ||
| 301 | const rawRows = useMemo( | 304 | const rawRows = useMemo( |
| 302 | () => | 305 | () => |
| 303 | deriveMessagesTimelineRows({ | 306 | deriveMessagesTimelineRows({ |
| 304 | timelineEntries, | 307 | timelineEntries, |
| 305 | latestTurn, | 308 | latestRun, |
| 306 | runningTurnId, | 309 | expandedRunIds, |
| 307 | expandedTurnIds, | ||
| 308 | expandedWorkGroupIds, | ||
| 309 | isWorking, | 310 | isWorking, |
| 310 | activeTurnStartedAt, | 311 | activeTurnStartedAt, |
| 311 | turnDiffSummaryByAssistantMessageId, | 312 | turnDiffSummaryByAssistantMessageId, |
| @@ -313,10 +314,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line | |||
| 313 | }), | 314 | }), |
| 314 | [ | 315 | [ |
| 315 | timelineEntries, | 316 | timelineEntries, |
| 316 | latestTurn, | 317 | latestRun, |
| 317 | runningTurnId, | 318 | expandedRunIds, |
| 318 | expandedTurnIds, | ||
| 319 | expandedWorkGroupIds, | ||
| 320 | isWorking, | 319 | isWorking, |
| 321 | activeTurnStartedAt, | 320 | activeTurnStartedAt, |
| 322 | turnDiffSummaryByAssistantMessageId, | 321 | turnDiffSummaryByAssistantMessageId, |
| @@ -843,7 +842,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 520 unmodified lines | |||
| 843 | // they sit closer to the work that follows them. | 842 | // they sit closer to the work that follows them. |
| 844 | (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || | 843 | (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || |
| 845 | row.kind === "work" || | 844 | row.kind === "work" || |
| 846 | row.kind === "work-toggle" | 845 | row.kind === "event" |
| 847 | ? "pb-2" | 846 | ? "pb-2" |
| 848 | : "pb-4", | 847 | : "pb-4", |
| 849 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, | 848 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| @@ -861,6 +860,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 11 unmodified lines | |||
| 861 | <AssistantTimelineRow row={row} /> | 860 | <AssistantTimelineRow row={row} /> |
| 862 | ) : null} | 861 | ) : null} |
| 863 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} | 862 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 863 | {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null} | ||
| 864 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} | 864 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 865 | </div> | 865 | </div> |
| 866 | ); | 866 | ); |
| @@ -1005,7 +1005,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- · 138 unmodified lines | |||
| 1005 | type="button" | 1005 | type="button" |
| 1006 | aria-expanded={row.expanded} | 1006 | aria-expanded={row.expanded} |
| 1007 | data-scroll-anchor-ignore | 1007 | data-scroll-anchor-ignore |
| 1008 | onClick={() => ctx.onToggleTurnFold(row.turnId)} | 1008 | onClick={() => ctx.onToggleTurnFold(row.runId)} |
| 1009 | className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | 1009 | className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" |
| 1010 | > | 1010 | > |
| 1011 | <span>{row.label}</span> | 1011 | <span>{row.label}</span> |
| @@ -1091,6 +1091,173 @@ function ProposedPlanTimelineRow({ · 79 unmodified lines | |||
| 1091 | ); | 1091 | ); |
| 1092 | } | 1092 | } |
| 1093 | 1093 | ||
| 1094 | type V2EventTone = "muted" | "warning" | "danger" | "success"; | ||
| 1095 | |||
| 1096 | function v2EventPresentation(item: OrchestrationV2TurnItem): { | ||
| 1097 | readonly label: string; | ||
| 1098 | readonly detail: string | null; | ||
| 1099 | readonly tone: V2EventTone; | ||
| 1100 | readonly icon: typeof CircleAlertIcon; | ||
| 1101 | } { | ||
| 1102 | switch (item.type) { | ||
| 1103 | case "run_interrupt_request": | ||
| 1104 | return { | ||
| 1105 | label: "Interrupt requested", | ||
| 1106 | detail: item.message, | ||
| 1107 | tone: "warning", | ||
| 1108 | icon: CircleAlertIcon, | ||
| 1109 | }; | ||
| 1110 | case "run_interrupt_result": | ||
| 1111 | return { | ||
| 1112 | label: "Run interrupted", | ||
| 1113 | detail: item.message, | ||
| 1114 | tone: "danger", | ||
| 1115 | icon: XIcon, | ||
| 1116 | }; | ||
| 1117 | case "handoff": | ||
| 1118 | return { | ||
| 1119 | label: "Context handoff", | ||
| 1120 | detail: | ||
| 1121 | item.summary ?? | ||
| 1122 | `${item.fromProviderInstanceIds.join(", ")} → ${item.toProviderInstanceId}`, | ||
| 1123 | tone: item.status === "failed" ? "danger" : "muted", | ||
| 1124 | icon: ZapIcon, | ||
| 1125 | }; | ||
| 1126 | case "fork": | ||
| 1127 | return { | ||
| 1128 | label: "Conversation fork", | ||
| 1129 | detail: `Continues in ${item.targetThreadId}`, | ||
| 1130 | tone: "muted", | ||
| 1131 | icon: HammerIcon, | ||
| 1132 | }; | ||
| 1133 | case "compaction": { | ||
| 1134 | const tokenSummary = | ||
| 1135 | item.beforeTokenCount === undefined && item.afterTokenCount === undefined | ||
| 1136 | ? null | ||
| 1137 | : `${item.beforeTokenCount ?? "?"} → ${item.afterTokenCount ?? "?"} tokens`; | ||
| 1138 | return { | ||
| 1139 | label: "Context compacted", | ||
| 1140 | detail: item.summary ?? tokenSummary, | ||
| 1141 | tone: item.status === "failed" ? "danger" : "muted", | ||
| 1142 | icon: MinusIcon, | ||
| 1143 | }; | ||
| 1144 | } | ||
| 1145 | case "subagent": | ||
| 1146 | return { | ||
| 1147 | label: item.title ?? "Subagent", | ||
| 1148 | detail: item.result ?? item.prompt, | ||
| 1149 | tone: | ||
| 1150 | item.status === "failed" ? "danger" : item.status === "completed" ? "success" : "muted", | ||
| 1151 | icon: HammerIcon, | ||
| 1152 | }; | ||
| 1153 | case "approval_request": | ||
| 1154 | return { | ||
| 1155 | label: "Approval requested", | ||
| 1156 | detail: item.prompt ?? item.requestKind, | ||
| 1157 | tone: item.status === "failed" ? "danger" : "warning", | ||
| 1158 | icon: MessageCircleIcon, | ||
| 1159 | }; | ||
| 1160 | case "user_input_request": | ||
| 1161 | return { | ||
| 1162 | label: "Input requested", | ||
| 1163 | detail: item.questions.map((question) => question.question).join("\n"), | ||
| 1164 | tone: item.status === "failed" ? "danger" : "warning", | ||
| 1165 | icon: MessageCircleIcon, | ||
| 1166 | }; | ||
| 1167 | case "todo_list": { | ||
| 1168 | const steps = item.steps.map((step) => `${step.status}: ${step.text}`).join("\n"); | ||
| 1169 | return { | ||
| 1170 | label: "Plan updated", | ||
| 1171 | detail: [item.explanation, steps].filter(Boolean).join("\n\n") || null, | ||
| 1172 | tone: item.status === "failed" ? "danger" : "success", | ||
| 1173 | icon: CheckIcon, | ||
| 1174 | }; | ||
| 1175 | } | ||
| 1176 | default: | ||
| 1177 | return { | ||
| 1178 | label: item.title?.trim() || item.type.replaceAll("_", " "), | ||
| 1179 | detail: null, | ||
| 1180 | tone: item.status === "failed" ? "danger" : "muted", | ||
| 1181 | icon: WrenchIcon, | ||
| 1182 | }; | ||
| 1183 | } | ||
| 1184 | } | ||
| 1185 | |||
| 1186 | function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" }> }) { | ||
| 1187 | const ctx = use(TimelineRowCtx); | ||
| 1188 | const { item, visibility, sourceThreadId } = row.projectedItem; | ||
| 1189 | const presentation = v2EventPresentation(item); | ||
| 1190 | const Icon = presentation.icon; | ||
| 1191 | return ( | ||
| 1192 | <section | ||
| 1193 | className={cn( | ||
| 1194 | "rounded-lg border px-3 py-2", | ||
| 1195 | presentation.tone === "warning" && "border-amber-500/25 bg-amber-500/5", | ||
| 1196 | presentation.tone === "danger" && "border-destructive/25 bg-destructive/5", | ||
| 1197 | presentation.tone === "success" && "border-emerald-500/20 bg-emerald-500/5", | ||
| 1198 | presentation.tone === "muted" && "border-border/60 bg-card/30", | ||
| 1199 | )} | ||
| 1200 | data-v2-item-type={item.type} | ||
| 1201 | data-v2-item-visibility={visibility} | ||
| 1202 | > | ||
| 1203 | <div className="flex items-start gap-2.5"> | ||
| 1204 | <Icon | ||
| 1205 | className={cn( | ||
| 1206 | "mt-0.5 size-3.5 shrink-0", | ||
| 1207 | presentation.tone === "warning" && "text-amber-600 dark:text-amber-400", | ||
| 1208 | presentation.tone === "danger" && "text-destructive", | ||
| 1209 | presentation.tone === "success" && "text-emerald-600 dark:text-emerald-400", | ||
| 1210 | presentation.tone === "muted" && "text-muted-foreground", | ||
| 1211 | )} | ||
| 1212 | /> | ||
| 1213 | <div className="min-w-0 flex-1"> | ||
| 1214 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | ||
| 1215 | <span className="text-xs font-medium text-foreground/90">{presentation.label}</span> | ||
| 1216 | {item.status !== "completed" ? ( | ||
| 1217 | <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"> | ||
| 1218 | {item.status} | ||
| 1219 | </span> | ||
| 1220 | ) : null} | ||
| 1221 | {visibility !== "local" ? ( | ||
| 1222 | <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> | ||
| 1223 | {visibility === "inherited" ? "Inherited" : "Synthetic"} | ||
| 1224 | </span> | ||
| 1225 | ) : null} | ||
| 1226 | </div> | ||
| 1227 | {presentation.detail ? ( | ||
| 1228 | <div className="mt-1 text-xs leading-relaxed text-muted-foreground"> | ||
| 1229 | <ChatMarkdown | ||
| 1230 | text={presentation.detail} | ||
| 1231 | cwd={ctx.markdownCwd} | ||
| 1232 | threadRef={ctx.threadRef ?? undefined} | ||
| 1233 | skills={ctx.skills} | ||
| 1234 | lineBreaks | ||
| 1235 | /> | ||
| 1236 | </div> | ||
| 1237 | ) : null} | ||
| 1238 | {visibility === "inherited" ? ( | ||
| 1239 | <p className="mt-1 font-mono text-[10px] text-muted-foreground/65"> | ||
| 1240 | From {sourceThreadId} | ||
| 1241 | </p> | ||
| 1242 | ) : null} | ||
| 1243 | <details | ||
| 1244 | className="group/v2-details mt-2 rounded-md border border-border/50 bg-background/45" | ||
| 1245 | data-v2-structured-details="true" | ||
| 1246 | > | ||
| 1247 | <summary className="flex cursor-pointer list-none items-center gap-1.5 px-2 py-1.5 text-[11px] font-medium text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden"> | ||
| 1248 | <ChevronRightIcon className="size-3 transition-transform group-open/v2-details:rotate-90" /> | ||
| 1249 | Structured details | ||
| 1250 | </summary> | ||
| 1251 | <pre className="max-h-80 overflow-auto border-t border-border/50 px-2 py-2 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground"> | ||
| 1252 | {JSON.stringify(item, null, 2)} | ||
| 1253 | </pre> | ||
| 1254 | </details> | ||
| 1255 | </div> | ||
| 1256 | </div> | ||
| 1257 | </section> | ||
| 1258 | ); | ||
| 1259 | } | ||
| 1260 | |||
| 1094 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { | 1261 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1095 | return ( | 1262 | return ( |
| 1096 | <div className="py-0.5 pl-1.5"> | 1263 | <div className="py-0.5 pl-1.5"> |
| @@ -1244,7 +1411,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( · 147 unmodified lines | |||
| 1244 | turnSummary: TurnDiffSummary | undefined; | 1411 | turnSummary: TurnDiffSummary | undefined; |
| 1245 | routeThreadKey: string; | 1412 | routeThreadKey: string; |
| 1246 | resolvedTheme: "light" | "dark"; | 1413 | resolvedTheme: "light" | "dark"; |
| 1247 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 1414 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 1248 | }) { | 1415 | }) { |
| 1249 | if (!turnSummary) return null; | 1416 | if (!turnSummary) return null; |
| 1250 | const checkpointFiles = turnSummary.files; | 1417 | const checkpointFiles = turnSummary.files; |
| @@ -1274,12 +1441,10 @@ function AssistantChangedFilesSectionInner({ · 23 unmodified lines | |||
| 1274 | checkpointFiles: TurnDiffSummary["files"]; | 1441 | checkpointFiles: TurnDiffSummary["files"]; |
| 1275 | routeThreadKey: string; | 1442 | routeThreadKey: string; |
| 1276 | resolvedTheme: "light" | "dark"; | 1443 | resolvedTheme: "light" | "dark"; |
| 1277 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 1444 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 1278 | }) { | 1445 | }) { |
| 1279 | const activity = use(TimelineRowActivityCtx); | 1446 | const allDirectoriesExpanded = useUiStateStore( |
| 1280 | const isLatestTurn = activity.latestTurnId === turnSummary.turnId; | 1447 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.runId] ?? true, |
| 1281 | const persistedExpanded = useUiStateStore( | ||
| 1282 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], | ||
| 1283 | ); | 1448 | ); |
| 1284 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); | 1449 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); |
| 1285 | const [autoExpanded] = useState(() => | 1450 | const [autoExpanded] = useState(() => |
| @@ -1289,19 +1454,46 @@ function AssistantChangedFilesSectionInner({ · 3 unmodified lines | |||
| 1289 | const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); | 1454 | const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); |
| 1290 | 1455 | ||
| 1291 | return ( | 1456 | return ( |
| 1292 | <ChangedFilesCard | 1457 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 1293 | turnId={turnSummary.turnId} | 1458 | <div className="sticky top-2 z-10 mb-1.5 flex items-center justify-between gap-2 bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:absolute before:inset-x-0 before:-top-2 before:h-2 before:bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:content-['']"> |
| 1294 | files={checkpointFiles} | 1459 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> |
| 1295 | expanded={expanded} | 1460 | <span>Changed files ({changedFileCountLabel})</span> |
| 1296 | showCompactPreview={isLatestTurn} | 1461 | {hasNonZeroStat(summaryStat) && ( |
| 1297 | allDirectoriesExpanded={allDirectoriesExpanded} | 1462 | <> |
| 1298 | resolvedTheme={resolvedTheme} | 1463 | <span className="mx-1">•</span> |
| 1299 | onExpandedChange={(nextExpanded) => | 1464 | <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} /> |
| 1300 | setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) | 1465 | </> |
| 1301 | } | 1466 | )} |
| 1302 | onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} | 1467 | </p> |
| 1303 | onOpenTurnDiff={onOpenTurnDiff} | 1468 | <div className="flex items-center gap-1.5"> |
| 1304 | /> | 1469 | <Button |
| 1470 | type="button" | ||
| 1471 | size="xs" | ||
| 1472 | variant="outline" | ||
| 1473 | data-scroll-anchor-ignore | ||
| 1474 | onClick={() => setExpanded(routeThreadKey, turnSummary.runId, !allDirectoriesExpanded)} | ||
| 1475 | > | ||
| 1476 | {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | ||
| 1477 | </Button> | ||
| 1478 | <Button | ||
| 1479 | type="button" | ||
| 1480 | size="xs" | ||
| 1481 | variant="outline" | ||
| 1482 | onClick={() => onOpenTurnDiff(turnSummary.runId, checkpointFiles[0]?.path)} | ||
| 1483 | > | ||
| 1484 | View diff | ||
| 1485 | </Button> | ||
| 1486 | </div> | ||
| 1487 | </div> | ||
| 1488 | <ChangedFilesTree | ||
| 1489 | key={`changed-files-tree:${turnSummary.runId}`} | ||
| 1490 | runId={turnSummary.runId} | ||
| 1491 | files={checkpointFiles} | ||
| 1492 | allDirectoriesExpanded={allDirectoriesExpanded} | ||
| 1493 | resolvedTheme={resolvedTheme} | ||
| 1494 | onOpenTurnDiff={onOpenTurnDiff} | ||
| 1495 | /> | ||
| 1496 | </div> | ||
| 1305 | ); | 1497 | ); |
| 1306 | } | 1498 | } |
| 1307 | 1499 | ||
| @@ -1850,8 +2042,8 @@ function buildToolCallExpandedBody( · 542 unmodified lines | |||
| 1850 | workspaceRoot: string | undefined, | 2042 | workspaceRoot: string | undefined, |
| 1851 | ): string | null { | 2043 | ): string | null { |
| 1852 | const blocks: string[] = []; | 2044 | const blocks: string[] = []; |
| 1853 | if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { | 2045 | if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) { |
| 1854 | blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); | 2046 | blocks.push(`Tool call\n${JSON.stringify(workEntry.toolData, null, 2)}`); |
| 1855 | } | 2047 | } |
| 1856 | const raw = workEntryRawCommand(workEntry); | 2048 | const raw = workEntryRawCommand(workEntry); |
| 1857 | if (raw?.trim()) { | 2049 | if (raw?.trim()) { |
| @@ -1870,14 +2062,25 @@ function buildToolCallExpandedBody( · 12 unmodified lines | |||
| 1870 | .join("\n"), | 2062 | .join("\n"), |
| 1871 | ); | 2063 | ); |
| 1872 | } | 2064 | } |
| 2065 | if (workEntry.structuredPayload !== undefined) { | ||
| 2066 | const structured = JSON.stringify(workEntry.structuredPayload, null, 2); | ||
| 2067 | if (structured && !blocks.includes(structured)) { | ||
| 2068 | blocks.push(structured); | ||
| 2069 | } | ||
| 2070 | } | ||
| 2071 | if (workEntry.projectedItem?.visibility !== undefined) { | ||
| 2072 | const { visibility, sourceThreadId } = workEntry.projectedItem; | ||
| 2073 | if (visibility !== "local") { | ||
| 2074 | blocks.push( | ||
| 2075 | `${visibility === "inherited" ? "Inherited" : "Synthetic"} from ${sourceThreadId}`, | ||
| 2076 | ); | ||
| 2077 | } | ||
| 2078 | } | ||
| 1873 | return blocks.length > 0 ? blocks.join("\n\n") : null; | 2079 | return blocks.length > 0 ? blocks.join("\n\n") : null; |
| 1874 | } | 2080 | } |
| 1875 | 2081 | ||
| 1876 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { | 2082 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 1877 | if ( | 2083 | if (workEntry.itemType === "user_input_request") { |
| 1878 | workEntry.sourceActivityKind === "user-input.requested" || | ||
| 1879 | workEntry.sourceActivityKind === "user-input.resolved" | ||
| 1880 | ) { | ||
| 1881 | return "message-circle"; | 2084 | return "message-circle"; |
| 1882 | } | 2085 | } |
| 1883 | if (workEntry.requestKind === "command") return "terminal"; | 2086 | if (workEntry.requestKind === "command") return "terminal"; |
| @@ -1891,13 +2094,12 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { · 7 unmodified lines | |||
| 1891 | return "square-pen"; | 2094 | return "square-pen"; |
| 1892 | } | 2095 | } |
| 1893 | if (workEntry.itemType === "web_search") return "globe"; | 2096 | if (workEntry.itemType === "web_search") return "globe"; |
| 1894 | if (workEntry.itemType === "image_view") return "eye"; | 2097 | if (workEntry.itemType === "file_search") return "eye"; |
| 1895 | 2098 | ||
| 1896 | switch (workEntry.itemType) { | 2099 | switch (workEntry.itemType) { |
| 1897 | case "mcp_tool_call": | 2100 | case "dynamic_tool": |
| 1898 | return "wrench"; | 2101 | return "wrench"; |
| 1899 | case "dynamic_tool_call": | 2102 | case "subagent": |
| 1900 | case "collab_agent_tool_call": | ||
| 1901 | return "hammer"; | 2103 | return "hammer"; |
| 1902 | } | 2104 | } |
| 1903 | 2105 | ||
| @@ -1929,7 +2131,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 25 unmodified lines | |||
| 1929 | const activity = use(TimelineRowActivityCtx); | 2131 | const activity = use(TimelineRowActivityCtx); |
| 1930 | const [expanded, setExpanded] = useState(false); | 2132 | const [expanded, setExpanded] = useState(false); |
| 1931 | const iconConfig = workToneIcon(workEntry.tone); | 2133 | const iconConfig = workToneIcon(workEntry.tone); |
| 1932 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; | 2134 | const showWarningIndicator = false; |
| 1933 | const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); | 2135 | const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); |
| 1934 | const heading = toolWorkEntryHeading(workEntry); | 2136 | const heading = toolWorkEntryHeading(workEntry); |
| 1935 | const rawPreview = workEntryPreview(workEntry, workspaceRoot); | 2137 | const rawPreview = workEntryPreview(workEntry, workspaceRoot); |
| @@ -1943,9 +2145,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 7 unmodified lines | |||
| 1943 | const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); | 2145 | const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); |
| 1944 | const canExpand = expandedBody !== null; | 2146 | const canExpand = expandedBody !== null; |
| 1945 | const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); | 2147 | const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); |
| 1946 | const showDestructiveRowStyle = | 2148 | const showDestructiveRowStyle = showFailedIndicator && !workLogEntryIsToolLike(workEntry); |
| 1947 | showFailedIndicator && | ||
| 1948 | (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); | ||
| 1949 | const iconWrapperClass = cn( | 2149 | const iconWrapperClass = cn( |
| 1950 | "flex size-5 shrink-0 items-center justify-center", | 2150 | "flex size-5 shrink-0 items-center justify-center", |
| 1951 | showWarningIndicator | 2151 | showWarningIndicator |
| @@ -1988,6 +2188,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 36 unmodified lines | |||
| 1988 | canExpand && | 2188 | canExpand && |
| 1989 | "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | 2189 | "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", |
| 1990 | )} | 2190 | )} |
| 2191 | data-v2-item-type={workEntry.projectedItem?.item.type} | ||
| 2192 | data-v2-item-visibility={workEntry.projectedItem?.visibility} | ||
| 1991 | {...rowToggleProps} | 2193 | {...rowToggleProps} |
| 1992 | > | 2194 | > |
| 1993 | <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200"> | 2195 | <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200"> |
| @@ -2001,6 +2203,12 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 7 unmodified lines | |||
| 2001 | <div className="min-w-0 flex-1 overflow-hidden"> | 2203 | <div className="min-w-0 flex-1 overflow-hidden"> |
| 2002 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> | 2204 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> |
| 2003 | <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span> | 2205 | <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span> |
| 2206 | {workEntry.projectedItem?.visibility !== undefined && | ||
| 2207 | workEntry.projectedItem.visibility !== "local" ? ( | ||
| 2208 | <span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] leading-none text-muted-foreground"> | ||
| 2209 | {workEntry.projectedItem.visibility === "inherited" ? "Inherited" : "Synthetic"} | ||
| 2210 | </span> | ||
| 2211 | ) : null} | ||
| 2004 | {preview && ( | 2212 | {preview && ( |
| 2005 | <span className="min-w-0 flex-1 truncate text-muted-foreground/55">{preview}</span> | 2213 | <span className="min-w-0 flex-1 truncate text-muted-foreground/55">{preview}</span> |
| 2006 | )} | 2214 | )} |
| @@ -24,6 +24,7 @@ import { · 23 unmodified lines | |||
| 24 | type SidebarProjectGroupingMode, | 24 | type SidebarProjectGroupingMode, |
| 25 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 26 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 26 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 27 | import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 28 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 28 | import { | 29 | import { |
| 29 | isAtomCommandInterrupted, | 30 | isAtomCommandInterrupted, |
| @@ -2245,10 +2246,7 @@ export function ArchivedThreadsPanel() { · 2215 unmodified lines | |||
| 2245 | ), | 2246 | ), |
| 2246 | ); | 2247 | ); |
| 2247 | const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => | 2248 | const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => |
| 2248 | snapshot.threads.map((thread) => ({ | 2249 | snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)), |
| 2249 | ...thread, | ||
| 2250 | environmentId, | ||
| 2251 | })), | ||
| 2252 | ); | 2250 | ); |
| 2253 | 2251 | ||
| 2254 | const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); | 2252 | const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); |
| @@ -6,6 +6,10 @@ import { · 5 unmodified lines | |||
| 6 | ConnectionTargetStore, | 6 | ConnectionTargetStore, |
| 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| 8 | EnvironmentCacheStore, | 8 | EnvironmentCacheStore, |
| 9 | ORCHESTRATION_CACHE_SCHEMA_VERSION, | ||
| 10 | StoredOrchestrationShellSnapshot, | ||
| 11 | StoredOrchestrationThreadSnapshot, | ||
| 12 | decodeOrDiscardOrchestrationCache, | ||
| 9 | registerConnectionInCatalog, | 13 | registerConnectionInCatalog, |
| 10 | removeCatalogValue, | 14 | removeCatalogValue, |
| 11 | removeConnectionFromCatalog, | 15 | removeConnectionFromCatalog, |
| @@ -17,14 +21,7 @@ import { · 5 unmodified lines | |||
| 17 | CredentialStore, | 21 | CredentialStore, |
| 18 | ProfileStore, | 22 | ProfileStore, |
| 19 | } from "@t3tools/client-runtime/connection"; | 23 | } from "@t3tools/client-runtime/connection"; |
| 20 | import { | 24 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 21 | EnvironmentId, | ||
| 22 | OrchestrationShellSnapshot, | ||
| 23 | OrchestrationThreadDetailSnapshot, | ||
| 24 | ServerConfig, | ||
| 25 | ThreadId, | ||
| 26 | VcsListRefsResult, | ||
| 27 | } from "@t3tools/contracts"; | ||
| 28 | import * as Context from "effect/Context"; | 25 | import * as Context from "effect/Context"; |
| 29 | import * as Effect from "effect/Effect"; | 26 | import * as Effect from "effect/Effect"; |
| 30 | import * as Layer from "effect/Layer"; | 27 | import * as Layer from "effect/Layer"; |
| @@ -41,23 +38,9 @@ const THREAD_STORE_NAME = "thread"; · 10 unmodified lines | |||
| 41 | const SERVER_CONFIG_STORE_NAME = "server-config"; | 38 | const SERVER_CONFIG_STORE_NAME = "server-config"; |
| 42 | const VCS_REFS_STORE_NAME = "vcs-refs"; | 39 | const VCS_REFS_STORE_NAME = "vcs-refs"; |
| 43 | const CATALOG_KEY = "document"; | 40 | const CATALOG_KEY = "document"; |
| 44 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | 41 | const StoredShellSnapshot = StoredOrchestrationShellSnapshot; |
| 45 | |||
| 46 | const StoredShellSnapshot = Schema.Struct({ | ||
| 47 | schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | ||
| 48 | environmentId: EnvironmentId, | ||
| 49 | snapshot: OrchestrationShellSnapshot, | ||
| 50 | }); | ||
| 51 | const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); | 42 | const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); |
| 52 | // v2 stores the snapshot sequence alongside the thread so a warm cache can | 43 | const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; |
| 53 | // resume via `afterSequence` instead of re-downloading the full thread body. | ||
| 54 | // Older v1 entries (no sequence) fail to decode and are treated as a cold cache. | ||
| 55 | const StoredThreadSnapshot = Schema.Struct({ | ||
| 56 | schemaVersion: Schema.Literal(2), | ||
| 57 | environmentId: EnvironmentId, | ||
| 58 | threadId: ThreadId, | ||
| 59 | snapshot: OrchestrationThreadDetailSnapshot, | ||
| 60 | }); | ||
| 61 | const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); | 44 | const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); |
| 62 | const StoredServerConfig = Schema.Struct({ | 45 | const StoredServerConfig = Schema.Struct({ |
| 63 | schemaVersion: Schema.Literal(1), | 46 | schemaVersion: Schema.Literal(1), |
| @@ -469,25 +452,24 @@ export const connectionStorageLayer = Layer.effectContext( · 405 unmodified lines | |||
| 469 | if (typeof raw !== "string") { | 452 | if (typeof raw !== "string") { |
| 470 | return Effect.succeed(Option.none()); | 453 | return Effect.succeed(Option.none()); |
| 471 | } | 454 | } |
| 472 | return decodeStoredShellSnapshot(raw).pipe( | 455 | return decodeOrDiscardOrchestrationCache( |
| 473 | Effect.mapError((cause) => persistenceError("load-shell", cause)), | 456 | decodeStoredShellSnapshot(raw).pipe( |
| 474 | Effect.map((stored) => | 457 | Effect.mapError((cause) => persistenceError("load-shell", cause)), |
| 475 | stored.environmentId === environmentId | 458 | Effect.map((stored) => |
| 476 | ? Option.some(stored.snapshot) | 459 | stored.environmentId === environmentId |
| 477 | : Option.none(), | 460 | ? Option.some(stored.snapshot) |
| 461 | : Option.none(), | ||
| 462 | ), | ||
| 478 | ), | 463 | ), |
| 464 | removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), | ||
| 479 | ); | 465 | ); |
| 480 | }), | 466 | }), |
| 481 | Effect.mapError((cause) => | 467 | Effect.mapError((cause) => persistenceError("load-shell", cause)), |
| 482 | cause._tag === "ConnectionPersistenceError" | ||
| 483 | ? cause | ||
| 484 | : persistenceError("load-shell", cause), | ||
| 485 | ), | ||
| 486 | ), | 468 | ), |
| 487 | saveShell: (environmentId, snapshot) => | 469 | saveShell: (environmentId, snapshot) => |
| 488 | Effect.gen(function* () { | 470 | Effect.gen(function* () { |
| 489 | const encoded = yield* encodeStoredShellSnapshot({ | 471 | const encoded = yield* encodeStoredShellSnapshot({ |
| 490 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | 472 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, |
| 491 | environmentId, | 473 | environmentId, |
| 492 | snapshot, | 474 | snapshot, |
| 493 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); | 475 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); |
| @@ -543,33 +525,36 @@ export const connectionStorageLayer = Layer.effectContext( · 49 unmodified lines | |||
| 543 | if (typeof raw !== "string") { | 525 | if (typeof raw !== "string") { |
| 544 | return Effect.succeed(Option.none()); | 526 | return Effect.succeed(Option.none()); |
| 545 | } | 527 | } |
| 546 | return decodeStoredThreadSnapshot(raw).pipe( | 528 | return decodeOrDiscardOrchestrationCache( |
| 547 | Effect.mapError((cause) => persistenceError("load-thread", cause)), | 529 | decodeStoredThreadSnapshot(raw).pipe( |
| 548 | Effect.map((stored) => | 530 | Effect.mapError((cause) => persistenceError("load-thread", cause)), |
| 549 | stored.environmentId === environmentId && stored.threadId === threadId | 531 | Effect.map((stored) => |
| 550 | ? Option.some(stored.snapshot) | 532 | stored.environmentId === environmentId && stored.threadId === threadId |
| 551 | : Option.none(), | 533 | ? Option.some(stored.thread) |
| 534 | : Option.none(), | ||
| 535 | ), | ||
| 536 | ), | ||
| 537 | removeDatabaseValue( | ||
| 538 | database, | ||
| 539 | THREAD_STORE_NAME, | ||
| 540 | threadCacheKey(environmentId, threadId), | ||
| 552 | ), | 541 | ), |
| 553 | ); | 542 | ); |
| 554 | }), | 543 | }), |
| 555 | Effect.mapError((cause) => | 544 | Effect.mapError((cause) => persistenceError("load-thread", cause)), |
| 556 | cause._tag === "ConnectionPersistenceError" | ||
| 557 | ? cause | ||
| 558 | : persistenceError("load-thread", cause), | ||
| 559 | ), | ||
| 560 | ), | 545 | ), |
| 561 | saveThread: (environmentId, snapshot) => | 546 | saveThread: (environmentId, snapshot) => |
| 562 | Effect.gen(function* () { | 547 | Effect.gen(function* () { |
| 563 | const encoded = yield* encodeStoredThreadSnapshot({ | 548 | const encoded = yield* encodeStoredThreadSnapshot({ |
| 564 | schemaVersion: 2, | 549 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, |
| 565 | environmentId, | 550 | environmentId, |
| 566 | threadId: snapshot.thread.id, | 551 | threadId: thread.thread.id, |
| 567 | snapshot, | 552 | thread, |
| 568 | }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); | 553 | }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); |
| 569 | yield* writeDatabaseValue( | 554 | yield* writeDatabaseValue( |
| 570 | database, | 555 | database, |
| 571 | THREAD_STORE_NAME, | 556 | THREAD_STORE_NAME, |
| 572 | threadCacheKey(environmentId, snapshot.thread.id), | 557 | threadCacheKey(environmentId, thread.thread.id), |
| 573 | encoded, | 558 | encoded, |
| 574 | ); | 559 | ); |
| 575 | }).pipe( | 560 | }).pipe( |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ThreadId, RunId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore"; | 5 | import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore"; |
| @@ -31,7 +31,7 @@ describe("diffPanelStore", () => { · 25 unmodified lines | |||
| 31 | 31 | ||
| 32 | it("clears incompatible selection fields when changing scopes", () => { | 32 | it("clears incompatible selection fields when changing scopes", () => { |
| 33 | const store = useDiffPanelStore.getState(); | 33 | const store = useDiffPanelStore.getState(); |
| 34 | store.selectTurn(THREAD_REF, TurnId.make("turn-1"), "src/app.ts"); | 34 | store.selectTurn(THREAD_REF, RunId.make("turn-1"), "src/app.ts"); |
| 35 | store.selectGitScope(THREAD_REF, "unstaged"); | 35 | store.selectGitScope(THREAD_REF, "unstaged"); |
| 36 | 36 | ||
| 37 | expect( | 37 | expect( |
| @@ -45,7 +45,7 @@ describe("diffPanelStore", () => { · 7 unmodified lines | |||
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | it("increments the reveal request when opening the same turn file again", () => { | 47 | it("increments the reveal request when opening the same turn file again", () => { |
| 48 | const turnId = TurnId.make("turn-1"); | 48 | const turnId = RunId.make("turn-1"); |
| 49 | useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); | 49 | useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); |
| 50 | useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); | 50 | useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); |
| 51 | 51 | ||
| @@ -65,8 +65,8 @@ describe("diffPanelStore", () => { · 13 unmodified lines | |||
| 65 | }); | 65 | }); |
| 66 | 66 | ||
| 67 | it("reconciles a missing turn selection to the latest available turn", () => { | 67 | it("reconciles a missing turn selection to the latest available turn", () => { |
| 68 | const missingTurnId = TurnId.make("turn-missing"); | 68 | const missingTurnId = RunId.make("turn-missing"); |
| 69 | const latestTurnId = TurnId.make("turn-latest"); | 69 | const latestTurnId = RunId.make("turn-latest"); |
| 70 | useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts"); | 70 | useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts"); |
| 71 | useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]); | 71 | useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]); |
| 72 | 72 | ||
| @@ -1,5 +1,5 @@ | |||
| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 2 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; | 2 | import type { RunId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { create } from "zustand"; | 3 | import { create } from "zustand"; |
| 4 | import { createJSONStorage, persist } from "zustand/middleware"; | 4 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 5 | 5 | ||
| @@ -8,7 +8,7 @@ import { resolveStorage } from "./lib/storage"; · 2 unmodified lines | |||
| 8 | export type DiffPanelSelection = | 8 | export type DiffPanelSelection = |
| 9 | | { kind: "branch"; baseRef: string | null } | 9 | | { kind: "branch"; baseRef: string | null } |
| 10 | | { kind: "unstaged" } | 10 | | { kind: "unstaged" } |
| 11 | | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; | 11 | | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; |
| 12 | 12 | ||
| 13 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; | 13 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; |
| 14 | const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; | 14 | const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; |
| @@ -18,8 +18,8 @@ interface DiffPanelStoreState { · 3 unmodified lines | |||
| 18 | branchBaseRefByThreadKey: Record<string, string | null>; | 18 | branchBaseRefByThreadKey: Record<string, string | null>; |
| 19 | selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; | 19 | selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; |
| 20 | selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; | 20 | selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; |
| 21 | selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; | 21 | selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; |
| 22 | reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<TurnId>) => void; | 22 | reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<RunId>) => void; |
| 23 | removeThread: (ref: ScopedThreadRef) => void; | 23 | removeThread: (ref: ScopedThreadRef) => void; |
| 24 | } | 24 | } |
| 25 | 25 | ||
| @@ -119,7 +119,7 @@ export const useDiffPanelStore = create<DiffPanelStoreState>()( · 93 unmodified lines | |||
| 119 | }), | 119 | }), |
| 120 | { | 120 | { |
| 121 | name: "t3code:diff-panel-state:v1", | 121 | name: "t3code:diff-panel-state:v1", |
| 122 | version: 1, | 122 | version: 2, |
| 123 | storage: createJSONStorage(() => | 123 | storage: createJSONStorage(() => |
| 124 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), | 124 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), |
| 125 | ), | 125 | ), |
| @@ -14,7 +14,7 @@ describe("buildBootstrapInput", () => { · 13 unmodified lines | |||
| 14 | role: "user", | 14 | role: "user", |
| 15 | text: "hello", | 15 | text: "hello", |
| 16 | createdAt: "2026-02-09T00:00:00.000Z", | 16 | createdAt: "2026-02-09T00:00:00.000Z", |
| 17 | turnId: null, | 17 | runId: null, |
| 18 | updatedAt: "2026-02-09T00:00:00.000Z", | 18 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 19 | streaming: false, | 19 | streaming: false, |
| 20 | }, | 20 | }, |
| @@ -23,7 +23,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines | |||
| 23 | role: "assistant", | 23 | role: "assistant", |
| 24 | text: "world", | 24 | text: "world", |
| 25 | createdAt: "2026-02-09T00:00:01.000Z", | 25 | createdAt: "2026-02-09T00:00:01.000Z", |
| 26 | turnId: null, | 26 | runId: null, |
| 27 | updatedAt: "2026-02-09T00:00:01.000Z", | 27 | updatedAt: "2026-02-09T00:00:01.000Z", |
| 28 | streaming: false, | 28 | streaming: false, |
| 29 | }, | 29 | }, |
| @@ -49,7 +49,7 @@ describe("buildBootstrapInput", () => { · 19 unmodified lines | |||
| 49 | role: "user", | 49 | role: "user", |
| 50 | text: "first question with details", | 50 | text: "first question with details", |
| 51 | createdAt: "2026-02-09T00:00:00.000Z", | 51 | createdAt: "2026-02-09T00:00:00.000Z", |
| 52 | turnId: null, | 52 | runId: null, |
| 53 | updatedAt: "2026-02-09T00:00:00.000Z", | 53 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 54 | streaming: false, | 54 | streaming: false, |
| 55 | }, | 55 | }, |
| @@ -58,7 +58,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines | |||
| 58 | role: "assistant", | 58 | role: "assistant", |
| 59 | text: "first answer with details", | 59 | text: "first answer with details", |
| 60 | createdAt: "2026-02-09T00:00:01.000Z", | 60 | createdAt: "2026-02-09T00:00:01.000Z", |
| 61 | turnId: null, | 61 | runId: null, |
| 62 | updatedAt: "2026-02-09T00:00:01.000Z", | 62 | updatedAt: "2026-02-09T00:00:01.000Z", |
| 63 | streaming: false, | 63 | streaming: false, |
| 64 | }, | 64 | }, |
| @@ -67,7 +67,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines | |||
| 67 | role: "user", | 67 | role: "user", |
| 68 | text: "second question with details", | 68 | text: "second question with details", |
| 69 | createdAt: "2026-02-09T00:00:02.000Z", | 69 | createdAt: "2026-02-09T00:00:02.000Z", |
| 70 | turnId: null, | 70 | runId: null, |
| 71 | updatedAt: "2026-02-09T00:00:02.000Z", | 71 | updatedAt: "2026-02-09T00:00:02.000Z", |
| 72 | streaming: false, | 72 | streaming: false, |
| 73 | }, | 73 | }, |
| @@ -92,7 +92,7 @@ describe("buildBootstrapInput", () => { · 18 unmodified lines | |||
| 92 | role: "user", | 92 | role: "user", |
| 93 | text: "old context", | 93 | text: "old context", |
| 94 | createdAt: "2026-02-09T00:00:00.000Z", | 94 | createdAt: "2026-02-09T00:00:00.000Z", |
| 95 | turnId: null, | 95 | runId: null, |
| 96 | updatedAt: "2026-02-09T00:00:00.000Z", | 96 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 97 | streaming: false, | 97 | streaming: false, |
| 98 | }, | 98 | }, |
| @@ -124,7 +124,7 @@ describe("buildBootstrapInput", () => { · 25 unmodified lines | |||
| 124 | }, | 124 | }, |
| 125 | ], | 125 | ], |
| 126 | createdAt: "2026-02-09T00:00:00.000Z", | 126 | createdAt: "2026-02-09T00:00:00.000Z", |
| 127 | turnId: null, | 127 | runId: null, |
| 128 | updatedAt: "2026-02-09T00:00:00.000Z", | 128 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 129 | streaming: false, | 129 | streaming: false, |
| 130 | }, | 130 | }, |
| @@ -160,7 +160,7 @@ export function useThreadActions() { · 159 unmodified lines | |||
| 160 | const resolved = resolveThreadTarget(target); | 160 | const resolved = resolveThreadTarget(target); |
| 161 | if (!resolved) return AsyncResult.success(undefined); | 161 | if (!resolved) return AsyncResult.success(undefined); |
| 162 | const { thread, threadRef } = resolved; | 162 | const { thread, threadRef } = resolved; |
| 163 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { | 163 | if (thread.runtime?.status === "running" && thread.runtime.activeRunId != null) { |
| 164 | return AsyncResult.failure( | 164 | return AsyncResult.failure( |
| 165 | Cause.fail( | 165 | Cause.fail( |
| 166 | new ThreadArchiveBlockedError({ | 166 | new ThreadArchiveBlockedError({ |
| @@ -277,7 +277,7 @@ export function useThreadActions() { · 110 unmodified lines | |||
| 277 | shouldDeleteWorktree = confirmationResult.value; | 277 | shouldDeleteWorktree = confirmationResult.value; |
| 278 | } | 278 | } |
| 279 | 279 | ||
| 280 | if (thread.session && thread.session.status !== "stopped") { | 280 | if (thread.runtime !== null) { |
| 281 | await stopThreadSession({ | 281 | await stopThreadSession({ |
| 282 | environmentId: threadRef.environmentId, | 282 | environmentId: threadRef.environmentId, |
| 283 | input: { threadId: threadRef.threadId }, | 283 | input: { threadId: threadRef.threadId }, |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; | 2 | import { inferCheckpointTurnCountByRunId } from "../session-logic"; |
| 3 | import type { Thread, TurnDiffSummary } from "../types"; | 3 | import type { Thread, TurnDiffSummary } from "../types"; |
| 4 | 4 | ||
| 5 | export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { | 5 | export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { |
| @@ -10,10 +10,10 @@ export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { · 4 unmodified lines | |||
| 10 | return activeThread.checkpoints; | 10 | return activeThread.checkpoints; |
| 11 | }, [activeThread]); | 11 | }, [activeThread]); |
| 12 | 12 | ||
| 13 | const inferredCheckpointTurnCountByTurnId = useMemo( | 13 | const inferredCheckpointTurnCountByRunId = useMemo( |
| 14 | () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | 14 | () => inferCheckpointTurnCountByRunId(turnDiffSummaries), |
| 15 | [turnDiffSummaries], | 15 | [turnDiffSummaries], |
| 16 | ); | 16 | ); |
| 17 | 17 | ||
| 18 | return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId }; | 18 | return { turnDiffSummaries, inferredCheckpointTurnCountByRunId }; |
| 19 | } | 19 | } |
| @@ -1,84 +1,44 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; | 2 | import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; |
| 5 | 3 | ||
| 6 | function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity { | 4 | describe("V2 context window presentation", () => { |
| 7 | return { | 5 | it("uses retained compaction token data when available", () => { |
| 8 | id: EventId.make(id), | ||
| 9 | tone: "info", | ||
| 10 | kind, | ||
| 11 | summary: kind, | ||
| 12 | payload, | ||
| 13 | turnId: TurnId.make("turn-1"), | ||
| 14 | createdAt: "2026-03-23T00:00:00.000Z", | ||
| 15 | }; | ||
| 16 | } | ||
| 17 | |||
| 18 | describe("contextWindow", () => { | ||
| 19 | it("derives the latest valid context window snapshot", () => { | ||
| 20 | const snapshot = deriveLatestContextWindowSnapshot([ | ||
| 21 | makeActivity("activity-1", "context-window.updated", { | ||
| 22 | usedTokens: 1000, | ||
| 23 | }), | ||
| 24 | makeActivity("activity-2", "tool.started", {}), | ||
| 25 | makeActivity("activity-3", "context-window.updated", { | ||
| 26 | usedTokens: 14_000, | ||
| 27 | maxTokens: 258_000, | ||
| 28 | compactsAutomatically: true, | ||
| 29 | }), | ||
| 30 | ]); | ||
| 31 | |||
| 32 | expect(snapshot).not.toBeNull(); | ||
| 33 | expect(snapshot?.usedTokens).toBe(14_000); | ||
| 34 | expect(snapshot?.totalProcessedTokens).toBeNull(); | ||
| 35 | expect(snapshot?.maxTokens).toBe(258_000); | ||
| 36 | expect(snapshot?.compactsAutomatically).toBe(true); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("ignores malformed payloads", () => { | ||
| 40 | const snapshot = deriveLatestContextWindowSnapshot([ | ||
| 41 | makeActivity("activity-1", "context-window.updated", {}), | ||
| 42 | ]); | ||
| 43 | |||
| 44 | expect(snapshot).toBeNull(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("keeps valid zero-usage snapshots", () => { | ||
| 48 | const snapshot = deriveLatestContextWindowSnapshot([ | 6 | const snapshot = deriveLatestContextWindowSnapshot([ |
| 49 | makeActivity("activity-1", "context-window.updated", { | 7 | { |
| 50 | usedTokens: 0, | 8 | id: "compaction-1", |
| 51 | maxTokens: 100_000, | 9 | createdAt: "2026-06-20T00:00:00.000Z", |
| 52 | }), | 10 | runId: null, |
| 11 | label: "Context compacted", | ||
| 12 | tone: "info", | ||
| 13 | itemType: "compaction", | ||
| 14 | toolLifecycleStatus: "completed", | ||
| 15 | structuredPayload: { | ||
| 16 | id: "compaction-1" as never, | ||
| 17 | threadId: "thread-1" as never, | ||
| 18 | runId: null, | ||
| 19 | nodeId: null, | ||
| 20 | providerThreadId: null, | ||
| 21 | providerTurnId: null, | ||
| 22 | nativeItemRef: null, | ||
| 23 | parentItemId: null, | ||
| 24 | ordinal: 1, | ||
| 25 | status: "completed", | ||
| 26 | title: null, | ||
| 27 | startedAt: null, | ||
| 28 | completedAt: null, | ||
| 29 | updatedAt: {} as never, | ||
| 30 | type: "compaction", | ||
| 31 | driver: null, | ||
| 32 | beforeTokenCount: 10_000, | ||
| 33 | afterTokenCount: 2_000, | ||
| 34 | }, | ||
| 35 | }, | ||
| 53 | ]); | 36 | ]); |
| 54 | 37 | expect(snapshot?.usedTokens).toBe(2_000); | |
| 55 | expect(snapshot).toMatchObject({ | 38 | expect(snapshot?.totalProcessedTokens).toBe(10_000); |
| 56 | usedTokens: 0, | ||
| 57 | maxTokens: 100_000, | ||
| 58 | remainingTokens: 100_000, | ||
| 59 | usedPercentage: 0, | ||
| 60 | remainingPercentage: 100, | ||
| 61 | }); | ||
| 62 | }); | 39 | }); |
| 63 | 40 | ||
| 64 | it("formats compact token counts", () => { | 41 | it("formats compact token values", () => { |
| 65 | expect(formatContextWindowTokens(999)).toBe("999"); | 42 | expect(formatContextWindowTokens(1_500)).toBe("1.5k"); |
| 66 | expect(formatContextWindowTokens(1400)).toBe("1.4k"); | ||
| 67 | expect(formatContextWindowTokens(14_000)).toBe("14k"); | ||
| 68 | expect(formatContextWindowTokens(258_000)).toBe("258k"); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("includes total processed tokens when available", () => { | ||
| 72 | const snapshot = deriveLatestContextWindowSnapshot([ | ||
| 73 | makeActivity("activity-1", "context-window.updated", { | ||
| 74 | usedTokens: 81_659, | ||
| 75 | totalProcessedTokens: 748_126, | ||
| 76 | maxTokens: 258_400, | ||
| 77 | lastUsedTokens: 81_659, | ||
| 78 | }), | ||
| 79 | ]); | ||
| 80 | |||
| 81 | expect(snapshot?.usedTokens).toBe(81_659); | ||
| 82 | expect(snapshot?.totalProcessedTokens).toBe(748_126); | ||
| 83 | }); | 43 | }); |
| 84 | }); | 44 | }); |
| @@ -1,17 +1,10 @@ | |||
| 1 | import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; | 1 | import type { ThreadTokenUsageSnapshot } from "@t3tools/contracts"; |
| 2 | 2 | import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell"; | |
| 3 | function asRecord(value: unknown): Record<string, unknown> | null { | ||
| 4 | return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | ||
| 5 | } | ||
| 6 | 3 | ||
| 7 | function asFiniteNumber(value: unknown): number | null { | 4 | function asFiniteNumber(value: unknown): number | null { |
| 8 | return typeof value === "number" && Number.isFinite(value) ? value : null; | 5 | return typeof value === "number" && Number.isFinite(value) ? value : null; |
| 9 | } | 6 | } |
| 10 | 7 | ||
| 11 | function asBoolean(value: unknown): boolean | null { | ||
| 12 | return typeof value === "boolean" ? value : null; | ||
| 13 | } | ||
| 14 | |||
| 15 | type NullableContextWindowUsage = { | 8 | type NullableContextWindowUsage = { |
| 16 | readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] | 9 | readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] |
| 17 | ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null | 10 | ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null |
| @@ -48,21 +41,20 @@ export function formatProviderDisplayName(provider: string | null | undefined): · 30 unmodified lines | |||
| 48 | } | 41 | } |
| 49 | 42 | ||
| 50 | export function deriveLatestContextWindowSnapshot( | 43 | export function deriveLatestContextWindowSnapshot( |
| 51 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 44 | entries: ReadonlyArray<ThreadWorkEntry>, |
| 52 | ): ContextWindowSnapshot | null { | 45 | ): ContextWindowSnapshot | null { |
| 53 | for (let index = activities.length - 1; index >= 0; index -= 1) { | 46 | for (let index = entries.length - 1; index >= 0; index -= 1) { |
| 54 | const activity = activities[index]; | 47 | const entry = entries[index]; |
| 55 | if (!activity || activity.kind !== "context-window.updated") { | 48 | if (!entry || entry.structuredPayload.type !== "compaction") { |
| 56 | continue; | 49 | continue; |
| 57 | } | 50 | } |
| 58 | 51 | const payload = entry.structuredPayload; | |
| 59 | const payload = asRecord(activity.payload); | 52 | const usedTokens = asFiniteNumber(payload.afterTokenCount); |
| 60 | const usedTokens = asFiniteNumber(payload?.usedTokens); | ||
| 61 | if (usedTokens === null || usedTokens < 0) { | 53 | if (usedTokens === null || usedTokens < 0) { |
| 62 | continue; | 54 | continue; |
| 63 | } | 55 | } |
| 64 | 56 | ||
| 65 | const maxTokens = asFiniteNumber(payload?.maxTokens); | 57 | const maxTokens = null; |
| 66 | const usedPercentage = | 58 | const usedPercentage = |
| 67 | maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; | 59 | maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; |
| 68 | const remainingTokens = | 60 | const remainingTokens = |
| @@ -71,24 +63,24 @@ export function deriveLatestContextWindowSnapshot( · 2 unmodified lines | |||
| 71 | 63 | ||
| 72 | return { | 64 | return { |
| 73 | usedTokens, | 65 | usedTokens, |
| 74 | totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens), | 66 | totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount), |
| 75 | maxTokens, | 67 | maxTokens, |
| 76 | remainingTokens, | 68 | remainingTokens, |
| 77 | usedPercentage, | 69 | usedPercentage, |
| 78 | remainingPercentage, | 70 | remainingPercentage, |
| 79 | inputTokens: asFiniteNumber(payload?.inputTokens), | 71 | inputTokens: null, |
| 80 | cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens), | 72 | cachedInputTokens: null, |
| 81 | outputTokens: asFiniteNumber(payload?.outputTokens), | 73 | outputTokens: null, |
| 82 | reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens), | 74 | reasoningOutputTokens: null, |
| 83 | lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens), | 75 | lastUsedTokens: null, |
| 84 | lastInputTokens: asFiniteNumber(payload?.lastInputTokens), | 76 | lastInputTokens: null, |
| 85 | lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens), | 77 | lastCachedInputTokens: null, |
| 86 | lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens), | 78 | lastOutputTokens: null, |
| 87 | lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens), | 79 | lastReasoningOutputTokens: null, |
| 88 | toolUses: asFiniteNumber(payload?.toolUses), | 80 | toolUses: null, |
| 89 | durationMs: asFiniteNumber(payload?.durationMs), | 81 | durationMs: null, |
| 90 | compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, | 82 | compactsAutomatically: true, |
| 91 | updatedAt: activity.createdAt, | 83 | updatedAt: entry.createdAt, |
| 92 | }; | 84 | }; |
| 93 | } | 85 | } |
| 94 | 86 | ||
| @@ -7,13 +7,14 @@ import { · 6 unmodified lines | |||
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import type { Thread } from "../types"; | 9 | import type { Thread } from "../types"; |
| 10 | import { makeThreadFixture } from "../test-fixtures"; | ||
| 10 | import { getLatestThreadForProject, sortThreads } from "./threadSort"; | 11 | import { getLatestThreadForProject, sortThreads } from "./threadSort"; |
| 11 | 12 | ||
| 12 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | 13 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 13 | const PROJECT_ID = ProjectId.make("project-1"); | 14 | const PROJECT_ID = ProjectId.make("project-1"); |
| 14 | 15 | ||
| 15 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 16 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 16 | return { | 17 | return makeThreadFixture({ |
| 17 | id: ThreadId.make("thread-1"), | 18 | id: ThreadId.make("thread-1"), |
| 18 | environmentId: LOCAL_ENVIRONMENT_ID, | 19 | environmentId: LOCAL_ENVIRONMENT_ID, |
| 19 | projectId: PROJECT_ID, | 20 | projectId: PROJECT_ID, |
| @@ -21,7 +22,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line | |||
| 21 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 22 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 22 | runtimeMode: DEFAULT_RUNTIME_MODE, | 23 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 23 | interactionMode: "default", | 24 | interactionMode: "default", |
| 24 | session: null, | 25 | runtime: null, |
| 25 | messages: [], | 26 | messages: [], |
| 26 | proposedPlans: [], | 27 | proposedPlans: [], |
| 27 | createdAt: "2026-03-09T10:00:00.000Z", | 28 | createdAt: "2026-03-09T10:00:00.000Z", |
| @@ -30,13 +31,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines | |||
| 30 | settledAt: null, | 31 | settledAt: null, |
| 31 | deletedAt: null, | 32 | deletedAt: null, |
| 32 | updatedAt: "2026-03-09T10:00:00.000Z", | 33 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 33 | latestTurn: null, | 34 | latestRun: null, |
| 34 | branch: null, | 35 | branch: null, |
| 35 | worktreePath: null, | 36 | worktreePath: null, |
| 36 | checkpoints: [], | ||
| 37 | activities: [], | ||
| 38 | ...overrides, | 37 | ...overrides, |
| 39 | }; | 38 | }); |
| 40 | } | 39 | } |
| 41 | 40 | ||
| 42 | describe("sortThreads", () => { | 41 | describe("sortThreads", () => { |
| @@ -51,7 +50,7 @@ describe("sortThreads", () => { · 8 unmodified lines | |||
| 51 | id: "message-1" as never, | 50 | id: "message-1" as never, |
| 52 | role: "user", | 51 | role: "user", |
| 53 | text: "older", | 52 | text: "older", |
| 54 | turnId: null, | 53 | runId: null, |
| 55 | createdAt: "2026-03-09T10:01:00.000Z", | 54 | createdAt: "2026-03-09T10:01:00.000Z", |
| 56 | updatedAt: "2026-03-09T10:01:00.000Z", | 55 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 57 | streaming: false, | 56 | streaming: false, |
| @@ -67,7 +66,7 @@ describe("sortThreads", () => { · 9 unmodified lines | |||
| 67 | id: "message-2" as never, | 66 | id: "message-2" as never, |
| 68 | role: "user", | 67 | role: "user", |
| 69 | text: "newer", | 68 | text: "newer", |
| 70 | turnId: null, | 69 | runId: null, |
| 71 | createdAt: "2026-03-09T10:06:00.000Z", | 70 | createdAt: "2026-03-09T10:06:00.000Z", |
| 72 | updatedAt: "2026-03-09T10:06:00.000Z", | 71 | updatedAt: "2026-03-09T10:06:00.000Z", |
| 73 | streaming: false, | 72 | streaming: false, |
| @@ -95,7 +94,7 @@ describe("sortThreads", () => { · 21 unmodified lines | |||
| 95 | id: "message-1" as never, | 94 | id: "message-1" as never, |
| 96 | role: "assistant", | 95 | role: "assistant", |
| 97 | text: "assistant only", | 96 | text: "assistant only", |
| 98 | turnId: null, | 97 | runId: null, |
| 99 | createdAt: "2026-03-09T10:02:00.000Z", | 98 | createdAt: "2026-03-09T10:02:00.000Z", |
| 100 | updatedAt: "2026-03-09T10:02:00.000Z", | 99 | updatedAt: "2026-03-09T10:02:00.000Z", |
| 101 | streaming: false, | 100 | streaming: false, |
| @@ -1,1688 +1,159 @@ | |||
| 1 | import { | 1 | import { |
| 2 | EventId, | ||
| 3 | MessageId, | 2 | MessageId, |
| 3 | PlanId, | ||
| 4 | RunId, | ||
| 4 | ThreadId, | 5 | ThreadId, |
| 5 | TurnId, | 6 | TurnItemId, |
| 6 | type OrchestrationThreadActivity, | 7 | type OrchestrationV2ProjectedTurnItem, |
| 8 | type OrchestrationV2TurnItem, | ||
| 7 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | 11 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | 12 | ||
| 10 | import { | 13 | import { |
| 11 | deriveActiveWorkStartedAt, | ||
| 12 | deriveActivePlanState, | ||
| 13 | derivePendingApprovals, | ||
| 14 | derivePendingUserInputs, | ||
| 15 | deriveTimelineEntries, | 14 | deriveTimelineEntries, |
| 16 | deriveWorkLogEntries, | 15 | deriveTimelineEntriesFromVisibleTurnItems, |
| 17 | findLatestProposedPlan, | 16 | findLatestProposedPlan, |
| 18 | findSidebarProposedPlan, | 17 | isLatestRunSettled, |
| 19 | hasActionableProposedPlan, | ||
| 20 | isLatestTurnSettled, | ||
| 21 | workEntryIndicatesToolFailure, | ||
| 22 | workEntryIndicatesToolNeutralStatus, | ||
| 23 | workEntryIndicatesToolSuccess, | ||
| 24 | } from "./session-logic"; | 18 | } from "./session-logic"; |
| 25 | 19 | ||
| 26 | let nextActivityId = 0; | 20 | describe("V2 session presentation", () => { |
| 27 | 21 | it("uses run status as the settlement boundary", () => { | |
| 28 | function makeActivity(overrides: { | 22 | const runId = RunId.make("run-1"); |
| 29 | id?: string; | 23 | expect( |
| 30 | createdAt?: string; | 24 | isLatestRunSettled( |
| 31 | kind?: string; | 25 | { |
| 32 | summary?: string; | 26 | runId, |
| 33 | tone?: OrchestrationThreadActivity["tone"]; | 27 | status: "completed", |
| 34 | payload?: Record<string, unknown>; | 28 | startedAt: "2026-06-20T00:00:00.000Z", |
| 35 | turnId?: string; | 29 | completedAt: "2026-06-20T00:01:00.000Z", |
| 36 | sequence?: number; | ||
| 37 | }): OrchestrationThreadActivity { | ||
| 38 | const payload = overrides.payload ?? {}; | ||
| 39 | return { | ||
| 40 | id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`), | ||
| 41 | createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", | ||
| 42 | kind: overrides.kind ?? "tool.started", | ||
| 43 | summary: overrides.summary ?? "Tool call", | ||
| 44 | tone: overrides.tone ?? "tool", | ||
| 45 | payload, | ||
| 46 | turnId: overrides.turnId ? TurnId.make(overrides.turnId) : null, | ||
| 47 | ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}), | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | describe("derivePendingApprovals", () => { | ||
| 52 | it("tracks open approvals and removes resolved ones", () => { | ||
| 53 | const activities: OrchestrationThreadActivity[] = [ | ||
| 54 | makeActivity({ | ||
| 55 | id: "approval-open", | ||
| 56 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 57 | kind: "approval.requested", | ||
| 58 | summary: "Command approval requested", | ||
| 59 | tone: "approval", | ||
| 60 | payload: { | ||
| 61 | requestId: "req-1", | ||
| 62 | requestKind: "command", | ||
| 63 | detail: "bun run lint", | ||
| 64 | }, | ||
| 65 | }), | ||
| 66 | makeActivity({ | ||
| 67 | id: "approval-close", | ||
| 68 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 69 | kind: "approval.resolved", | ||
| 70 | summary: "Approval resolved", | ||
| 71 | tone: "info", | ||
| 72 | payload: { requestId: "req-2" }, | ||
| 73 | }), | ||
| 74 | makeActivity({ | ||
| 75 | id: "approval-closed-request", | ||
| 76 | createdAt: "2026-02-23T00:00:01.500Z", | ||
| 77 | kind: "approval.requested", | ||
| 78 | summary: "File-change approval requested", | ||
| 79 | tone: "approval", | ||
| 80 | payload: { requestId: "req-2", requestKind: "file-change" }, | ||
| 81 | }), | ||
| 82 | ]; | ||
| 83 | |||
| 84 | expect(derivePendingApprovals(activities)).toEqual([ | ||
| 85 | { | ||
| 86 | requestId: "req-1", | ||
| 87 | requestKind: "command", | ||
| 88 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 89 | detail: "bun run lint", | ||
| 90 | }, | ||
| 91 | ]); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("maps canonical requestType payloads into pending approvals", () => { | ||
| 95 | const activities: OrchestrationThreadActivity[] = [ | ||
| 96 | makeActivity({ | ||
| 97 | id: "approval-open-request-type", | ||
| 98 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 99 | kind: "approval.requested", | ||
| 100 | summary: "Command approval requested", | ||
| 101 | tone: "approval", | ||
| 102 | payload: { | ||
| 103 | requestId: "req-request-type", | ||
| 104 | requestType: "command_execution_approval", | ||
| 105 | detail: "pwd", | ||
| 106 | }, | ||
| 107 | }), | ||
| 108 | ]; | ||
| 109 | |||
| 110 | expect(derivePendingApprovals(activities)).toEqual([ | ||
| 111 | { | ||
| 112 | requestId: "req-request-type", | ||
| 113 | requestKind: "command", | ||
| 114 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 115 | detail: "pwd", | ||
| 116 | }, | ||
| 117 | ]); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("clears stale pending approvals when provider reports unknown pending request", () => { | ||
| 121 | const activities: OrchestrationThreadActivity[] = [ | ||
| 122 | makeActivity({ | ||
| 123 | id: "approval-open-stale", | ||
| 124 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 125 | kind: "approval.requested", | ||
| 126 | summary: "Command approval requested", | ||
| 127 | tone: "approval", | ||
| 128 | payload: { | ||
| 129 | requestId: "req-stale-1", | ||
| 130 | requestKind: "command", | ||
| 131 | }, | ||
| 132 | }), | ||
| 133 | makeActivity({ | ||
| 134 | id: "approval-failed-stale", | ||
| 135 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 136 | kind: "provider.approval.respond.failed", | ||
| 137 | summary: "Provider approval response failed", | ||
| 138 | tone: "error", | ||
| 139 | payload: { | ||
| 140 | requestId: "req-stale-1", | ||
| 141 | detail: "Unknown pending permission request: req-stale-1", | ||
| 142 | }, | ||
| 143 | }), | ||
| 144 | ]; | ||
| 145 | |||
| 146 | expect(derivePendingApprovals(activities)).toEqual([]); | ||
| 147 | }); | ||
| 148 | |||
| 149 | it("clears stale pending approvals when the backend marks them stale after restart", () => { | ||
| 150 | const activities: OrchestrationThreadActivity[] = [ | ||
| 151 | makeActivity({ | ||
| 152 | id: "approval-open-stale-restart", | ||
| 153 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 154 | kind: "approval.requested", | ||
| 155 | summary: "Command approval requested", | ||
| 156 | tone: "approval", | ||
| 157 | payload: { | ||
| 158 | requestId: "req-stale-restart-1", | ||
| 159 | requestKind: "command", | ||
| 160 | }, | ||
| 161 | }), | ||
| 162 | makeActivity({ | ||
| 163 | id: "approval-failed-stale-restart", | ||
| 164 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 165 | kind: "provider.approval.respond.failed", | ||
| 166 | summary: "Provider approval response failed", | ||
| 167 | tone: "error", | ||
| 168 | payload: { | ||
| 169 | requestId: "req-stale-restart-1", | ||
| 170 | detail: | ||
| 171 | "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", | ||
| 172 | }, | ||
| 173 | }), | ||
| 174 | ]; | ||
| 175 | |||
| 176 | expect(derivePendingApprovals(activities)).toEqual([]); | ||
| 177 | }); | ||
| 178 | }); | ||
| 179 | |||
| 180 | describe("derivePendingUserInputs", () => { | ||
| 181 | it("tracks open structured prompts and removes resolved ones", () => { | ||
| 182 | const activities: OrchestrationThreadActivity[] = [ | ||
| 183 | makeActivity({ | ||
| 184 | id: "user-input-open", | ||
| 185 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 186 | kind: "user-input.requested", | ||
| 187 | summary: "User input requested", | ||
| 188 | tone: "info", | ||
| 189 | payload: { | ||
| 190 | requestId: "req-user-input-1", | ||
| 191 | questions: [ | ||
| 192 | { | ||
| 193 | id: "sandbox_mode", | ||
| 194 | header: "Sandbox", | ||
| 195 | question: "Which mode should be used?", | ||
| 196 | options: [ | ||
| 197 | { | ||
| 198 | label: "workspace-write", | ||
| 199 | description: "Allow workspace writes only", | ||
| 200 | }, | ||
| 201 | ], | ||
| 202 | multiSelect: true, | ||
| 203 | }, | ||
| 204 | ], | ||
| 205 | }, | ||
| 206 | }), | ||
| 207 | makeActivity({ | ||
| 208 | id: "user-input-resolved", | ||
| 209 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 210 | kind: "user-input.resolved", | ||
| 211 | summary: "User input submitted", | ||
| 212 | tone: "info", | ||
| 213 | payload: { | ||
| 214 | requestId: "req-user-input-2", | ||
| 215 | answers: { | ||
| 216 | sandbox_mode: "workspace-write", | ||
| 217 | }, | ||
| 218 | }, | ||
| 219 | }), | ||
| 220 | makeActivity({ | ||
| 221 | id: "user-input-open-2", | ||
| 222 | createdAt: "2026-02-23T00:00:01.500Z", | ||
| 223 | kind: "user-input.requested", | ||
| 224 | summary: "User input requested", | ||
| 225 | tone: "info", | ||
| 226 | payload: { | ||
| 227 | requestId: "req-user-input-2", | ||
| 228 | questions: [ | ||
| 229 | { | ||
| 230 | id: "approval", | ||
| 231 | header: "Approval", | ||
| 232 | question: "Continue?", | ||
| 233 | options: [ | ||
| 234 | { | ||
| 235 | label: "yes", | ||
| 236 | description: "Continue execution", | ||
| 237 | }, | ||
| 238 | ], | ||
| 239 | multiSelect: false, | ||
| 240 | }, | ||
| 241 | ], | ||
| 242 | }, | ||
| 243 | }), | ||
| 244 | ]; | ||
| 245 | |||
| 246 | expect(derivePendingUserInputs(activities)).toEqual([ | ||
| 247 | { | ||
| 248 | requestId: "req-user-input-1", | ||
| 249 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 250 | questions: [ | ||
| 251 | { | ||
| 252 | id: "sandbox_mode", | ||
| 253 | header: "Sandbox", | ||
| 254 | question: "Which mode should be used?", | ||
| 255 | options: [ | ||
| 256 | { | ||
| 257 | label: "workspace-write", | ||
| 258 | description: "Allow workspace writes only", | ||
| 259 | }, | ||
| 260 | ], | ||
| 261 | multiSelect: true, | ||
| 262 | }, | ||
| 263 | ], | ||
| 264 | }, | ||
| 265 | ]); | ||
| 266 | }); | ||
| 267 | |||
| 268 | it("clears stale pending user-input prompts when the provider reports an orphaned request", () => { | ||
| 269 | const activities: OrchestrationThreadActivity[] = [ | ||
| 270 | makeActivity({ | ||
| 271 | id: "user-input-open-stale", | ||
| 272 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 273 | kind: "user-input.requested", | ||
| 274 | summary: "User input requested", | ||
| 275 | tone: "info", | ||
| 276 | payload: { | ||
| 277 | requestId: "req-user-input-stale-1", | ||
| 278 | questions: [ | ||
| 279 | { | ||
| 280 | id: "sandbox_mode", | ||
| 281 | header: "Sandbox", | ||
| 282 | question: "Which mode should be used?", | ||
| 283 | options: [ | ||
| 284 | { | ||
| 285 | label: "workspace-write", | ||
| 286 | description: "Allow workspace writes only", | ||
| 287 | }, | ||
| 288 | ], | ||
| 289 | multiSelect: false, | ||
| 290 | }, | ||
| 291 | ], | ||
| 292 | }, | ||
| 293 | }), | ||
| 294 | makeActivity({ | ||
| 295 | id: "user-input-failed-stale", | ||
| 296 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 297 | kind: "provider.user-input.respond.failed", | ||
| 298 | summary: "Provider user input response failed", | ||
| 299 | tone: "error", | ||
| 300 | payload: { | ||
| 301 | requestId: "req-user-input-stale-1", | ||
| 302 | detail: | ||
| 303 | "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1", | ||
| 304 | }, | ||
| 305 | }), | ||
| 306 | ]; | ||
| 307 | |||
| 308 | expect(derivePendingUserInputs(activities)).toEqual([]); | ||
| 309 | }); | ||
| 310 | }); | ||
| 311 | |||
| 312 | describe("deriveActivePlanState", () => { | ||
| 313 | it("returns the latest plan update for the active turn", () => { | ||
| 314 | const activities: OrchestrationThreadActivity[] = [ | ||
| 315 | makeActivity({ | ||
| 316 | id: "plan-old", | ||
| 317 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 318 | kind: "turn.plan.updated", | ||
| 319 | summary: "Plan updated", | ||
| 320 | tone: "info", | ||
| 321 | turnId: "turn-1", | ||
| 322 | payload: { | ||
| 323 | explanation: "Initial plan", | ||
| 324 | plan: [{ step: "Inspect code", status: "pending" }], | ||
| 325 | }, | ||
| 326 | }), | ||
| 327 | makeActivity({ | ||
| 328 | id: "plan-latest", | ||
| 329 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 330 | kind: "turn.plan.updated", | ||
| 331 | summary: "Plan updated", | ||
| 332 | tone: "info", | ||
| 333 | turnId: "turn-1", | ||
| 334 | payload: { | ||
| 335 | explanation: "Refined plan", | ||
| 336 | plan: [{ step: "Implement Codex user input", status: "inProgress" }], | ||
| 337 | }, | ||
| 338 | }), | ||
| 339 | ]; | ||
| 340 | |||
| 341 | expect(deriveActivePlanState(activities, TurnId.make("turn-1"))).toEqual({ | ||
| 342 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 343 | turnId: "turn-1", | ||
| 344 | explanation: "Refined plan", | ||
| 345 | steps: [{ step: "Implement Codex user input", status: "inProgress" }], | ||
| 346 | }); | ||
| 347 | }); | ||
| 348 | |||
| 349 | it("falls back to the most recent plan from a previous turn", () => { | ||
| 350 | const activities: OrchestrationThreadActivity[] = [ | ||
| 351 | makeActivity({ | ||
| 352 | id: "plan-from-turn-1", | ||
| 353 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 354 | kind: "turn.plan.updated", | ||
| 355 | summary: "Plan updated", | ||
| 356 | tone: "info", | ||
| 357 | turnId: "turn-1", | ||
| 358 | payload: { | ||
| 359 | plan: [{ step: "Write tests", status: "completed" }], | ||
| 360 | }, | 30 | }, |
| 361 | }), | 31 | null, |
| 362 | ]; | 32 | ), |
| 363 | 33 | ).toBe(true); | |
| 364 | // Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan | ||
| 365 | const result = deriveActivePlanState(activities, TurnId.make("turn-2")); | ||
| 366 | expect(result).toEqual({ | ||
| 367 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 368 | turnId: "turn-1", | ||
| 369 | steps: [{ step: "Write tests", status: "completed" }], | ||
| 370 | }); | ||
| 371 | }); | ||
| 372 | }); | ||
| 373 | |||
| 374 | describe("findLatestProposedPlan", () => { | ||
| 375 | it("prefers the latest proposed plan for the active turn", () => { | ||
| 376 | expect( | 34 | expect( |
| 377 | findLatestProposedPlan( | 35 | isLatestRunSettled( |
| 378 | [ | 36 | { runId, status: "running", startedAt: null, completedAt: null }, |
| 379 | { | 37 | { status: "running", activeRunId: runId }, |
| 380 | id: "plan:thread-1:turn:turn-1", | ||
| 381 | turnId: TurnId.make("turn-1"), | ||
| 382 | planMarkdown: "# Older", | ||
| 383 | implementedAt: null, | ||
| 384 | implementationThreadId: null, | ||
| 385 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 386 | updatedAt: "2026-02-23T00:00:01.000Z", | ||
| 387 | }, | ||
| 388 | { | ||
| 389 | id: "plan:thread-1:turn:turn-1", | ||
| 390 | turnId: TurnId.make("turn-1"), | ||
| 391 | planMarkdown: "# Latest", | ||
| 392 | implementedAt: null, | ||
| 393 | implementationThreadId: null, | ||
| 394 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 395 | updatedAt: "2026-02-23T00:00:02.000Z", | ||
| 396 | }, | ||
| 397 | { | ||
| 398 | id: "plan:thread-1:turn:turn-2", | ||
| 399 | turnId: TurnId.make("turn-2"), | ||
| 400 | planMarkdown: "# Different turn", | ||
| 401 | implementedAt: null, | ||
| 402 | implementationThreadId: null, | ||
| 403 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 404 | updatedAt: "2026-02-23T00:00:03.000Z", | ||
| 405 | }, | ||
| 406 | ], | ||
| 407 | TurnId.make("turn-1"), | ||
| 408 | ), | 38 | ), |
| 409 | ).toEqual({ | 39 | ).toBe(false); |
| 410 | id: "plan:thread-1:turn:turn-1", | ||
| 411 | turnId: "turn-1", | ||
| 412 | planMarkdown: "# Latest", | ||
| 413 | implementedAt: null, | ||
| 414 | implementationThreadId: null, | ||
| 415 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 416 | updatedAt: "2026-02-23T00:00:02.000Z", | ||
| 417 | }); | ||
| 418 | }); | 40 | }); |
| 419 | 41 | ||
| 420 | it("falls back to the most recently updated proposed plan", () => { | 42 | it("selects the latest proposed plan for a run", () => { |
| 421 | const latestPlan = findLatestProposedPlan( | 43 | const runId = RunId.make("run-1"); |
| 44 | const plan = findLatestProposedPlan( | ||
| 422 | [ | 45 | [ |
| 423 | { | 46 | { |
| 424 | id: "plan:thread-1:turn:turn-1", | 47 | id: PlanId.make("plan-1"), |
| 425 | turnId: TurnId.make("turn-1"), | 48 | runId, |
| 426 | planMarkdown: "# First", | 49 | planMarkdown: "Plan", |
| 50 | status: "active", | ||
| 427 | implementedAt: null, | 51 | implementedAt: null, |
| 428 | implementationThreadId: null, | 52 | implementationThreadId: ThreadId.make("thread-implementation"), |
| 429 | createdAt: "2026-02-23T00:00:01.000Z", | 53 | createdAt: "2026-06-20T00:00:00.000Z", |
| 430 | updatedAt: "2026-02-23T00:00:01.000Z", | 54 | updatedAt: "2026-06-20T00:00:01.000Z", |
| 431 | }, | ||
| 432 | { | ||
| 433 | id: "plan:thread-1:turn:turn-2", | ||
| 434 | turnId: TurnId.make("turn-2"), | ||
| 435 | planMarkdown: "# Latest", | ||
| 436 | implementedAt: null, | ||
| 437 | implementationThreadId: null, | ||
| 438 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 439 | updatedAt: "2026-02-23T00:00:03.000Z", | ||
| 440 | }, | 55 | }, |
| 441 | ], | 56 | ], |
| 442 | null, | 57 | runId, |
| 443 | ); | ||
| 444 | |||
| 445 | expect(latestPlan?.planMarkdown).toBe("# Latest"); | ||
| 446 | }); | ||
| 447 | }); | ||
| 448 | |||
| 449 | describe("hasActionableProposedPlan", () => { | ||
| 450 | it("returns true for an unimplemented proposed plan", () => { | ||
| 451 | expect( | ||
| 452 | hasActionableProposedPlan({ | ||
| 453 | id: "plan-1", | ||
| 454 | turnId: TurnId.make("turn-1"), | ||
| 455 | planMarkdown: "# Plan", | ||
| 456 | implementedAt: null, | ||
| 457 | implementationThreadId: null, | ||
| 458 | createdAt: "2026-02-23T00:00:00.000Z", | ||
| 459 | updatedAt: "2026-02-23T00:00:01.000Z", | ||
| 460 | }), | ||
| 461 | ).toBe(true); | ||
| 462 | }); | ||
| 463 | |||
| 464 | it("returns false for a proposed plan already implemented elsewhere", () => { | ||
| 465 | expect( | ||
| 466 | hasActionableProposedPlan({ | ||
| 467 | id: "plan-1", | ||
| 468 | turnId: TurnId.make("turn-1"), | ||
| 469 | planMarkdown: "# Plan", | ||
| 470 | implementedAt: "2026-02-23T00:00:02.000Z", | ||
| 471 | implementationThreadId: ThreadId.make("thread-implement"), | ||
| 472 | createdAt: "2026-02-23T00:00:00.000Z", | ||
| 473 | updatedAt: "2026-02-23T00:00:02.000Z", | ||
| 474 | }), | ||
| 475 | ).toBe(false); | ||
| 476 | }); | ||
| 477 | }); | ||
| 478 | |||
| 479 | describe("findSidebarProposedPlan", () => { | ||
| 480 | it("prefers the running turn source proposed plan when available on the same thread", () => { | ||
| 481 | expect( | ||
| 482 | findSidebarProposedPlan({ | ||
| 483 | threads: [ | ||
| 484 | { | ||
| 485 | id: ThreadId.make("thread-1"), | ||
| 486 | proposedPlans: [ | ||
| 487 | { | ||
| 488 | id: "plan-1", | ||
| 489 | turnId: TurnId.make("turn-plan"), | ||
| 490 | planMarkdown: "# Source plan", | ||
| 491 | implementedAt: "2026-02-23T00:00:03.000Z", | ||
| 492 | implementationThreadId: ThreadId.make("thread-2"), | ||
| 493 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 494 | updatedAt: "2026-02-23T00:00:02.000Z", | ||
| 495 | }, | ||
| 496 | ], | ||
| 497 | }, | ||
| 498 | { | ||
| 499 | id: ThreadId.make("thread-2"), | ||
| 500 | proposedPlans: [ | ||
| 501 | { | ||
| 502 | id: "plan-2", | ||
| 503 | turnId: TurnId.make("turn-other"), | ||
| 504 | planMarkdown: "# Latest elsewhere", | ||
| 505 | implementedAt: null, | ||
| 506 | implementationThreadId: null, | ||
| 507 | createdAt: "2026-02-23T00:00:04.000Z", | ||
| 508 | updatedAt: "2026-02-23T00:00:05.000Z", | ||
| 509 | }, | ||
| 510 | ], | ||
| 511 | }, | ||
| 512 | ], | ||
| 513 | latestTurn: { | ||
| 514 | turnId: TurnId.make("turn-implementation"), | ||
| 515 | sourceProposedPlan: { | ||
| 516 | threadId: ThreadId.make("thread-1"), | ||
| 517 | planId: "plan-1", | ||
| 518 | }, | ||
| 519 | }, | ||
| 520 | latestTurnSettled: false, | ||
| 521 | threadId: ThreadId.make("thread-1"), | ||
| 522 | }), | ||
| 523 | ).toEqual({ | ||
| 524 | id: "plan-1", | ||
| 525 | turnId: "turn-plan", | ||
| 526 | planMarkdown: "# Source plan", | ||
| 527 | implementedAt: "2026-02-23T00:00:03.000Z", | ||
| 528 | implementationThreadId: "thread-2", | ||
| 529 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 530 | updatedAt: "2026-02-23T00:00:02.000Z", | ||
| 531 | }); | ||
| 532 | }); | ||
| 533 | |||
| 534 | it("falls back to the latest proposed plan once the turn is settled", () => { | ||
| 535 | expect( | ||
| 536 | findSidebarProposedPlan({ | ||
| 537 | threads: [ | ||
| 538 | { | ||
| 539 | id: ThreadId.make("thread-1"), | ||
| 540 | proposedPlans: [ | ||
| 541 | { | ||
| 542 | id: "plan-1", | ||
| 543 | turnId: TurnId.make("turn-plan"), | ||
| 544 | planMarkdown: "# Older", | ||
| 545 | implementedAt: null, | ||
| 546 | implementationThreadId: null, | ||
| 547 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 548 | updatedAt: "2026-02-23T00:00:02.000Z", | ||
| 549 | }, | ||
| 550 | { | ||
| 551 | id: "plan-2", | ||
| 552 | turnId: TurnId.make("turn-latest"), | ||
| 553 | planMarkdown: "# Latest", | ||
| 554 | implementedAt: null, | ||
| 555 | implementationThreadId: null, | ||
| 556 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 557 | updatedAt: "2026-02-23T00:00:04.000Z", | ||
| 558 | }, | ||
| 559 | ], | ||
| 560 | }, | ||
| 561 | ], | ||
| 562 | latestTurn: { | ||
| 563 | turnId: TurnId.make("turn-implementation"), | ||
| 564 | sourceProposedPlan: { | ||
| 565 | threadId: ThreadId.make("thread-1"), | ||
| 566 | planId: "plan-1", | ||
| 567 | }, | ||
| 568 | }, | ||
| 569 | latestTurnSettled: true, | ||
| 570 | threadId: ThreadId.make("thread-1"), | ||
| 571 | })?.planMarkdown, | ||
| 572 | ).toBe("# Latest"); | ||
| 573 | }); | ||
| 574 | }); | ||
| 575 | |||
| 576 | describe("workEntryIndicatesToolFailure", () => { | ||
| 577 | const base = { | ||
| 578 | id: "w1", | ||
| 579 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 580 | label: "Read", | ||
| 581 | }; | ||
| 582 | |||
| 583 | it("is true for error tone", () => { | ||
| 584 | expect( | ||
| 585 | workEntryIndicatesToolFailure({ | ||
| 586 | ...base, | ||
| 587 | tone: "error", | ||
| 588 | detail: "nothing special", | ||
| 589 | }), | ||
| 590 | ).toBe(true); | ||
| 591 | }); | ||
| 592 | |||
| 593 | it("is true when lifecycle says failed even if detail is empty", () => { | ||
| 594 | expect( | ||
| 595 | workEntryIndicatesToolFailure({ | ||
| 596 | ...base, | ||
| 597 | tone: "tool", | ||
| 598 | toolLifecycleStatus: "failed", | ||
| 599 | }), | ||
| 600 | ).toBe(true); | ||
| 601 | }); | ||
| 602 | |||
| 603 | it("detects file-not-found style tool output with completed lifecycle", () => { | ||
| 604 | expect( | ||
| 605 | workEntryIndicatesToolFailure({ | ||
| 606 | ...base, | ||
| 607 | tone: "tool", | ||
| 608 | toolLifecycleStatus: "completed", | ||
| 609 | detail: "File not found: C:\\foo\\nonexistent.ts", | ||
| 610 | }), | ||
| 611 | ).toBe(true); | ||
| 612 | }); | ||
| 613 | |||
| 614 | it("detects glob no files and PowerShell command errors", () => { | ||
| 615 | expect( | ||
| 616 | workEntryIndicatesToolFailure({ | ||
| 617 | ...base, | ||
| 618 | label: "Glob", | ||
| 619 | tone: "tool", | ||
| 620 | detail: "No files found", | ||
| 621 | }), | ||
| 622 | ).toBe(true); | ||
| 623 | expect( | ||
| 624 | workEntryIndicatesToolFailure({ | ||
| 625 | ...base, | ||
| 626 | label: "Bash", | ||
| 627 | tone: "tool", | ||
| 628 | detail: | ||
| 629 | "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.", | ||
| 630 | }), | ||
| 631 | ).toBe(true); | ||
| 632 | }); | ||
| 633 | |||
| 634 | it("is false for successful completed tools", () => { | ||
| 635 | expect( | ||
| 636 | workEntryIndicatesToolFailure({ | ||
| 637 | ...base, | ||
| 638 | tone: "tool", | ||
| 639 | toolLifecycleStatus: "completed", | ||
| 640 | detail: "Found 3 matching files", | ||
| 641 | }), | ||
| 642 | ).toBe(false); | ||
| 643 | }); | ||
| 644 | |||
| 645 | it("treats successful tool rows as success candidates", () => { | ||
| 646 | expect( | ||
| 647 | workEntryIndicatesToolSuccess({ | ||
| 648 | ...base, | ||
| 649 | tone: "tool", | ||
| 650 | toolLifecycleStatus: "completed", | ||
| 651 | detail: "ok", | ||
| 652 | }), | ||
| 653 | ).toBe(true); | ||
| 654 | expect( | ||
| 655 | workEntryIndicatesToolSuccess({ | ||
| 656 | ...base, | ||
| 657 | tone: "tool", | ||
| 658 | toolLifecycleStatus: "inProgress", | ||
| 659 | detail: "…", | ||
| 660 | }), | ||
| 661 | ).toBe(false); | ||
| 662 | expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false); | ||
| 663 | expect( | ||
| 664 | workEntryIndicatesToolNeutralStatus({ | ||
| 665 | ...base, | ||
| 666 | tone: "tool", | ||
| 667 | toolLifecycleStatus: "inProgress", | ||
| 668 | detail: "…", | ||
| 669 | }), | ||
| 670 | ).toBe(true); | ||
| 671 | expect( | ||
| 672 | workEntryIndicatesToolNeutralStatus({ | ||
| 673 | ...base, | ||
| 674 | tone: "tool", | ||
| 675 | toolLifecycleStatus: "completed", | ||
| 676 | detail: "ok", | ||
| 677 | }), | ||
| 678 | ).toBe(false); | ||
| 679 | }); | ||
| 680 | |||
| 681 | it("does not run heuristics on non-tool info rows", () => { | ||
| 682 | expect( | ||
| 683 | workEntryIndicatesToolFailure({ | ||
| 684 | ...base, | ||
| 685 | label: "Context compacted", | ||
| 686 | tone: "info", | ||
| 687 | detail: "File not found in conversation", | ||
| 688 | }), | ||
| 689 | ).toBe(false); | ||
| 690 | }); | ||
| 691 | }); | ||
| 692 | |||
| 693 | describe("deriveWorkLogEntries", () => { | ||
| 694 | it("omits tool started entries and keeps completed entries", () => { | ||
| 695 | const activities: OrchestrationThreadActivity[] = [ | ||
| 696 | makeActivity({ | ||
| 697 | id: "tool-complete", | ||
| 698 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 699 | summary: "Tool call complete", | ||
| 700 | kind: "tool.completed", | ||
| 701 | }), | ||
| 702 | makeActivity({ | ||
| 703 | id: "tool-start", | ||
| 704 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 705 | summary: "Tool call", | ||
| 706 | kind: "tool.started", | ||
| 707 | }), | ||
| 708 | ]; | ||
| 709 | |||
| 710 | const entries = deriveWorkLogEntries(activities); | ||
| 711 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | ||
| 712 | }); | ||
| 713 | |||
| 714 | it("omits task.started but shows task.progress and task.completed", () => { | ||
| 715 | const activities: OrchestrationThreadActivity[] = [ | ||
| 716 | makeActivity({ | ||
| 717 | id: "task-start", | ||
| 718 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 719 | kind: "task.started", | ||
| 720 | summary: "default task started", | ||
| 721 | tone: "info", | ||
| 722 | }), | ||
| 723 | makeActivity({ | ||
| 724 | id: "task-progress", | ||
| 725 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 726 | kind: "task.progress", | ||
| 727 | summary: "Updating files", | ||
| 728 | tone: "info", | ||
| 729 | }), | ||
| 730 | makeActivity({ | ||
| 731 | id: "task-complete", | ||
| 732 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 733 | kind: "task.completed", | ||
| 734 | summary: "Task completed", | ||
| 735 | tone: "info", | ||
| 736 | }), | ||
| 737 | ]; | ||
| 738 | |||
| 739 | const entries = deriveWorkLogEntries(activities); | ||
| 740 | expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]); | ||
| 741 | }); | ||
| 742 | |||
| 743 | it("uses payload summary as label for task entries when available", () => { | ||
| 744 | const activities: OrchestrationThreadActivity[] = [ | ||
| 745 | makeActivity({ | ||
| 746 | id: "task-progress-with-summary", | ||
| 747 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 748 | kind: "task.progress", | ||
| 749 | summary: "Reasoning update", | ||
| 750 | tone: "info", | ||
| 751 | payload: { summary: "Searching for API endpoints" }, | ||
| 752 | }), | ||
| 753 | ]; | ||
| 754 | |||
| 755 | const entries = deriveWorkLogEntries(activities); | ||
| 756 | expect(entries[0]?.label).toBe("Searching for API endpoints"); | ||
| 757 | }); | ||
| 758 | |||
| 759 | it("uses payload detail as label for task.completed and preserves error tone", () => { | ||
| 760 | const activities: OrchestrationThreadActivity[] = [ | ||
| 761 | makeActivity({ | ||
| 762 | id: "task-completed-failed", | ||
| 763 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 764 | kind: "task.completed", | ||
| 765 | summary: "Task failed", | ||
| 766 | tone: "error", | ||
| 767 | payload: { detail: "Failed to deploy changes" }, | ||
| 768 | }), | ||
| 769 | ]; | ||
| 770 | |||
| 771 | const entries = deriveWorkLogEntries(activities); | ||
| 772 | expect(entries[0]?.label).toBe("Failed to deploy changes"); | ||
| 773 | expect(entries[0]?.tone).toBe("error"); | ||
| 774 | }); | ||
| 775 | |||
| 776 | it("keeps tool entries from every turn and tags each with its turn id", () => { | ||
| 777 | const activities: OrchestrationThreadActivity[] = [ | ||
| 778 | makeActivity({ | ||
| 779 | id: "turn-1-tool", | ||
| 780 | turnId: "turn-1", | ||
| 781 | summary: "Tool call complete", | ||
| 782 | kind: "tool.completed", | ||
| 783 | }), | ||
| 784 | makeActivity({ | ||
| 785 | id: "turn-2-tool", | ||
| 786 | turnId: "turn-2", | ||
| 787 | summary: "Tool call complete", | ||
| 788 | kind: "tool.completed", | ||
| 789 | }), | ||
| 790 | ]; | ||
| 791 | |||
| 792 | const entries = deriveWorkLogEntries(activities); | ||
| 793 | expect(entries.map((entry) => entry.id)).toEqual(["turn-1-tool", "turn-2-tool"]); | ||
| 794 | expect(entries.map((entry) => entry.turnId)).toEqual([ | ||
| 795 | TurnId.make("turn-1"), | ||
| 796 | TurnId.make("turn-2"), | ||
| 797 | ]); | ||
| 798 | }); | ||
| 799 | |||
| 800 | it("omits checkpoint captured info entries", () => { | ||
| 801 | const activities: OrchestrationThreadActivity[] = [ | ||
| 802 | makeActivity({ | ||
| 803 | id: "checkpoint", | ||
| 804 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 805 | summary: "Checkpoint captured", | ||
| 806 | tone: "info", | ||
| 807 | }), | ||
| 808 | makeActivity({ | ||
| 809 | id: "tool-complete", | ||
| 810 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 811 | summary: "Ran command", | ||
| 812 | tone: "tool", | ||
| 813 | kind: "tool.completed", | ||
| 814 | }), | ||
| 815 | ]; | ||
| 816 | |||
| 817 | const entries = deriveWorkLogEntries(activities); | ||
| 818 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | ||
| 819 | }); | ||
| 820 | |||
| 821 | it("omits ExitPlanMode lifecycle entries once the plan card is shown", () => { | ||
| 822 | const activities: OrchestrationThreadActivity[] = [ | ||
| 823 | makeActivity({ | ||
| 824 | id: "exit-plan-updated", | ||
| 825 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 826 | kind: "tool.updated", | ||
| 827 | summary: "Tool call", | ||
| 828 | payload: { | ||
| 829 | detail: 'ExitPlanMode: {"allowedPrompts":[{"tool":"Bash","prompt":"run tests"}]}', | ||
| 830 | }, | ||
| 831 | }), | ||
| 832 | makeActivity({ | ||
| 833 | id: "exit-plan-completed", | ||
| 834 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 835 | kind: "tool.completed", | ||
| 836 | summary: "Tool call", | ||
| 837 | payload: { | ||
| 838 | detail: "ExitPlanMode: {}", | ||
| 839 | }, | ||
| 840 | }), | ||
| 841 | makeActivity({ | ||
| 842 | id: "real-work-log", | ||
| 843 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 844 | kind: "tool.completed", | ||
| 845 | summary: "Ran command", | ||
| 846 | payload: { | ||
| 847 | itemType: "command_execution", | ||
| 848 | detail: "Bash: bun test", | ||
| 849 | }, | ||
| 850 | }), | ||
| 851 | ]; | ||
| 852 | |||
| 853 | const entries = deriveWorkLogEntries(activities); | ||
| 854 | expect(entries.map((entry) => entry.id)).toEqual(["real-work-log"]); | ||
| 855 | }); | ||
| 856 | |||
| 857 | it("orders work log by activity sequence when present", () => { | ||
| 858 | const activities: OrchestrationThreadActivity[] = [ | ||
| 859 | makeActivity({ | ||
| 860 | id: "second", | ||
| 861 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 862 | sequence: 2, | ||
| 863 | summary: "Tool call complete", | ||
| 864 | kind: "tool.completed", | ||
| 865 | }), | ||
| 866 | makeActivity({ | ||
| 867 | id: "first", | ||
| 868 | createdAt: "2026-02-23T00:00:04.000Z", | ||
| 869 | sequence: 1, | ||
| 870 | summary: "Tool call complete", | ||
| 871 | kind: "tool.completed", | ||
| 872 | }), | ||
| 873 | ]; | ||
| 874 | |||
| 875 | const entries = deriveWorkLogEntries(activities); | ||
| 876 | expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]); | ||
| 877 | }); | ||
| 878 | |||
| 879 | it("extracts command text for command tool activities", () => { | ||
| 880 | const activities: OrchestrationThreadActivity[] = [ | ||
| 881 | makeActivity({ | ||
| 882 | id: "command-tool", | ||
| 883 | kind: "tool.completed", | ||
| 884 | summary: "Ran command", | ||
| 885 | payload: { | ||
| 886 | itemType: "command_execution", | ||
| 887 | data: { | ||
| 888 | item: { | ||
| 889 | command: ["bun", "run", "lint"], | ||
| 890 | }, | ||
| 891 | }, | ||
| 892 | }, | ||
| 893 | }), | ||
| 894 | ]; | ||
| 895 | |||
| 896 | const [entry] = deriveWorkLogEntries(activities); | ||
| 897 | expect(entry?.command).toBe("bun run lint"); | ||
| 898 | }); | ||
| 899 | |||
| 900 | it("extracts failed tool lifecycle status from item payloads", () => { | ||
| 901 | const activities: OrchestrationThreadActivity[] = [ | ||
| 902 | makeActivity({ | ||
| 903 | id: "tool-failed", | ||
| 904 | kind: "tool.updated", | ||
| 905 | summary: "Glob", | ||
| 906 | tone: "tool", | ||
| 907 | payload: { | ||
| 908 | itemType: "mcp_tool_call", | ||
| 909 | status: "failed", | ||
| 910 | detail: "No files found", | ||
| 911 | }, | ||
| 912 | }), | ||
| 913 | ]; | ||
| 914 | |||
| 915 | const [entry] = deriveWorkLogEntries(activities); | ||
| 916 | expect(entry?.toolLifecycleStatus).toBe("failed"); | ||
| 917 | }); | ||
| 918 | |||
| 919 | it("defaults tool.completed entries to completed lifecycle status", () => { | ||
| 920 | const activities: OrchestrationThreadActivity[] = [ | ||
| 921 | makeActivity({ | ||
| 922 | id: "tool-done", | ||
| 923 | kind: "tool.completed", | ||
| 924 | summary: "Glob", | ||
| 925 | tone: "tool", | ||
| 926 | payload: { | ||
| 927 | itemType: "mcp_tool_call", | ||
| 928 | detail: "Found 3 files", | ||
| 929 | }, | ||
| 930 | }), | ||
| 931 | ]; | ||
| 932 | |||
| 933 | const [entry] = deriveWorkLogEntries(activities); | ||
| 934 | expect(entry?.toolLifecycleStatus).toBe("completed"); | ||
| 935 | }); | ||
| 936 | |||
| 937 | it("preserves MCP server, tool, arguments, and results for expanded display", () => { | ||
| 938 | const item = { | ||
| 939 | type: "mcpToolCall", | ||
| 940 | server: "t3-code", | ||
| 941 | tool: "preview_status", | ||
| 942 | arguments: {}, | ||
| 943 | status: "completed", | ||
| 944 | result: { content: [{ type: "text", text: "attached" }] }, | ||
| 945 | }; | ||
| 946 | const activities: OrchestrationThreadActivity[] = [ | ||
| 947 | makeActivity({ | ||
| 948 | id: "mcp-tool-done", | ||
| 949 | kind: "tool.completed", | ||
| 950 | summary: "t3-code · preview_status", | ||
| 951 | payload: { | ||
| 952 | itemType: "mcp_tool_call", | ||
| 953 | title: "t3-code · preview_status", | ||
| 954 | data: { item }, | ||
| 955 | }, | ||
| 956 | }), | ||
| 957 | ]; | ||
| 958 | |||
| 959 | const [entry] = deriveWorkLogEntries(activities); | ||
| 960 | expect(entry?.toolTitle).toBe("t3-code · preview_status"); | ||
| 961 | expect(entry?.toolData).toEqual(item); | ||
| 962 | }); | ||
| 963 | |||
| 964 | it("keeps MCP payloads while collapsing lifecycle updates", () => { | ||
| 965 | const item = { | ||
| 966 | type: "mcpToolCall", | ||
| 967 | server: "t3-code", | ||
| 968 | tool: "preview_snapshot", | ||
| 969 | arguments: { interactiveOnly: true }, | ||
| 970 | status: "completed", | ||
| 971 | }; | ||
| 972 | const activities: OrchestrationThreadActivity[] = [ | ||
| 973 | makeActivity({ | ||
| 974 | id: "mcp-tool-progress", | ||
| 975 | kind: "tool.updated", | ||
| 976 | summary: "t3-code · preview_snapshot", | ||
| 977 | payload: { | ||
| 978 | itemType: "mcp_tool_call", | ||
| 979 | toolCallId: "call-1", | ||
| 980 | data: { item }, | ||
| 981 | }, | ||
| 982 | }), | ||
| 983 | makeActivity({ | ||
| 984 | id: "mcp-tool-complete", | ||
| 985 | kind: "tool.completed", | ||
| 986 | summary: "t3-code · preview_snapshot", | ||
| 987 | payload: { | ||
| 988 | itemType: "mcp_tool_call", | ||
| 989 | toolCallId: "call-1", | ||
| 990 | }, | ||
| 991 | }), | ||
| 992 | ]; | ||
| 993 | |||
| 994 | const [entry] = deriveWorkLogEntries(activities); | ||
| 995 | expect(entry?.toolData).toEqual(item); | ||
| 996 | }); | ||
| 997 | |||
| 998 | it("unwraps PowerShell command wrappers for displayed command text", () => { | ||
| 999 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1000 | makeActivity({ | ||
| 1001 | id: "command-tool-windows-wrapper", | ||
| 1002 | kind: "tool.completed", | ||
| 1003 | summary: "Ran command", | ||
| 1004 | payload: { | ||
| 1005 | itemType: "command_execution", | ||
| 1006 | data: { | ||
| 1007 | item: { | ||
| 1008 | command: "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'", | ||
| 1009 | }, | ||
| 1010 | }, | ||
| 1011 | }, | ||
| 1012 | }), | ||
| 1013 | ]; | ||
| 1014 | |||
| 1015 | const [entry] = deriveWorkLogEntries(activities); | ||
| 1016 | expect(entry?.command).toBe("bun run lint"); | ||
| 1017 | expect(entry?.rawCommand).toBe( | ||
| 1018 | "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'", | ||
| 1019 | ); | ||
| 1020 | }); | ||
| 1021 | |||
| 1022 | it("unwraps PowerShell command wrappers from argv-style command payloads", () => { | ||
| 1023 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1024 | makeActivity({ | ||
| 1025 | id: "command-tool-windows-wrapper-argv", | ||
| 1026 | kind: "tool.completed", | ||
| 1027 | summary: "Ran command", | ||
| 1028 | payload: { | ||
| 1029 | itemType: "command_execution", | ||
| 1030 | data: { | ||
| 1031 | item: { | ||
| 1032 | command: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "-Command", "rg -n foo ."], | ||
| 1033 | }, | ||
| 1034 | }, | ||
| 1035 | }, | ||
| 1036 | }), | ||
| 1037 | ]; | ||
| 1038 | |||
| 1039 | const [entry] = deriveWorkLogEntries(activities); | ||
| 1040 | expect(entry?.command).toBe("rg -n foo ."); | ||
| 1041 | expect(entry?.rawCommand).toBe( | ||
| 1042 | '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command "rg -n foo ."', | ||
| 1043 | ); | ||
| 1044 | }); | ||
| 1045 | |||
| 1046 | it("extracts command text from command detail when structured command metadata is missing", () => { | ||
| 1047 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1048 | makeActivity({ | ||
| 1049 | id: "command-tool-windows-detail-fallback", | ||
| 1050 | kind: "tool.completed", | ||
| 1051 | summary: "Ran command", | ||
| 1052 | payload: { | ||
| 1053 | itemType: "command_execution", | ||
| 1054 | detail: | ||
| 1055 | '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command \'rg -n -F "new Date()" .\' <exited with exit code 0>', | ||
| 1056 | }, | ||
| 1057 | }), | ||
| 1058 | ]; | ||
| 1059 | |||
| 1060 | const [entry] = deriveWorkLogEntries(activities); | ||
| 1061 | expect(entry?.command).toBe('rg -n -F "new Date()" .'); | ||
| 1062 | expect(entry?.rawCommand).toBe( | ||
| 1063 | `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n -F "new Date()" .'`, | ||
| 1064 | ); | 58 | ); |
| 59 | expect(plan?.planMarkdown).toBe("Plan"); | ||
| 1065 | }); | 60 | }); |
| 1066 | 61 | ||
| 1067 | it("does not unwrap shell commands when no wrapper flag is present", () => { | 62 | it("orders conversation and generic V2 work entries", () => { |
| 1068 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1069 | makeActivity({ | ||
| 1070 | id: "command-tool-shell-script", | ||
| 1071 | kind: "tool.completed", | ||
| 1072 | summary: "Ran command", | ||
| 1073 | payload: { | ||
| 1074 | itemType: "command_execution", | ||
| 1075 | data: { | ||
| 1076 | item: { | ||
| 1077 | command: "bash script.sh", | ||
| 1078 | }, | ||
| 1079 | }, | ||
| 1080 | }, | ||
| 1081 | }), | ||
| 1082 | ]; | ||
| 1083 | |||
| 1084 | const [entry] = deriveWorkLogEntries(activities); | ||
| 1085 | expect(entry?.command).toBe("bash script.sh"); | ||
| 1086 | expect(entry?.rawCommand).toBeUndefined(); | ||
| 1087 | }); | ||
| 1088 | |||
| 1089 | it("keeps compact Codex tool metadata used for icons and labels", () => { | ||
| 1090 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1091 | makeActivity({ | ||
| 1092 | id: "tool-with-metadata", | ||
| 1093 | kind: "tool.completed", | ||
| 1094 | summary: "bash", | ||
| 1095 | payload: { | ||
| 1096 | itemType: "command_execution", | ||
| 1097 | title: "bash", | ||
| 1098 | status: "completed", | ||
| 1099 | detail: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>', | ||
| 1100 | data: { | ||
| 1101 | item: { | ||
| 1102 | command: ["bun", "run", "dev"], | ||
| 1103 | result: { | ||
| 1104 | content: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>', | ||
| 1105 | exitCode: 0, | ||
| 1106 | }, | ||
| 1107 | }, | ||
| 1108 | }, | ||
| 1109 | }, | ||
| 1110 | }), | ||
| 1111 | ]; | ||
| 1112 | |||
| 1113 | const [entry] = deriveWorkLogEntries(activities); | ||
| 1114 | expect(entry).toMatchObject({ | ||
| 1115 | command: "bun run dev", | ||
| 1116 | detail: '{ "dev": "vite dev --port 3000" }', | ||
| 1117 | itemType: "command_execution", | ||
| 1118 | toolTitle: "bash", | ||
| 1119 | }); | ||
| 1120 | }); | ||
| 1121 | |||
| 1122 | it("extracts changed file paths for file-change tool activities", () => { | ||
| 1123 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1124 | makeActivity({ | ||
| 1125 | id: "file-tool", | ||
| 1126 | kind: "tool.completed", | ||
| 1127 | summary: "File change", | ||
| 1128 | payload: { | ||
| 1129 | itemType: "file_change", | ||
| 1130 | data: { | ||
| 1131 | item: { | ||
| 1132 | changes: [ | ||
| 1133 | { path: "apps/web/src/components/ChatView.tsx" }, | ||
| 1134 | { filename: "apps/web/src/session-logic.ts" }, | ||
| 1135 | ], | ||
| 1136 | }, | ||
| 1137 | }, | ||
| 1138 | }, | ||
| 1139 | }), | ||
| 1140 | ]; | ||
| 1141 | |||
| 1142 | const [entry] = deriveWorkLogEntries(activities); | ||
| 1143 | expect(entry?.changedFiles).toEqual([ | ||
| 1144 | "apps/web/src/components/ChatView.tsx", | ||
| 1145 | "apps/web/src/session-logic.ts", | ||
| 1146 | ]); | ||
| 1147 | }); | ||
| 1148 | |||
| 1149 | it("drops duplicated tool detail when it only repeats the title", () => { | ||
| 1150 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1151 | makeActivity({ | ||
| 1152 | id: "read-file-generic", | ||
| 1153 | kind: "tool.completed", | ||
| 1154 | summary: "Read File", | ||
| 1155 | payload: { | ||
| 1156 | itemType: "dynamic_tool_call", | ||
| 1157 | title: "Read File", | ||
| 1158 | detail: "Read File", | ||
| 1159 | }, | ||
| 1160 | }), | ||
| 1161 | ]; | ||
| 1162 | |||
| 1163 | const [entry] = deriveWorkLogEntries(activities); | ||
| 1164 | expect(entry?.toolTitle).toBe("Read File"); | ||
| 1165 | expect(entry?.detail).toBeUndefined(); | ||
| 1166 | }); | ||
| 1167 | |||
| 1168 | it("uses grep raw output summaries instead of repeating the generic tool label", () => { | ||
| 1169 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1170 | makeActivity({ | ||
| 1171 | id: "grep-update", | ||
| 1172 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 1173 | kind: "tool.updated", | ||
| 1174 | summary: "grep", | ||
| 1175 | payload: { | ||
| 1176 | itemType: "web_search", | ||
| 1177 | title: "grep", | ||
| 1178 | detail: "grep", | ||
| 1179 | data: { | ||
| 1180 | toolCallId: "tool-grep-1", | ||
| 1181 | kind: "search", | ||
| 1182 | rawInput: {}, | ||
| 1183 | }, | ||
| 1184 | }, | ||
| 1185 | }), | ||
| 1186 | makeActivity({ | ||
| 1187 | id: "grep-complete", | ||
| 1188 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1189 | kind: "tool.completed", | ||
| 1190 | summary: "grep", | ||
| 1191 | payload: { | ||
| 1192 | itemType: "web_search", | ||
| 1193 | title: "grep", | ||
| 1194 | detail: "grep", | ||
| 1195 | data: { | ||
| 1196 | toolCallId: "tool-grep-1", | ||
| 1197 | kind: "search", | ||
| 1198 | rawOutput: { | ||
| 1199 | totalFiles: 19, | ||
| 1200 | truncated: false, | ||
| 1201 | }, | ||
| 1202 | }, | ||
| 1203 | }, | ||
| 1204 | }), | ||
| 1205 | ]; | ||
| 1206 | |||
| 1207 | const entries = deriveWorkLogEntries(activities); | ||
| 1208 | expect(entries).toHaveLength(1); | ||
| 1209 | expect(entries[0]).toMatchObject({ | ||
| 1210 | id: "grep-complete", | ||
| 1211 | toolTitle: "grep", | ||
| 1212 | detail: "19 files", | ||
| 1213 | itemType: "web_search", | ||
| 1214 | }); | ||
| 1215 | }); | ||
| 1216 | |||
| 1217 | it("uses completed read-file output previews and still collapses the same tool call", () => { | ||
| 1218 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1219 | makeActivity({ | ||
| 1220 | id: "read-update", | ||
| 1221 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 1222 | kind: "tool.updated", | ||
| 1223 | summary: "Read File", | ||
| 1224 | payload: { | ||
| 1225 | itemType: "dynamic_tool_call", | ||
| 1226 | title: "Read File", | ||
| 1227 | detail: "Read File", | ||
| 1228 | data: { | ||
| 1229 | toolCallId: "tool-read-1", | ||
| 1230 | kind: "read", | ||
| 1231 | rawInput: {}, | ||
| 1232 | }, | ||
| 1233 | }, | ||
| 1234 | }), | ||
| 1235 | makeActivity({ | ||
| 1236 | id: "read-complete", | ||
| 1237 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1238 | kind: "tool.completed", | ||
| 1239 | summary: "Read File", | ||
| 1240 | payload: { | ||
| 1241 | itemType: "dynamic_tool_call", | ||
| 1242 | title: "Read File", | ||
| 1243 | detail: "Read File", | ||
| 1244 | data: { | ||
| 1245 | toolCallId: "tool-read-1", | ||
| 1246 | kind: "read", | ||
| 1247 | rawOutput: { | ||
| 1248 | content: | ||
| 1249 | 'import * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\n', | ||
| 1250 | }, | ||
| 1251 | }, | ||
| 1252 | }, | ||
| 1253 | }), | ||
| 1254 | ]; | ||
| 1255 | |||
| 1256 | const entries = deriveWorkLogEntries(activities); | ||
| 1257 | expect(entries).toHaveLength(1); | ||
| 1258 | expect(entries[0]).toMatchObject({ | ||
| 1259 | id: "read-complete", | ||
| 1260 | toolTitle: "Read File", | ||
| 1261 | detail: 'import * as Effect from "effect/Effect"', | ||
| 1262 | itemType: "dynamic_tool_call", | ||
| 1263 | }); | ||
| 1264 | }); | ||
| 1265 | |||
| 1266 | it("does not use command stdout as the detail when Cursor omits the command input", () => { | ||
| 1267 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1268 | makeActivity({ | ||
| 1269 | id: "cursor-command-complete", | ||
| 1270 | createdAt: "2026-04-16T22:40:42.221Z", | ||
| 1271 | kind: "tool.completed", | ||
| 1272 | summary: "Ran command", | ||
| 1273 | payload: { | ||
| 1274 | itemType: "command_execution", | ||
| 1275 | title: "Ran command", | ||
| 1276 | data: { | ||
| 1277 | toolCallId: "toolu_vrtx_01WypXgRM8PPygBtrVAZwzy5", | ||
| 1278 | kind: "execute", | ||
| 1279 | rawInput: {}, | ||
| 1280 | rawOutput: { | ||
| 1281 | exitCode: 0, | ||
| 1282 | stdout: "total 960\napps\npackages\n", | ||
| 1283 | stderr: "", | ||
| 1284 | }, | ||
| 1285 | }, | ||
| 1286 | }, | ||
| 1287 | }), | ||
| 1288 | ]; | ||
| 1289 | |||
| 1290 | const [entry] = deriveWorkLogEntries(activities, undefined); | ||
| 1291 | expect(entry).toMatchObject({ | ||
| 1292 | id: "cursor-command-complete", | ||
| 1293 | label: "Ran command", | ||
| 1294 | itemType: "command_execution", | ||
| 1295 | toolTitle: "Ran command", | ||
| 1296 | }); | ||
| 1297 | expect(entry?.detail).toBeUndefined(); | ||
| 1298 | expect(entry?.command).toBeUndefined(); | ||
| 1299 | }); | ||
| 1300 | |||
| 1301 | it("collapses legacy completed tool rows that are missing tool metadata", () => { | ||
| 1302 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1303 | makeActivity({ | ||
| 1304 | id: "legacy-read-update", | ||
| 1305 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 1306 | kind: "tool.updated", | ||
| 1307 | summary: "Read File", | ||
| 1308 | payload: { | ||
| 1309 | itemType: "dynamic_tool_call", | ||
| 1310 | title: "Read File", | ||
| 1311 | detail: "Read File", | ||
| 1312 | data: { | ||
| 1313 | toolCallId: "tool-read-legacy", | ||
| 1314 | kind: "read", | ||
| 1315 | rawInput: {}, | ||
| 1316 | }, | ||
| 1317 | }, | ||
| 1318 | }), | ||
| 1319 | makeActivity({ | ||
| 1320 | id: "legacy-read-complete", | ||
| 1321 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1322 | kind: "tool.completed", | ||
| 1323 | summary: "Read File", | ||
| 1324 | payload: { | ||
| 1325 | itemType: "dynamic_tool_call", | ||
| 1326 | title: "Read File", | ||
| 1327 | detail: "Read File", | ||
| 1328 | }, | ||
| 1329 | }), | ||
| 1330 | ]; | ||
| 1331 | |||
| 1332 | const entries = deriveWorkLogEntries(activities); | ||
| 1333 | expect(entries).toHaveLength(1); | ||
| 1334 | expect(entries[0]).toMatchObject({ | ||
| 1335 | id: "legacy-read-complete", | ||
| 1336 | toolTitle: "Read File", | ||
| 1337 | itemType: "dynamic_tool_call", | ||
| 1338 | }); | ||
| 1339 | expect(entries[0]?.detail).toBeUndefined(); | ||
| 1340 | }); | ||
| 1341 | |||
| 1342 | it("collapses repeated lifecycle updates for the same tool call into one entry", () => { | ||
| 1343 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1344 | makeActivity({ | ||
| 1345 | id: "tool-update-1", | ||
| 1346 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 1347 | kind: "tool.updated", | ||
| 1348 | summary: "Tool call", | ||
| 1349 | payload: { | ||
| 1350 | itemType: "dynamic_tool_call", | ||
| 1351 | title: "Tool call", | ||
| 1352 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1353 | }, | ||
| 1354 | }), | ||
| 1355 | makeActivity({ | ||
| 1356 | id: "tool-update-2", | ||
| 1357 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1358 | kind: "tool.updated", | ||
| 1359 | summary: "Tool call", | ||
| 1360 | payload: { | ||
| 1361 | itemType: "dynamic_tool_call", | ||
| 1362 | title: "Tool call", | ||
| 1363 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1364 | data: { | ||
| 1365 | item: { | ||
| 1366 | command: ["sed", "-n", "1,40p", "/tmp/app.ts"], | ||
| 1367 | }, | ||
| 1368 | }, | ||
| 1369 | }, | ||
| 1370 | }), | ||
| 1371 | makeActivity({ | ||
| 1372 | id: "tool-complete", | ||
| 1373 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 1374 | kind: "tool.completed", | ||
| 1375 | summary: "Tool call completed", | ||
| 1376 | payload: { | ||
| 1377 | itemType: "dynamic_tool_call", | ||
| 1378 | title: "Tool call", | ||
| 1379 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1380 | }, | ||
| 1381 | }), | ||
| 1382 | ]; | ||
| 1383 | |||
| 1384 | const entries = deriveWorkLogEntries(activities); | ||
| 1385 | |||
| 1386 | expect(entries).toHaveLength(1); | ||
| 1387 | expect(entries[0]).toMatchObject({ | ||
| 1388 | id: "tool-complete", | ||
| 1389 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 1390 | label: "Tool call completed", | ||
| 1391 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1392 | command: "sed -n 1,40p /tmp/app.ts", | ||
| 1393 | itemType: "dynamic_tool_call", | ||
| 1394 | toolTitle: "Tool call", | ||
| 1395 | }); | ||
| 1396 | }); | ||
| 1397 | |||
| 1398 | it("keeps separate tool entries when an identical call starts after the prior one completed", () => { | ||
| 1399 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1400 | makeActivity({ | ||
| 1401 | id: "tool-1-update", | ||
| 1402 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 1403 | kind: "tool.updated", | ||
| 1404 | summary: "Tool call", | ||
| 1405 | payload: { | ||
| 1406 | itemType: "dynamic_tool_call", | ||
| 1407 | title: "Tool call", | ||
| 1408 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1409 | }, | ||
| 1410 | }), | ||
| 1411 | makeActivity({ | ||
| 1412 | id: "tool-1-complete", | ||
| 1413 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1414 | kind: "tool.completed", | ||
| 1415 | summary: "Tool call completed", | ||
| 1416 | payload: { | ||
| 1417 | itemType: "dynamic_tool_call", | ||
| 1418 | title: "Tool call", | ||
| 1419 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1420 | }, | ||
| 1421 | }), | ||
| 1422 | makeActivity({ | ||
| 1423 | id: "tool-2-update", | ||
| 1424 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 1425 | kind: "tool.updated", | ||
| 1426 | summary: "Tool call", | ||
| 1427 | payload: { | ||
| 1428 | itemType: "dynamic_tool_call", | ||
| 1429 | title: "Tool call", | ||
| 1430 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1431 | }, | ||
| 1432 | }), | ||
| 1433 | makeActivity({ | ||
| 1434 | id: "tool-2-complete", | ||
| 1435 | createdAt: "2026-02-23T00:00:04.000Z", | ||
| 1436 | kind: "tool.completed", | ||
| 1437 | summary: "Tool call completed", | ||
| 1438 | payload: { | ||
| 1439 | itemType: "dynamic_tool_call", | ||
| 1440 | title: "Tool call", | ||
| 1441 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1442 | }, | ||
| 1443 | }), | ||
| 1444 | ]; | ||
| 1445 | |||
| 1446 | const entries = deriveWorkLogEntries(activities); | ||
| 1447 | |||
| 1448 | expect(entries.map((entry) => entry.id)).toEqual(["tool-1-complete", "tool-2-complete"]); | ||
| 1449 | }); | ||
| 1450 | |||
| 1451 | it("collapses same-timestamp lifecycle rows even when completed sorts before updated by id", () => { | ||
| 1452 | const activities: OrchestrationThreadActivity[] = [ | ||
| 1453 | makeActivity({ | ||
| 1454 | id: "z-update-earlier", | ||
| 1455 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 1456 | kind: "tool.updated", | ||
| 1457 | summary: "Tool call", | ||
| 1458 | payload: { | ||
| 1459 | itemType: "dynamic_tool_call", | ||
| 1460 | title: "Tool call", | ||
| 1461 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1462 | }, | ||
| 1463 | }), | ||
| 1464 | makeActivity({ | ||
| 1465 | id: "a-complete-same-timestamp", | ||
| 1466 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1467 | kind: "tool.completed", | ||
| 1468 | summary: "Tool call", | ||
| 1469 | payload: { | ||
| 1470 | itemType: "dynamic_tool_call", | ||
| 1471 | title: "Tool call", | ||
| 1472 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1473 | }, | ||
| 1474 | }), | ||
| 1475 | makeActivity({ | ||
| 1476 | id: "z-update-same-timestamp", | ||
| 1477 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1478 | kind: "tool.updated", | ||
| 1479 | summary: "Tool call", | ||
| 1480 | payload: { | ||
| 1481 | itemType: "dynamic_tool_call", | ||
| 1482 | title: "Tool call", | ||
| 1483 | detail: 'Read: {"file_path":"/tmp/app.ts"}', | ||
| 1484 | }, | ||
| 1485 | }), | ||
| 1486 | ]; | ||
| 1487 | |||
| 1488 | const entries = deriveWorkLogEntries(activities); | ||
| 1489 | |||
| 1490 | expect(entries).toHaveLength(1); | ||
| 1491 | expect(entries[0]?.id).toBe("a-complete-same-timestamp"); | ||
| 1492 | }); | ||
| 1493 | }); | ||
| 1494 | |||
| 1495 | describe("deriveTimelineEntries", () => { | ||
| 1496 | it("includes proposed plans alongside messages and work entries in chronological order", () => { | ||
| 1497 | const entries = deriveTimelineEntries( | 63 | const entries = deriveTimelineEntries( |
| 1498 | [ | 64 | [ |
| 1499 | { | 65 | { |
| 1500 | id: MessageId.make("message-1"), | 66 | id: "message-1" as never, |
| 1501 | role: "assistant", | 67 | role: "user", |
| 1502 | text: "hello", | 68 | text: "Hello", |
| 1503 | createdAt: "2026-02-23T00:00:01.000Z", | 69 | runId: null, |
| 1504 | turnId: null, | ||
| 1505 | updatedAt: "2026-02-23T00:00:01.000Z", | ||
| 1506 | streaming: false, | 70 | streaming: false, |
| 71 | createdAt: "2026-06-20T00:00:00.000Z", | ||
| 72 | updatedAt: "2026-06-20T00:00:00.000Z", | ||
| 1507 | }, | 73 | }, |
| 1508 | ], | 74 | ], |
| 1509 | [ | 75 | [], |
| 1510 | { | 76 | [], |
| 1511 | id: "plan:thread-1:turn:turn-1", | ||
| 1512 | turnId: TurnId.make("turn-1"), | ||
| 1513 | planMarkdown: "# Ship it", | ||
| 1514 | implementedAt: null, | ||
| 1515 | implementationThreadId: null, | ||
| 1516 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 1517 | updatedAt: "2026-02-23T00:00:02.000Z", | ||
| 1518 | }, | ||
| 1519 | ], | ||
| 1520 | [ | ||
| 1521 | { | ||
| 1522 | id: "work-1", | ||
| 1523 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 1524 | label: "Ran tests", | ||
| 1525 | tone: "tool", | ||
| 1526 | }, | ||
| 1527 | ], | ||
| 1528 | ); | 77 | ); |
| 1529 | 78 | expect(entries.map((entry) => entry.kind)).toEqual(["message"]); | |
| 1530 | expect(entries.map((entry) => entry.kind)).toEqual(["message", "proposed-plan", "work"]); | 79 | }); |
| 1531 | expect(entries[1]).toMatchObject({ | 80 | |
| 1532 | kind: "proposed-plan", | 81 | it("uses visible turn item order and keeps interruption lifecycle entries standalone", () => { |
| 1533 | proposedPlan: { | 82 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 1534 | planMarkdown: "# Ship it", | 83 | const threadId = ThreadId.make("thread-visible"); |
| 1535 | implementedAt: null, | 84 | const runId = RunId.make("run-visible"); |
| 1536 | implementationThreadId: null, | 85 | const base = (id: string, ordinal: number) => ({ |
| 1537 | }, | 86 | id: TurnItemId.make(id), |
| 87 | threadId, | ||
| 88 | runId, | ||
| 89 | nodeId: null, | ||
| 90 | providerThreadId: null, | ||
| 91 | providerTurnId: null, | ||
| 92 | nativeItemRef: null, | ||
| 93 | parentItemId: null, | ||
| 94 | ordinal, | ||
| 95 | status: "completed" as const, | ||
| 96 | title: null, | ||
| 97 | startedAt: now, | ||
| 98 | completedAt: now, | ||
| 99 | updatedAt: now, | ||
| 100 | }); | ||
| 101 | const userItem = { | ||
| 102 | ...base("item-user", 0), | ||
| 103 | type: "user_message" as const, | ||
| 104 | messageId: MessageId.make("message-user"), | ||
| 105 | inputIntent: "turn_start" as const, | ||
| 106 | text: "Start", | ||
| 107 | attachments: [], | ||
| 108 | createdBy: "user" as const, | ||
| 109 | creationSource: "web" as const, | ||
| 110 | } satisfies OrchestrationV2TurnItem; | ||
| 111 | const requestItem = { | ||
| 112 | ...base("item-interrupt-request", 1), | ||
| 113 | type: "run_interrupt_request" as const, | ||
| 114 | message: "Stopping", | ||
| 115 | } satisfies OrchestrationV2TurnItem; | ||
| 116 | const commandItem = { | ||
| 117 | ...base("item-command", 2), | ||
| 118 | type: "command_execution" as const, | ||
| 119 | input: "sleep 1", | ||
| 120 | output: "done", | ||
| 121 | exitCode: 0, | ||
| 122 | } satisfies OrchestrationV2TurnItem; | ||
| 123 | const resultItem = { | ||
| 124 | ...base("item-interrupt-result", 3), | ||
| 125 | type: "run_interrupt_result" as const, | ||
| 126 | message: "Stopped", | ||
| 127 | } satisfies OrchestrationV2TurnItem; | ||
| 128 | const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [ | ||
| 129 | userItem, | ||
| 130 | requestItem, | ||
| 131 | commandItem, | ||
| 132 | resultItem, | ||
| 133 | ].map((item, position) => ({ | ||
| 134 | position, | ||
| 135 | visibility: "local" as const, | ||
| 136 | sourceThreadId: threadId, | ||
| 137 | sourceItemId: item.id, | ||
| 138 | item, | ||
| 139 | })); | ||
| 140 | |||
| 141 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ | ||
| 142 | visibleTurnItems, | ||
| 143 | optimisticMessages: [], | ||
| 1538 | }); | 144 | }); |
| 1539 | }); | ||
| 1540 | }); | ||
| 1541 | |||
| 1542 | describe("deriveWorkLogEntries context window handling", () => { | ||
| 1543 | it("excludes context window updates from the work log", () => { | ||
| 1544 | const entries = deriveWorkLogEntries([ | ||
| 1545 | makeActivity({ | ||
| 1546 | id: "context-1", | ||
| 1547 | turnId: "turn-1", | ||
| 1548 | kind: "context-window.updated", | ||
| 1549 | summary: "Context window updated", | ||
| 1550 | tone: "info", | ||
| 1551 | }), | ||
| 1552 | makeActivity({ | ||
| 1553 | id: "tool-1", | ||
| 1554 | turnId: "turn-1", | ||
| 1555 | kind: "tool.completed", | ||
| 1556 | summary: "Ran command", | ||
| 1557 | tone: "tool", | ||
| 1558 | }), | ||
| 1559 | ]); | ||
| 1560 | |||
| 1561 | expect(entries).toHaveLength(1); | ||
| 1562 | expect(entries[0]?.label).toBe("Ran command"); | ||
| 1563 | }); | ||
| 1564 | 145 | ||
| 1565 | it("keeps context compaction activities as normal work log entries", () => { | 146 | expect(entries.map((entry) => [entry.kind, entry.id])).toEqual([ |
| 1566 | const entries = deriveWorkLogEntries([ | 147 | ["message", userItem.messageId], |
| 1567 | makeActivity({ | 148 | ["event", requestItem.id], |
| 1568 | id: "compaction-1", | 149 | ["work", commandItem.id], |
| 1569 | turnId: "turn-1", | 150 | ["event", resultItem.id], |
| 1570 | kind: "context-compaction", | ||
| 1571 | summary: "Context compacted", | ||
| 1572 | tone: "info", | ||
| 1573 | }), | ||
| 1574 | ]); | 151 | ]); |
| 1575 | 152 | const commandEntry = entries[2]; | |
| 1576 | expect(entries).toHaveLength(1); | 153 | expect(commandEntry?.kind).toBe("work"); |
| 1577 | expect(entries[0]?.label).toBe("Context compacted"); | 154 | if (commandEntry?.kind === "work") { |
| 1578 | }); | 155 | expect(commandEntry.entry.projectedItem).toBe(visibleTurnItems[2]); |
| 1579 | }); | 156 | expect(commandEntry.entry.structuredPayload).toBe(commandItem); |
| 1580 | 157 | } | |
| 1581 | describe("isLatestTurnSettled", () => { | ||
| 1582 | const latestTurn = { | ||
| 1583 | turnId: TurnId.make("turn-1"), | ||
| 1584 | startedAt: "2026-02-27T21:10:00.000Z", | ||
| 1585 | completedAt: "2026-02-27T21:10:06.000Z", | ||
| 1586 | } as const; | ||
| 1587 | |||
| 1588 | it("returns false while the same turn is still active in a running session", () => { | ||
| 1589 | expect( | ||
| 1590 | isLatestTurnSettled(latestTurn, { | ||
| 1591 | status: "running", | ||
| 1592 | activeTurnId: TurnId.make("turn-1"), | ||
| 1593 | }), | ||
| 1594 | ).toBe(false); | ||
| 1595 | }); | ||
| 1596 | |||
| 1597 | it("returns false while any turn is running to avoid stale latest-turn banners", () => { | ||
| 1598 | expect( | ||
| 1599 | isLatestTurnSettled(latestTurn, { | ||
| 1600 | status: "running", | ||
| 1601 | activeTurnId: TurnId.make("turn-2"), | ||
| 1602 | }), | ||
| 1603 | ).toBe(false); | ||
| 1604 | }); | ||
| 1605 | |||
| 1606 | it("returns true once the session is no longer running that turn", () => { | ||
| 1607 | expect( | ||
| 1608 | isLatestTurnSettled(latestTurn, { | ||
| 1609 | status: "ready", | ||
| 1610 | activeTurnId: null, | ||
| 1611 | }), | ||
| 1612 | ).toBe(true); | ||
| 1613 | }); | ||
| 1614 | |||
| 1615 | it("returns false when turn timestamps are incomplete", () => { | ||
| 1616 | expect( | ||
| 1617 | isLatestTurnSettled( | ||
| 1618 | { | ||
| 1619 | turnId: TurnId.make("turn-1"), | ||
| 1620 | startedAt: null, | ||
| 1621 | completedAt: "2026-02-27T21:10:06.000Z", | ||
| 1622 | }, | ||
| 1623 | null, | ||
| 1624 | ), | ||
| 1625 | ).toBe(false); | ||
| 1626 | }); | ||
| 1627 | }); | ||
| 1628 | |||
| 1629 | describe("deriveActiveWorkStartedAt", () => { | ||
| 1630 | const latestTurn = { | ||
| 1631 | turnId: TurnId.make("turn-1"), | ||
| 1632 | startedAt: "2026-02-27T21:10:00.000Z", | ||
| 1633 | completedAt: "2026-02-27T21:10:06.000Z", | ||
| 1634 | } as const; | ||
| 1635 | |||
| 1636 | it("prefers the in-flight turn start when the latest turn is not settled", () => { | ||
| 1637 | expect( | ||
| 1638 | deriveActiveWorkStartedAt( | ||
| 1639 | latestTurn, | ||
| 1640 | { | ||
| 1641 | status: "running", | ||
| 1642 | activeTurnId: TurnId.make("turn-1"), | ||
| 1643 | }, | ||
| 1644 | "2026-02-27T21:11:00.000Z", | ||
| 1645 | ), | ||
| 1646 | ).toBe("2026-02-27T21:10:00.000Z"); | ||
| 1647 | }); | ||
| 1648 | |||
| 1649 | it("uses the new send start while the session is running a different turn", () => { | ||
| 1650 | expect( | ||
| 1651 | deriveActiveWorkStartedAt( | ||
| 1652 | latestTurn, | ||
| 1653 | { | ||
| 1654 | status: "running", | ||
| 1655 | activeTurnId: TurnId.make("turn-2"), | ||
| 1656 | }, | ||
| 1657 | "2026-02-27T21:11:00.000Z", | ||
| 1658 | ), | ||
| 1659 | ).toBe("2026-02-27T21:11:00.000Z"); | ||
| 1660 | }); | ||
| 1661 | |||
| 1662 | it("falls back to sendStartedAt once the latest turn is settled", () => { | ||
| 1663 | expect( | ||
| 1664 | deriveActiveWorkStartedAt( | ||
| 1665 | latestTurn, | ||
| 1666 | { | ||
| 1667 | status: "ready", | ||
| 1668 | activeTurnId: null, | ||
| 1669 | }, | ||
| 1670 | "2026-02-27T21:11:00.000Z", | ||
| 1671 | ), | ||
| 1672 | ).toBe("2026-02-27T21:11:00.000Z"); | ||
| 1673 | }); | ||
| 1674 | |||
| 1675 | it("uses sendStartedAt for a fresh send after the prior turn completed", () => { | ||
| 1676 | expect( | ||
| 1677 | deriveActiveWorkStartedAt( | ||
| 1678 | { | ||
| 1679 | turnId: TurnId.make("turn-1"), | ||
| 1680 | startedAt: "2026-02-27T21:10:00.000Z", | ||
| 1681 | completedAt: "2026-02-27T21:10:06.000Z", | ||
| 1682 | }, | ||
| 1683 | null, | ||
| 1684 | "2026-02-27T21:11:00.000Z", | ||
| 1685 | ), | ||
| 1686 | ).toBe("2026-02-27T21:11:00.000Z"); | ||
| 1687 | }); | 158 | }); |
| 1688 | }); | 159 | }); |
| @@ -1,26 +1,24 @@ | |||
| 1 | import * as Option from "effect/Option"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import { | 1 | import { |
| 4 | ApprovalRequestId, | ||
| 5 | isToolLifecycleItemType, | ||
| 6 | type OrchestrationLatestTurn, | ||
| 7 | type OrchestrationThreadActivity, | ||
| 8 | type OrchestrationProposedPlanId, | ||
| 9 | ProviderDriverKind, | 2 | ProviderDriverKind, |
| 10 | type ToolLifecycleItemType, | 3 | type OrchestrationV2ProjectedTurnItem, |
| 11 | type UserInputQuestion, | 4 | type OrchestrationV2TurnItem, |
| 5 | type PlanId, | ||
| 6 | type RunId, | ||
| 12 | type ThreadId, | 7 | type ThreadId, |
| 13 | type TurnId, | ||
| 14 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 15 | |||
| 16 | import type { | 9 | import type { |
| 17 | ChatMessage, | 10 | ThreadCheckpointSummary, |
| 18 | ProposedPlan, | 11 | ThreadPendingApproval, |
| 19 | SessionPhase, | 12 | ThreadPendingUserInput, |
| 20 | Thread, | 13 | ThreadProposedPlan, |
| 21 | ThreadSession, | 14 | ThreadRunSummary, |
| 22 | TurnDiffSummary, | 15 | ThreadRuntimeSummary, |
| 23 | } from "./types"; | 16 | ThreadTodoPlan, |
| 17 | ThreadWorkEntry, | ||
| 18 | } from "@t3tools/client-runtime/state/shell"; | ||
| 19 | |||
| 20 | import type { ChatMessage, ProposedPlan, SessionPhase, TurnDiffSummary } from "./types"; | ||
| 21 | import * as DateTime from "effect/DateTime"; | ||
| 24 | 22 | ||
| 25 | export type ProviderPickerKind = ProviderDriverKind; | 23 | export type ProviderPickerKind = ProviderDriverKind; |
| 26 | 24 | ||
| @@ -28,7 +26,6 @@ export const PROVIDER_OPTIONS: Array<{ · 1 unmodified line | |||
| 28 | value: ProviderPickerKind; | 26 | value: ProviderPickerKind; |
| 29 | label: string; | 27 | label: string; |
| 30 | available: boolean; | 28 | available: boolean; |
| 31 | /** Shown on the model picker sidebar when relevant */ | ||
| 32 | pickerSidebarBadge?: "new" | "soon"; | 29 | pickerSidebarBadge?: "new" | "soon"; |
| 33 | }> = [ | 30 | }> = [ |
| 34 | { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, | 31 | { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, |
| @@ -45,221 +42,101 @@ export const PROVIDER_OPTIONS: Array<{ · 10 unmodified lines | |||
| 45 | available: true, | 42 | available: true, |
| 46 | pickerSidebarBadge: "new", | 43 | pickerSidebarBadge: "new", |
| 47 | }, | 44 | }, |
| 48 | { | 45 | { value: ProviderDriverKind.make("grok"), label: "Grok", available: true }, |
| 49 | value: ProviderDriverKind.make("grok"), | ||
| 50 | label: "Grok", | ||
| 51 | available: true, | ||
| 52 | pickerSidebarBadge: "new", | ||
| 53 | }, | ||
| 54 | ]; | 46 | ]; |
| 55 | 47 | ||
| 56 | export type WorkLogToolLifecycleStatus = | 48 | export type WorkLogToolLifecycleStatus = ThreadWorkEntry["toolLifecycleStatus"]; |
| 57 | | "inProgress" | ||
| 58 | | "completed" | ||
| 59 | | "failed" | ||
| 60 | | "declined" | ||
| 61 | | "stopped"; | ||
| 62 | |||
| 63 | export interface WorkLogEntry { | ||
| 64 | id: string; | ||
| 65 | createdAt: string; | ||
| 66 | turnId?: TurnId | null; | ||
| 67 | label: string; | ||
| 68 | detail?: string; | ||
| 69 | command?: string; | ||
| 70 | rawCommand?: string; | ||
| 71 | changedFiles?: ReadonlyArray<string>; | ||
| 72 | tone: "thinking" | "tool" | "info" | "error"; | ||
| 73 | toolTitle?: string; | ||
| 74 | toolData?: unknown; | ||
| 75 | itemType?: ToolLifecycleItemType; | ||
| 76 | requestKind?: PendingApproval["requestKind"]; | ||
| 77 | /** From runtime item / task payload `status` when present (e.g. tool.updated). */ | ||
| 78 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; | ||
| 79 | /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */ | ||
| 80 | sourceActivityKind?: OrchestrationThreadActivity["kind"]; | ||
| 81 | } | ||
| 82 | |||
| 83 | interface DerivedWorkLogEntry extends WorkLogEntry { | ||
| 84 | activityKind: OrchestrationThreadActivity["kind"]; | ||
| 85 | collapseKey?: string; | ||
| 86 | toolCallId?: string; | ||
| 87 | } | ||
| 88 | 49 | ||
| 89 | export interface PendingApproval { | 50 | export interface WorkLogEntry extends Omit< |
| 90 | requestId: ApprovalRequestId; | 51 | ThreadWorkEntry, |
| 91 | requestKind: "command" | "file-read" | "file-change"; | 52 | "structuredPayload" | "runId" | "itemType" | "toolLifecycleStatus" |
| 92 | createdAt: string; | 53 | > { |
| 93 | detail?: string; | 54 | readonly runId?: RunId | null; |
| 55 | readonly itemType?: ThreadWorkEntry["itemType"]; | ||
| 56 | readonly toolLifecycleStatus?: ThreadWorkEntry["toolLifecycleStatus"]; | ||
| 57 | readonly structuredPayload?: ThreadWorkEntry["structuredPayload"]; | ||
| 58 | readonly sourceItemType?: ThreadWorkEntry["itemType"]; | ||
| 59 | readonly projectedItem?: OrchestrationV2ProjectedTurnItem; | ||
| 94 | } | 60 | } |
| 95 | 61 | ||
| 96 | export interface PendingUserInput { | 62 | export type PendingApproval = ThreadPendingApproval; |
| 97 | requestId: ApprovalRequestId; | 63 | export type PendingUserInput = ThreadPendingUserInput; |
| 98 | createdAt: string; | ||
| 99 | questions: ReadonlyArray<UserInputQuestion>; | ||
| 100 | } | ||
| 101 | 64 | ||
| 102 | export interface ActivePlanState { | 65 | export interface ActivePlanState { |
| 103 | createdAt: string; | 66 | readonly createdAt: string; |
| 104 | turnId: TurnId | null; | 67 | readonly runId: RunId | null; |
| 105 | explanation?: string | null; | 68 | readonly explanation?: string | null; |
| 106 | steps: Array<{ | 69 | readonly steps: Array<{ |
| 107 | step: string; | 70 | readonly step: string; |
| 108 | status: "pending" | "inProgress" | "completed"; | 71 | readonly status: "pending" | "inProgress" | "completed"; |
| 109 | }>; | 72 | }>; |
| 110 | } | 73 | } |
| 111 | 74 | ||
| 112 | export interface LatestProposedPlanState { | 75 | export interface LatestProposedPlanState { |
| 113 | id: OrchestrationProposedPlanId; | 76 | readonly id: PlanId; |
| 114 | createdAt: string; | 77 | readonly createdAt: string; |
| 115 | updatedAt: string; | 78 | readonly updatedAt: string; |
| 116 | turnId: TurnId | null; | 79 | readonly runId: RunId | null; |
| 117 | planMarkdown: string; | 80 | readonly planMarkdown: string; |
| 118 | implementedAt: string | null; | 81 | readonly implementedAt: string | null; |
| 119 | implementationThreadId: ThreadId | null; | 82 | readonly implementationThreadId: ThreadId | null; |
| 83 | readonly status: ThreadProposedPlan["status"]; | ||
| 120 | } | 84 | } |
| 121 | 85 | ||
| 122 | export type TimelineEntry = | 86 | export type TimelineEntry = |
| 123 | | { | 87 | | { |
| 124 | id: string; | 88 | readonly id: string; |
| 125 | kind: "message"; | 89 | readonly kind: "message"; |
| 126 | createdAt: string; | 90 | readonly createdAt: string; |
| 127 | message: ChatMessage; | 91 | readonly message: ChatMessage; |
| 92 | } | ||
| 93 | | { | ||
| 94 | readonly id: string; | ||
| 95 | readonly kind: "proposed-plan"; | ||
| 96 | readonly createdAt: string; | ||
| 97 | readonly proposedPlan: ProposedPlan; | ||
| 128 | } | 98 | } |
| 129 | | { | 99 | | { |
| 130 | id: string; | 100 | readonly id: string; |
| 131 | kind: "proposed-plan"; | 101 | readonly kind: "work"; |
| 132 | createdAt: string; | 102 | readonly createdAt: string; |
| 133 | proposedPlan: ProposedPlan; | 103 | readonly entry: WorkLogEntry; |
| 134 | } | 104 | } |
| 135 | | { | 105 | | { |
| 136 | id: string; | 106 | readonly id: string; |
| 137 | kind: "work"; | 107 | readonly kind: "event"; |
| 138 | createdAt: string; | 108 | readonly createdAt: string; |
| 139 | entry: WorkLogEntry; | 109 | readonly projectedItem: OrchestrationV2ProjectedTurnItem; |
| 140 | }; | 110 | }; |
| 141 | 111 | ||
| 142 | export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { | 112 | export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { |
| 143 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { | 113 | return ( |
| 144 | return true; | 114 | entry.tone === "tool" || |
| 145 | } | 115 | entry.tone === "thinking" || |
| 146 | if (entry.command !== undefined && entry.command.trim().length > 0) { | 116 | entry.tone === "error" || |
| 147 | return true; | 117 | entry.command !== undefined || |
| 148 | } | 118 | entry.requestKind !== undefined |
| 149 | if (entry.requestKind !== undefined) { | 119 | ); |
| 150 | return true; | ||
| 151 | } | ||
| 152 | return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); | ||
| 153 | } | ||
| 154 | |||
| 155 | /** Heuristic: providers often emit successful lifecycle status while error text lives in `detail` / `command`. */ | ||
| 156 | function toolDetailTextLooksLikeFailure(text: string): boolean { | ||
| 157 | const t = text.toLowerCase(); | ||
| 158 | if (t.includes("file not found")) { | ||
| 159 | return true; | ||
| 160 | } | ||
| 161 | if (t.includes("no files found")) { | ||
| 162 | return true; | ||
| 163 | } | ||
| 164 | if ( | ||
| 165 | t.includes("enoent") || | ||
| 166 | t.includes("no such file or directory") || | ||
| 167 | t.includes("no such file") | ||
| 168 | ) { | ||
| 169 | return true; | ||
| 170 | } | ||
| 171 | if (t.includes("cannot find path") && t.includes("because it does not exist")) { | ||
| 172 | return true; | ||
| 173 | } | ||
| 174 | if (t.includes("commandnotfoundexception")) { | ||
| 175 | return true; | ||
| 176 | } | ||
| 177 | if (t.includes("is not recognized as the name of a cmdlet")) { | ||
| 178 | return true; | ||
| 179 | } | ||
| 180 | if (t.includes("is not recognized") && t.includes("the term '")) { | ||
| 181 | return true; | ||
| 182 | } | ||
| 183 | if (t.includes("a parameter cannot be found that matches parameter name")) { | ||
| 184 | return true; | ||
| 185 | } | ||
| 186 | if (t.includes("command not found")) { | ||
| 187 | return true; | ||
| 188 | } | ||
| 189 | if (/<exited with exit code\s+[1-9]\d*\s*>/i.test(text)) { | ||
| 190 | return true; | ||
| 191 | } | ||
| 192 | if (/exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text)) { | ||
| 193 | return true; | ||
| 194 | } | ||
| 195 | if (/exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)) { | ||
| 196 | return true; | ||
| 197 | } | ||
| 198 | return false; | ||
| 199 | } | 120 | } |
| 200 | 121 | ||
| 201 | /** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */ | ||
| 202 | export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { | 122 | export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { |
| 203 | if (entry.tone === "error") { | 123 | return ( |
| 204 | return true; | 124 | entry.tone === "error" || |
| 205 | } | 125 | entry.toolLifecycleStatus === "failed" || |
| 206 | const ls = entry.toolLifecycleStatus; | 126 | entry.toolLifecycleStatus === "declined" |
| 207 | if (ls === "failed" || ls === "declined") { | 127 | ); |
| 208 | return true; | ||
| 209 | } | ||
| 210 | if (!workLogEntryIsToolLike(entry)) { | ||
| 211 | return false; | ||
| 212 | } | ||
| 213 | const parts: string[] = []; | ||
| 214 | if (entry.detail) { | ||
| 215 | parts.push(entry.detail); | ||
| 216 | } | ||
| 217 | if (entry.command) { | ||
| 218 | parts.push(entry.command); | ||
| 219 | } | ||
| 220 | const blob = parts.join("\n"); | ||
| 221 | if (blob.length === 0) { | ||
| 222 | return false; | ||
| 223 | } | ||
| 224 | return toolDetailTextLooksLikeFailure(blob); | ||
| 225 | } | 128 | } |
| 226 | 129 | ||
| 227 | /** Tool/command row completed without failure (blue check affordance). */ | ||
| 228 | export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | 130 | export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { |
| 229 | if (!workLogEntryIsToolLike(entry)) { | 131 | return workLogEntryIsToolLike(entry) && entry.toolLifecycleStatus === "completed"; |
| 230 | return false; | ||
| 231 | } | ||
| 232 | if (workEntryIndicatesToolFailure(entry)) { | ||
| 233 | return false; | ||
| 234 | } | ||
| 235 | if (entry.tone === "thinking") { | ||
| 236 | return false; | ||
| 237 | } | ||
| 238 | const ls = entry.toolLifecycleStatus; | ||
| 239 | if (ls === "failed" || ls === "declined") { | ||
| 240 | return false; | ||
| 241 | } | ||
| 242 | if (ls === "inProgress") { | ||
| 243 | return false; | ||
| 244 | } | ||
| 245 | if (ls === "stopped") { | ||
| 246 | return false; | ||
| 247 | } | ||
| 248 | return true; | ||
| 249 | } | 132 | } |
| 250 | 133 | ||
| 251 | /** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ | ||
| 252 | export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { | 134 | export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { |
| 253 | if (!workLogEntryIsToolLike(entry)) { | 135 | return ( |
| 254 | return false; | 136 | workLogEntryIsToolLike(entry) && |
| 255 | } | 137 | !workEntryIndicatesToolFailure(entry) && |
| 256 | if (workEntryIndicatesToolFailure(entry)) { | 138 | !workEntryIndicatesToolSuccess(entry) |
| 257 | return false; | 139 | ); |
| 258 | } | ||
| 259 | if (workEntryIndicatesToolSuccess(entry)) { | ||
| 260 | return false; | ||
| 261 | } | ||
| 262 | return true; | ||
| 263 | } | 140 | } |
| 264 | 141 | ||
| 265 | export function formatDuration(durationMs: number): string { | 142 | export function formatDuration(durationMs: number): string { |
| @@ -267,7 +144,6 @@ export function formatDuration(durationMs: number): string { · 1 unmodified line | |||
| 267 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; | 144 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; |
| 268 | if (durationMs < 10_000) { | 145 | if (durationMs < 10_000) { |
| 269 | const tenths = Math.round(durationMs / 100) / 10; | 146 | const tenths = Math.round(durationMs / 100) / 10; |
| 270 | // 9.95s+ rounds up to the next bucket — render "10s", not "10.0s". | ||
| 271 | return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`; | 147 | return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`; |
| 272 | } | 148 | } |
| 273 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; | 149 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; |
| @@ -282,1112 +158,384 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str · 8 unmodified lines | |||
| 282 | if (!endIso) return null; | 158 | if (!endIso) return null; |
| 283 | const startedAt = Date.parse(startIso); | 159 | const startedAt = Date.parse(startIso); |
| 284 | const endedAt = Date.parse(endIso); | 160 | const endedAt = Date.parse(endIso); |
| 285 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { | 161 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) return null; |
| 286 | return null; | ||
| 287 | } | ||
| 288 | return formatDuration(endedAt - startedAt); | 162 | return formatDuration(endedAt - startedAt); |
| 289 | } | 163 | } |
| 290 | 164 | ||
| 291 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; | 165 | export function isLatestRunSettled( |
| 292 | type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">; | 166 | latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null, |
| 293 | 167 | runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null, | |
| 294 | export function isLatestTurnSettled( | ||
| 295 | latestTurn: LatestTurnTiming | null, | ||
| 296 | session: SessionActivityState | null, | ||
| 297 | ): boolean { | 168 | ): boolean { |
| 298 | if (!latestTurn?.startedAt) return false; | 169 | if (latestRun === null) return false; |
| 299 | if (!latestTurn.completedAt) return false; | 170 | if ( |
| 300 | if (!session) return true; | 171 | latestRun.status === "starting" || |
| 301 | if (session.status === "running") return false; | 172 | latestRun.status === "running" || |
| 302 | return true; | 173 | latestRun.status === "waiting" |
| 174 | ) | ||
| 175 | return false; | ||
| 176 | return runtime?.activeRunId !== latestRun.runId; | ||
| 303 | } | 177 | } |
| 304 | 178 | ||
| 305 | export function deriveActiveWorkStartedAt( | 179 | export function deriveActiveWorkStartedAt( |
| 306 | latestTurn: LatestTurnTiming | null, | 180 | latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null, |
| 307 | session: SessionActivityState | null, | 181 | runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null, |
| 308 | sendStartedAt: string | null, | 182 | sendStartedAt: string | null, |
| 309 | ): string | null { | 183 | ): string | null { |
| 310 | const runningTurnId = session?.status === "running" ? session.activeTurnId : null; | 184 | if (runtime?.activeRunId !== null && runtime?.activeRunId !== undefined) { |
| 311 | if (runningTurnId !== null) { | 185 | return latestRun?.runId === runtime.activeRunId |
| 312 | if (latestTurn?.turnId === runningTurnId) { | 186 | ? (latestRun.startedAt ?? sendStartedAt) |
| 313 | return latestTurn.startedAt ?? sendStartedAt; | 187 | : sendStartedAt; |
| 314 | } | ||
| 315 | return sendStartedAt; | ||
| 316 | } | 188 | } |
| 317 | if (!isLatestTurnSettled(latestTurn, session)) { | 189 | return isLatestRunSettled(latestRun, runtime) |
| 318 | return latestTurn?.startedAt ?? sendStartedAt; | 190 | ? sendStartedAt |
| 319 | } | 191 | : (latestRun?.startedAt ?? sendStartedAt); |
| 320 | return sendStartedAt; | ||
| 321 | } | ||
| 322 | |||
| 323 | function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { | ||
| 324 | switch (requestType) { | ||
| 325 | case "command_execution_approval": | ||
| 326 | case "exec_command_approval": | ||
| 327 | case "dynamic_tool_call": | ||
| 328 | return "command"; | ||
| 329 | case "file_read_approval": | ||
| 330 | return "file-read"; | ||
| 331 | case "file_change_approval": | ||
| 332 | case "apply_patch_approval": | ||
| 333 | return "file-change"; | ||
| 334 | default: | ||
| 335 | return null; | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { | ||
| 340 | const normalized = detail?.toLowerCase(); | ||
| 341 | if (!normalized) { | ||
| 342 | return false; | ||
| 343 | } | ||
| 344 | return ( | ||
| 345 | normalized.includes("stale pending approval request") || | ||
| 346 | normalized.includes("stale pending user-input request") || | ||
| 347 | normalized.includes("unknown pending approval request") || | ||
| 348 | normalized.includes("unknown pending permission request") || | ||
| 349 | normalized.includes("unknown pending user-input request") || | ||
| 350 | normalized.includes("unknown pending user input request") || | ||
| 351 | normalized.includes("unknown pending codex user input request") | ||
| 352 | ); | ||
| 353 | } | 192 | } |
| 354 | 193 | ||
| 355 | export function derivePendingApprovals( | 194 | export function derivePendingApprovals( |
| 356 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 195 | approvals: ReadonlyArray<ThreadPendingApproval>, |
| 357 | ): PendingApproval[] { | 196 | ): ThreadPendingApproval[] { |
| 358 | const openByRequestId = new Map<ApprovalRequestId, PendingApproval>(); | 197 | return [...approvals].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); |
| 359 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | ||
| 360 | |||
| 361 | for (const activity of ordered) { | ||
| 362 | const payload = | ||
| 363 | activity.payload && typeof activity.payload === "object" | ||
| 364 | ? (activity.payload as Record<string, unknown>) | ||
| 365 | : null; | ||
| 366 | const requestId = | ||
| 367 | payload && typeof payload.requestId === "string" | ||
| 368 | ? ApprovalRequestId.make(payload.requestId) | ||
| 369 | : null; | ||
| 370 | const requestKind = | ||
| 371 | payload && | ||
| 372 | (payload.requestKind === "command" || | ||
| 373 | payload.requestKind === "file-read" || | ||
| 374 | payload.requestKind === "file-change") | ||
| 375 | ? payload.requestKind | ||
| 376 | : payload | ||
| 377 | ? requestKindFromRequestType(payload.requestType) | ||
| 378 | : null; | ||
| 379 | const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; | ||
| 380 | |||
| 381 | if (activity.kind === "approval.requested" && requestId && requestKind) { | ||
| 382 | openByRequestId.set(requestId, { | ||
| 383 | requestId, | ||
| 384 | requestKind, | ||
| 385 | createdAt: activity.createdAt, | ||
| 386 | ...(detail ? { detail } : {}), | ||
| 387 | }); | ||
| 388 | continue; | ||
| 389 | } | ||
| 390 | |||
| 391 | if (activity.kind === "approval.resolved" && requestId) { | ||
| 392 | openByRequestId.delete(requestId); | ||
| 393 | continue; | ||
| 394 | } | ||
| 395 | |||
| 396 | if ( | ||
| 397 | activity.kind === "provider.approval.respond.failed" && | ||
| 398 | requestId && | ||
| 399 | isStalePendingRequestFailureDetail(detail) | ||
| 400 | ) { | ||
| 401 | openByRequestId.delete(requestId); | ||
| 402 | continue; | ||
| 403 | } | ||
| 404 | } | ||
| 405 | |||
| 406 | return [...openByRequestId.values()].toSorted((left, right) => | ||
| 407 | left.createdAt.localeCompare(right.createdAt), | ||
| 408 | ); | ||
| 409 | } | ||
| 410 | |||
| 411 | function parseUserInputQuestions( | ||
| 412 | payload: Record<string, unknown> | null, | ||
| 413 | ): ReadonlyArray<UserInputQuestion> | null { | ||
| 414 | const questions = payload?.questions; | ||
| 415 | if (!Array.isArray(questions)) { | ||
| 416 | return null; | ||
| 417 | } | ||
| 418 | const parsed = questions | ||
| 419 | .map<UserInputQuestion | null>((entry) => { | ||
| 420 | if (!entry || typeof entry !== "object") return null; | ||
| 421 | const question = entry as Record<string, unknown>; | ||
| 422 | if ( | ||
| 423 | typeof question.id !== "string" || | ||
| 424 | typeof question.header !== "string" || | ||
| 425 | typeof question.question !== "string" || | ||
| 426 | !Array.isArray(question.options) | ||
| 427 | ) { | ||
| 428 | return null; | ||
| 429 | } | ||
| 430 | const options = question.options | ||
| 431 | .map<UserInputQuestion["options"][number] | null>((option) => { | ||
| 432 | if (!option || typeof option !== "object") return null; | ||
| 433 | const optionRecord = option as Record<string, unknown>; | ||
| 434 | if ( | ||
| 435 | typeof optionRecord.label !== "string" || | ||
| 436 | typeof optionRecord.description !== "string" | ||
| 437 | ) { | ||
| 438 | return null; | ||
| 439 | } | ||
| 440 | return { | ||
| 441 | label: optionRecord.label, | ||
| 442 | description: optionRecord.description, | ||
| 443 | }; | ||
| 444 | }) | ||
| 445 | .filter((option): option is UserInputQuestion["options"][number] => option !== null); | ||
| 446 | if (options.length === 0) { | ||
| 447 | return null; | ||
| 448 | } | ||
| 449 | return { | ||
| 450 | id: question.id, | ||
| 451 | header: question.header, | ||
| 452 | question: question.question, | ||
| 453 | options, | ||
| 454 | multiSelect: question.multiSelect === true, | ||
| 455 | }; | ||
| 456 | }) | ||
| 457 | .filter((question): question is UserInputQuestion => question !== null); | ||
| 458 | return parsed.length > 0 ? parsed : null; | ||
| 459 | } | 198 | } |
| 460 | 199 | ||
| 461 | export function derivePendingUserInputs( | 200 | export function derivePendingUserInputs( |
| 462 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 201 | inputs: ReadonlyArray<ThreadPendingUserInput>, |
| 463 | ): PendingUserInput[] { | 202 | ): ThreadPendingUserInput[] { |
| 464 | const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>(); | 203 | return [...inputs].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); |
| 465 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | ||
| 466 | |||
| 467 | for (const activity of ordered) { | ||
| 468 | const payload = | ||
| 469 | activity.payload && typeof activity.payload === "object" | ||
| 470 | ? (activity.payload as Record<string, unknown>) | ||
| 471 | : null; | ||
| 472 | const requestId = | ||
| 473 | payload && typeof payload.requestId === "string" | ||
| 474 | ? ApprovalRequestId.make(payload.requestId) | ||
| 475 | : null; | ||
| 476 | const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; | ||
| 477 | |||
| 478 | if (activity.kind === "user-input.requested" && requestId) { | ||
| 479 | const questions = parseUserInputQuestions(payload); | ||
| 480 | if (!questions) { | ||
| 481 | continue; | ||
| 482 | } | ||
| 483 | openByRequestId.set(requestId, { | ||
| 484 | requestId, | ||
| 485 | createdAt: activity.createdAt, | ||
| 486 | questions, | ||
| 487 | }); | ||
| 488 | continue; | ||
| 489 | } | ||
| 490 | |||
| 491 | if (activity.kind === "user-input.resolved" && requestId) { | ||
| 492 | openByRequestId.delete(requestId); | ||
| 493 | continue; | ||
| 494 | } | ||
| 495 | |||
| 496 | if ( | ||
| 497 | activity.kind === "provider.user-input.respond.failed" && | ||
| 498 | requestId && | ||
| 499 | isStalePendingRequestFailureDetail(detail) | ||
| 500 | ) { | ||
| 501 | openByRequestId.delete(requestId); | ||
| 502 | } | ||
| 503 | } | ||
| 504 | |||
| 505 | return [...openByRequestId.values()].toSorted((left, right) => | ||
| 506 | left.createdAt.localeCompare(right.createdAt), | ||
| 507 | ); | ||
| 508 | } | 204 | } |
| 509 | 205 | ||
| 510 | export function deriveActivePlanState( | 206 | export function deriveActivePlanState( |
| 511 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 207 | plans: ReadonlyArray<ThreadTodoPlan>, |
| 512 | latestTurnId: TurnId | undefined, | 208 | latestRunId: RunId | undefined, |
| 513 | ): ActivePlanState | null { | 209 | ): ActivePlanState | null { |
| 514 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | 210 | const plan = |
| 515 | const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); | 211 | [...plans].toReversed().find((candidate) => candidate.runId === latestRunId) ?? |
| 516 | // Prefer plan from the current turn; fall back to the most recent plan from any turn | 212 | plans.at(-1) ?? |
| 517 | // so that TodoWrite tasks persist across follow-up messages. | 213 | null; |
| 518 | const latest = Option.firstSomeOf([ | 214 | if (plan === null || plan.steps.length === 0) return null; |
| 519 | ...(latestTurnId | 215 | return { |
| 520 | ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) | 216 | createdAt: plan.updatedAt, |
| 521 | : Option.none()), | 217 | runId: plan.runId, |
| 522 | Arr.last(allPlanActivities), | 218 | explanation: plan.explanation, |
| 523 | ]).pipe(Option.getOrNull); | 219 | steps: plan.steps.map(({ step, status }) => ({ step, status })), |
| 524 | if (!latest) { | 220 | }; |
| 525 | return null; | 221 | } |
| 526 | } | 222 | |
| 527 | const payload = | 223 | function toLatestProposedPlanState(plan: ThreadProposedPlan): LatestProposedPlanState { |
| 528 | latest.payload && typeof latest.payload === "object" | ||
| 529 | ? (latest.payload as Record<string, unknown>) | ||
| 530 | : null; | ||
| 531 | const rawPlan = payload?.plan; | ||
| 532 | if (!Array.isArray(rawPlan)) { | ||
| 533 | return null; | ||
| 534 | } | ||
| 535 | const steps: Array<{ | ||
| 536 | step: string; | ||
| 537 | status: "pending" | "inProgress" | "completed"; | ||
| 538 | }> = []; | ||
| 539 | for (const entry of rawPlan) { | ||
| 540 | if (!entry || typeof entry !== "object") { | ||
| 541 | continue; | ||
| 542 | } | ||
| 543 | const record = entry as Record<string, unknown>; | ||
| 544 | if (typeof record.step !== "string") { | ||
| 545 | continue; | ||
| 546 | } | ||
| 547 | const status = | ||
| 548 | record.status === "completed" || record.status === "inProgress" ? record.status : "pending"; | ||
| 549 | steps.push({ | ||
| 550 | step: record.step, | ||
| 551 | status, | ||
| 552 | }); | ||
| 553 | } | ||
| 554 | if (steps.length === 0) { | ||
| 555 | return null; | ||
| 556 | } | ||
| 557 | return { | 224 | return { |
| 558 | createdAt: latest.createdAt, | 225 | id: plan.id, |
| 559 | turnId: latest.turnId, | 226 | createdAt: plan.createdAt, |
| 560 | ...(payload && "explanation" in payload | 227 | updatedAt: plan.updatedAt, |
| 561 | ? { explanation: payload.explanation as string | null } | 228 | runId: plan.runId, |
| 562 | : {}), | 229 | planMarkdown: plan.planMarkdown, |
| 563 | steps, | 230 | implementedAt: plan.implementedAt, |
| 231 | implementationThreadId: plan.implementationThreadId, | ||
| 232 | status: plan.status, | ||
| 564 | }; | 233 | }; |
| 565 | } | 234 | } |
| 566 | 235 | ||
| 567 | export function findLatestProposedPlan( | 236 | export function findLatestProposedPlan( |
| 568 | proposedPlans: ReadonlyArray<ProposedPlan>, | 237 | plans: ReadonlyArray<ThreadProposedPlan>, |
| 569 | latestTurnId: TurnId | string | null | undefined, | 238 | latestRunId: RunId | string | null | undefined, |
| 570 | ): LatestProposedPlanState | null { | 239 | ): LatestProposedPlanState | null { |
| 571 | if (latestTurnId) { | 240 | const candidates = latestRunId ? plans.filter((plan) => plan.runId === latestRunId) : plans; |
| 572 | const matchingTurnPlan = [...proposedPlans] | 241 | const plan = [...(candidates.length > 0 ? candidates : plans)] |
| 573 | .filter((proposedPlan) => proposedPlan.turnId === latestTurnId) | ||
| 574 | .toSorted( | ||
| 575 | (left, right) => | ||
| 576 | left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), | ||
| 577 | ) | ||
| 578 | .at(-1); | ||
| 579 | if (matchingTurnPlan) { | ||
| 580 | return toLatestProposedPlanState(matchingTurnPlan); | ||
| 581 | } | ||
| 582 | } | ||
| 583 | |||
| 584 | const latestPlan = [...proposedPlans] | ||
| 585 | .toSorted( | 242 | .toSorted( |
| 586 | (left, right) => | 243 | (left, right) => |
| 587 | left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), | 244 | left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), |
| 588 | ) | 245 | ) |
| 589 | .at(-1); | 246 | .at(-1); |
| 590 | if (!latestPlan) { | 247 | return plan === undefined ? null : toLatestProposedPlanState(plan); |
| 591 | return null; | ||
| 592 | } | ||
| 593 | |||
| 594 | return toLatestProposedPlanState(latestPlan); | ||
| 595 | } | 248 | } |
| 596 | 249 | ||
| 597 | export function findSidebarProposedPlan(input: { | 250 | export function findSidebarProposedPlan(input: { |
| 598 | threads: ReadonlyArray<Pick<Thread, "id" | "proposedPlans">>; | 251 | readonly threads: ReadonlyArray< |
| 599 | latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "sourceProposedPlan"> | null; | 252 | Pick< |
| 600 | latestTurnSettled: boolean; | 253 | { readonly id: ThreadId; readonly proposedPlans: ReadonlyArray<ThreadProposedPlan> }, |
| 601 | threadId: ThreadId | string | null | undefined; | 254 | "id" | "proposedPlans" |
| 255 | > | ||
| 256 | >; | ||
| 257 | readonly latestRun: Pick<ThreadRunSummary, "runId" | "sourcePlanRef"> | null; | ||
| 258 | readonly latestRunSettled: boolean; | ||
| 259 | readonly threadId: ThreadId | string | null | undefined; | ||
| 602 | }): LatestProposedPlanState | null { | 260 | }): LatestProposedPlanState | null { |
| 603 | const activeThreadPlans = | 261 | if (!input.latestRunSettled && input.latestRun?.sourcePlanRef !== undefined) { |
| 262 | const source = input.latestRun.sourcePlanRef; | ||
| 263 | const plan = input.threads | ||
| 264 | .find((thread) => thread.id === source.threadId) | ||
| 265 | ?.proposedPlans.find((candidate) => candidate.id === source.planId); | ||
| 266 | if (plan !== undefined) return toLatestProposedPlanState(plan); | ||
| 267 | } | ||
| 268 | const activePlans = | ||
| 604 | input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; | 269 | input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; |
| 605 | 270 | return findLatestProposedPlan(activePlans, input.latestRun?.runId); | |
| 606 | if (!input.latestTurnSettled) { | ||
| 607 | const sourceProposedPlan = input.latestTurn?.sourceProposedPlan; | ||
| 608 | if (sourceProposedPlan) { | ||
| 609 | const sourcePlan = input.threads | ||
| 610 | .find((thread) => thread.id === sourceProposedPlan.threadId) | ||
| 611 | ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId); | ||
| 612 | if (sourcePlan) { | ||
| 613 | return toLatestProposedPlanState(sourcePlan); | ||
| 614 | } | ||
| 615 | } | ||
| 616 | } | ||
| 617 | |||
| 618 | return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null); | ||
| 619 | } | 271 | } |
| 620 | 272 | ||
| 621 | export function hasActionableProposedPlan( | 273 | export function hasActionableProposedPlan( |
| 622 | proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null, | 274 | plan: LatestProposedPlanState | Pick<ThreadProposedPlan, "implementedAt"> | null, |
| 623 | ): boolean { | ||
| 624 | return proposedPlan !== null && proposedPlan.implementedAt === null; | ||
| 625 | } | ||
| 626 | |||
| 627 | export function deriveWorkLogEntries( | ||
| 628 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 629 | ): WorkLogEntry[] { | ||
| 630 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | ||
| 631 | const entries: DerivedWorkLogEntry[] = []; | ||
| 632 | for (const activity of ordered) { | ||
| 633 | if (activity.kind === "tool.started") continue; | ||
| 634 | if (activity.kind === "task.started") continue; | ||
| 635 | if (activity.kind === "context-window.updated") continue; | ||
| 636 | if (activity.summary === "Checkpoint captured") continue; | ||
| 637 | if (isPlanBoundaryToolActivity(activity)) continue; | ||
| 638 | entries.push(toDerivedWorkLogEntry(activity)); | ||
| 639 | } | ||
| 640 | return collapseDerivedWorkLogEntries(entries).map((entry) => { | ||
| 641 | const { activityKind, collapseKey: _collapseKey, ...rest } = entry; | ||
| 642 | return Object.assign(rest, { sourceActivityKind: activityKind }); | ||
| 643 | }); | ||
| 644 | } | ||
| 645 | |||
| 646 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 647 | if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { | ||
| 648 | return false; | ||
| 649 | } | ||
| 650 | |||
| 651 | const payload = | ||
| 652 | activity.payload && typeof activity.payload === "object" | ||
| 653 | ? (activity.payload as Record<string, unknown>) | ||
| 654 | : null; | ||
| 655 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); | ||
| 656 | } | ||
| 657 | |||
| 658 | function extractWorkLogToolLifecycleStatus( | ||
| 659 | payload: Record<string, unknown> | null, | ||
| 660 | ): WorkLogToolLifecycleStatus | undefined { | ||
| 661 | if (!payload) { | ||
| 662 | return undefined; | ||
| 663 | } | ||
| 664 | const s = payload.status; | ||
| 665 | if ( | ||
| 666 | s === "inProgress" || | ||
| 667 | s === "completed" || | ||
| 668 | s === "failed" || | ||
| 669 | s === "declined" || | ||
| 670 | s === "stopped" | ||
| 671 | ) { | ||
| 672 | return s; | ||
| 673 | } | ||
| 674 | return undefined; | ||
| 675 | } | ||
| 676 | |||
| 677 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { | ||
| 678 | const payload = | ||
| 679 | activity.payload && typeof activity.payload === "object" | ||
| 680 | ? (activity.payload as Record<string, unknown>) | ||
| 681 | : null; | ||
| 682 | const commandPreview = extractToolCommand(payload); | ||
| 683 | const changedFiles = extractChangedFiles(payload); | ||
| 684 | const title = extractToolTitle(payload); | ||
| 685 | const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; | ||
| 686 | const taskSummary = | ||
| 687 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 | ||
| 688 | ? payload.summary | ||
| 689 | : null; | ||
| 690 | const taskDetailAsLabel = | ||
| 691 | isTaskActivity && | ||
| 692 | !taskSummary && | ||
| 693 | typeof payload?.detail === "string" && | ||
| 694 | payload.detail.length > 0 | ||
| 695 | ? payload.detail | ||
| 696 | : null; | ||
| 697 | const taskLabel = taskSummary || taskDetailAsLabel; | ||
| 698 | const detail = isTaskActivity | ||
| 699 | ? !taskDetailAsLabel && | ||
| 700 | payload && | ||
| 701 | typeof payload.detail === "string" && | ||
| 702 | payload.detail.length > 0 | ||
| 703 | ? stripTrailingExitCode(payload.detail).output | ||
| 704 | : null | ||
| 705 | : extractToolDetail(payload, title ?? activity.summary); | ||
| 706 | const toolCallId = isTaskActivity ? null : extractToolCallId(payload); | ||
| 707 | const entry: DerivedWorkLogEntry = { | ||
| 708 | id: activity.id, | ||
| 709 | createdAt: activity.createdAt, | ||
| 710 | turnId: activity.turnId, | ||
| 711 | label: taskLabel || activity.summary, | ||
| 712 | tone: | ||
| 713 | activity.kind === "task.progress" | ||
| 714 | ? "thinking" | ||
| 715 | : activity.tone === "approval" | ||
| 716 | ? "info" | ||
| 717 | : activity.tone, | ||
| 718 | activityKind: activity.kind, | ||
| 719 | }; | ||
| 720 | const itemType = extractWorkLogItemType(payload); | ||
| 721 | const requestKind = extractWorkLogRequestKind(payload); | ||
| 722 | if (detail) { | ||
| 723 | entry.detail = detail; | ||
| 724 | } | ||
| 725 | if (commandPreview.command) { | ||
| 726 | entry.command = commandPreview.command; | ||
| 727 | } | ||
| 728 | if (commandPreview.rawCommand) { | ||
| 729 | entry.rawCommand = commandPreview.rawCommand; | ||
| 730 | } | ||
| 731 | if (changedFiles.length > 0) { | ||
| 732 | entry.changedFiles = changedFiles; | ||
| 733 | } | ||
| 734 | if (title) { | ||
| 735 | entry.toolTitle = title; | ||
| 736 | } | ||
| 737 | if (itemType === "mcp_tool_call") { | ||
| 738 | const data = asRecord(payload?.data); | ||
| 739 | if (data?.item !== undefined) { | ||
| 740 | entry.toolData = data.item; | ||
| 741 | } | ||
| 742 | } | ||
| 743 | if (itemType) { | ||
| 744 | entry.itemType = itemType; | ||
| 745 | } | ||
| 746 | if (requestKind) { | ||
| 747 | entry.requestKind = requestKind; | ||
| 748 | } | ||
| 749 | if (toolCallId) { | ||
| 750 | entry.toolCallId = toolCallId; | ||
| 751 | } | ||
| 752 | let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); | ||
| 753 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { | ||
| 754 | toolLifecycleStatus = "completed"; | ||
| 755 | } | ||
| 756 | if (toolLifecycleStatus) { | ||
| 757 | entry.toolLifecycleStatus = toolLifecycleStatus; | ||
| 758 | } | ||
| 759 | const collapseKey = deriveToolLifecycleCollapseKey(entry); | ||
| 760 | if (collapseKey) { | ||
| 761 | entry.collapseKey = collapseKey; | ||
| 762 | } | ||
| 763 | return entry; | ||
| 764 | } | ||
| 765 | |||
| 766 | function collapseDerivedWorkLogEntries( | ||
| 767 | entries: ReadonlyArray<DerivedWorkLogEntry>, | ||
| 768 | ): DerivedWorkLogEntry[] { | ||
| 769 | const collapsed: DerivedWorkLogEntry[] = []; | ||
| 770 | for (const entry of entries) { | ||
| 771 | const previous = collapsed.at(-1); | ||
| 772 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { | ||
| 773 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); | ||
| 774 | continue; | ||
| 775 | } | ||
| 776 | collapsed.push(entry); | ||
| 777 | } | ||
| 778 | return collapsed; | ||
| 779 | } | ||
| 780 | |||
| 781 | function shouldCollapseToolLifecycleEntries( | ||
| 782 | previous: DerivedWorkLogEntry, | ||
| 783 | next: DerivedWorkLogEntry, | ||
| 784 | ): boolean { | 275 | ): boolean { |
| 785 | if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { | 276 | return plan !== null && plan.implementedAt === null; |
| 786 | return false; | ||
| 787 | } | ||
| 788 | if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { | ||
| 789 | return false; | ||
| 790 | } | ||
| 791 | if (previous.activityKind === "tool.completed") { | ||
| 792 | return false; | ||
| 793 | } | ||
| 794 | if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { | ||
| 795 | return true; | ||
| 796 | } | ||
| 797 | return ( | ||
| 798 | previous.toolCallId !== undefined && | ||
| 799 | next.toolCallId === undefined && | ||
| 800 | previous.itemType === next.itemType && | ||
| 801 | normalizeCompactToolLabel(previous.toolTitle ?? previous.label) === | ||
| 802 | normalizeCompactToolLabel(next.toolTitle ?? next.label) | ||
| 803 | ); | ||
| 804 | } | ||
| 805 | |||
| 806 | function mergeDerivedWorkLogEntries( | ||
| 807 | previous: DerivedWorkLogEntry, | ||
| 808 | next: DerivedWorkLogEntry, | ||
| 809 | ): DerivedWorkLogEntry { | ||
| 810 | const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); | ||
| 811 | const detail = next.detail ?? previous.detail; | ||
| 812 | const command = next.command ?? previous.command; | ||
| 813 | const rawCommand = next.rawCommand ?? previous.rawCommand; | ||
| 814 | const toolTitle = next.toolTitle ?? previous.toolTitle; | ||
| 815 | const itemType = next.itemType ?? previous.itemType; | ||
| 816 | const requestKind = next.requestKind ?? previous.requestKind; | ||
| 817 | const collapseKey = next.collapseKey ?? previous.collapseKey; | ||
| 818 | const toolCallId = next.toolCallId ?? previous.toolCallId; | ||
| 819 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; | ||
| 820 | const toolData = next.toolData ?? previous.toolData; | ||
| 821 | return { | ||
| 822 | ...previous, | ||
| 823 | ...next, | ||
| 824 | ...(detail ? { detail } : {}), | ||
| 825 | ...(command ? { command } : {}), | ||
| 826 | ...(rawCommand ? { rawCommand } : {}), | ||
| 827 | ...(changedFiles.length > 0 ? { changedFiles } : {}), | ||
| 828 | ...(toolTitle ? { toolTitle } : {}), | ||
| 829 | ...(itemType ? { itemType } : {}), | ||
| 830 | ...(requestKind ? { requestKind } : {}), | ||
| 831 | ...(collapseKey ? { collapseKey } : {}), | ||
| 832 | ...(toolCallId ? { toolCallId } : {}), | ||
| 833 | ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), | ||
| 834 | ...(toolData !== undefined ? { toolData } : {}), | ||
| 835 | }; | ||
| 836 | } | 277 | } |
| 837 | 278 | ||
| 838 | function mergeChangedFiles( | 279 | export function deriveWorkLogEntries(entries: ReadonlyArray<ThreadWorkEntry>): WorkLogEntry[] { |
| 839 | previous: ReadonlyArray<string> | undefined, | 280 | return entries.map((entry) => ({ ...entry, sourceItemType: entry.itemType })); |
| 840 | next: ReadonlyArray<string> | undefined, | ||
| 841 | ): string[] { | ||
| 842 | const merged = [...(previous ?? []), ...(next ?? [])]; | ||
| 843 | if (merged.length === 0) { | ||
| 844 | return []; | ||
| 845 | } | ||
| 846 | return [...new Set(merged)]; | ||
| 847 | } | ||
| 848 | |||
| 849 | function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { | ||
| 850 | if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { | ||
| 851 | return undefined; | ||
| 852 | } | ||
| 853 | if (entry.toolCallId) { | ||
| 854 | return `tool:${entry.toolCallId}`; | ||
| 855 | } | ||
| 856 | const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); | ||
| 857 | const detail = entry.detail?.trim() ?? ""; | ||
| 858 | const itemType = entry.itemType ?? ""; | ||
| 859 | if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) { | ||
| 860 | return undefined; | ||
| 861 | } | ||
| 862 | return [itemType, normalizedLabel, detail].join("\u001f"); | ||
| 863 | } | 281 | } |
| 864 | 282 | ||
| 865 | function normalizeCompactToolLabel(value: string): string { | 283 | export function deriveTimelineEntries( |
| 866 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | 284 | messages: ReadonlyArray<ChatMessage>, |
| 867 | } | 285 | proposedPlans: ReadonlyArray<ThreadProposedPlan>, |
| 868 | 286 | workEntries: ReadonlyArray<WorkLogEntry>, | |
| 869 | function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPlanState { | 287 | ): TimelineEntry[] { |
| 870 | return { | 288 | return [ |
| 871 | id: proposedPlan.id, | 289 | ...messages.map( |
| 872 | createdAt: proposedPlan.createdAt, | 290 | (message): TimelineEntry => ({ |
| 873 | updatedAt: proposedPlan.updatedAt, | 291 | id: message.id, |
| 874 | turnId: proposedPlan.turnId, | 292 | kind: "message", |
| 875 | planMarkdown: proposedPlan.planMarkdown, | 293 | createdAt: message.createdAt, |
| 876 | implementedAt: proposedPlan.implementedAt, | 294 | message, |
| 877 | implementationThreadId: proposedPlan.implementationThreadId, | 295 | }), |
| 878 | }; | 296 | ), |
| 879 | } | 297 | ...proposedPlans.map( |
| 880 | 298 | (proposedPlan): TimelineEntry => ({ | |
| 881 | function asRecord(value: unknown): Record<string, unknown> | null { | 299 | id: proposedPlan.id, |
| 882 | return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | 300 | kind: "proposed-plan", |
| 883 | } | 301 | createdAt: proposedPlan.createdAt, |
| 884 | 302 | proposedPlan, | |
| 885 | function asTrimmedString(value: unknown): string | null { | 303 | }), |
| 886 | if (typeof value !== "string") { | 304 | ), |
| 887 | return null; | 305 | ...workEntries.map( |
| 888 | } | 306 | (entry): TimelineEntry => ({ |
| 889 | const trimmed = value.trim(); | 307 | id: entry.id, |
| 890 | return trimmed.length > 0 ? trimmed : null; | 308 | kind: "work", |
| 891 | } | 309 | createdAt: entry.createdAt, |
| 892 | 310 | entry, | |
| 893 | function asNumber(value: unknown): number | null { | 311 | }), |
| 894 | return typeof value === "number" && Number.isFinite(value) ? value : null; | 312 | ), |
| 895 | } | 313 | ].toSorted( |
| 896 | 314 | (left, right) => | |
| 897 | function trimMatchingOuterQuotes(value: string): string { | 315 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), |
| 898 | const trimmed = value.trim(); | ||
| 899 | if ( | ||
| 900 | (trimmed.startsWith("'") && trimmed.endsWith("'")) || | ||
| 901 | (trimmed.startsWith('"') && trimmed.endsWith('"')) | ||
| 902 | ) { | ||
| 903 | const unquoted = trimmed.slice(1, -1).trim(); | ||
| 904 | return unquoted.length > 0 ? unquoted : trimmed; | ||
| 905 | } | ||
| 906 | return trimmed; | ||
| 907 | } | ||
| 908 | |||
| 909 | function executableBasename(value: string): string | null { | ||
| 910 | const trimmed = trimMatchingOuterQuotes(value); | ||
| 911 | if (trimmed.length === 0) { | ||
| 912 | return null; | ||
| 913 | } | ||
| 914 | const normalized = trimmed.replace(/\\/g, "/"); | ||
| 915 | const segments = normalized.split("/"); | ||
| 916 | const last = segments.at(-1)?.trim() ?? ""; | ||
| 917 | return last.length > 0 ? last.toLowerCase() : null; | ||
| 918 | } | ||
| 919 | |||
| 920 | function splitExecutableAndRest(value: string): { executable: string; rest: string } | null { | ||
| 921 | const trimmed = value.trim(); | ||
| 922 | if (trimmed.length === 0) { | ||
| 923 | return null; | ||
| 924 | } | ||
| 925 | |||
| 926 | if (trimmed.startsWith('"') || trimmed.startsWith("'")) { | ||
| 927 | const quote = trimmed.charAt(0); | ||
| 928 | const closeIndex = trimmed.indexOf(quote, 1); | ||
| 929 | if (closeIndex <= 0) { | ||
| 930 | return null; | ||
| 931 | } | ||
| 932 | return { | ||
| 933 | executable: trimmed.slice(0, closeIndex + 1), | ||
| 934 | rest: trimmed.slice(closeIndex + 1).trim(), | ||
| 935 | }; | ||
| 936 | } | ||
| 937 | |||
| 938 | const firstWhitespace = trimmed.search(/\s/); | ||
| 939 | if (firstWhitespace < 0) { | ||
| 940 | return { | ||
| 941 | executable: trimmed, | ||
| 942 | rest: "", | ||
| 943 | }; | ||
| 944 | } | ||
| 945 | |||
| 946 | return { | ||
| 947 | executable: trimmed.slice(0, firstWhitespace), | ||
| 948 | rest: trimmed.slice(firstWhitespace).trim(), | ||
| 949 | }; | ||
| 950 | } | ||
| 951 | |||
| 952 | const SHELL_WRAPPER_SPECS = [ | ||
| 953 | { | ||
| 954 | executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"], | ||
| 955 | wrapperFlagPattern: /(?:^|\s)-command\s+/i, | ||
| 956 | }, | ||
| 957 | { | ||
| 958 | executables: ["cmd", "cmd.exe"], | ||
| 959 | wrapperFlagPattern: /(?:^|\s)\/c\s+/i, | ||
| 960 | }, | ||
| 961 | { | ||
| 962 | executables: ["bash", "sh", "zsh"], | ||
| 963 | wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i, | ||
| 964 | }, | ||
| 965 | ] as const; | ||
| 966 | |||
| 967 | function findShellWrapperSpec(shell: string) { | ||
| 968 | return SHELL_WRAPPER_SPECS.find((spec) => | ||
| 969 | (spec.executables as ReadonlyArray<string>).includes(shell), | ||
| 970 | ); | 316 | ); |
| 971 | } | 317 | } |
| 972 | 318 | ||
| 973 | function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { | 319 | const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"]["type"]>([ |
| 974 | const match = wrapperFlagPattern.exec(value); | 320 | "approval_request", |
| 975 | if (!match) { | 321 | "compaction", |
| 976 | return null; | 322 | "fork", |
| 977 | } | 323 | "handoff", |
| 978 | 324 | "run_interrupt_request", | |
| 979 | const command = value.slice(match.index + match[0].length).trim(); | 325 | "run_interrupt_result", |
| 980 | if (command.length === 0) { | 326 | "subagent", |
| 981 | return null; | 327 | "todo_list", |
| 982 | } | 328 | "user_input_request", |
| 983 | 329 | ]); | |
| 984 | const unwrapped = trimMatchingOuterQuotes(command); | 330 | |
| 985 | return unwrapped.length > 0 ? unwrapped : null; | 331 | function projectedItemCreatedAt(row: OrchestrationV2ProjectedTurnItem): string { |
| 986 | } | 332 | return DateTime.formatIso(row.item.startedAt ?? row.item.updatedAt); |
| 987 | 333 | } | |
| 988 | function unwrapKnownShellCommandWrapper(value: string): string { | 334 | |
| 989 | const split = splitExecutableAndRest(value); | 335 | function projectedWorkEntryStatus( |
| 990 | if (!split || split.rest.length === 0) { | 336 | item: OrchestrationV2TurnItem, |
| 991 | return value; | 337 | ): NonNullable<WorkLogEntry["toolLifecycleStatus"]> { |
| 992 | } | 338 | switch (item.status) { |
| 993 | 339 | case "pending": | |
| 994 | const shell = executableBasename(split.executable); | 340 | case "running": |
| 995 | if (!shell) { | 341 | case "waiting": |
| 996 | return value; | 342 | return "inProgress"; |
| 997 | } | 343 | case "completed": |
| 998 | 344 | return "completed"; | |
| 999 | const spec = findShellWrapperSpec(shell); | 345 | case "failed": |
| 1000 | if (!spec) { | 346 | return "failed"; |
| 1001 | return value; | 347 | case "cancelled": |
| 1002 | } | 348 | case "interrupted": |
| 1003 | 349 | return "stopped"; | |
| 1004 | return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; | 350 | } |
| 1005 | } | 351 | } |
| 1006 | 352 | ||
| 1007 | function formatCommandArrayPart(value: string): string { | 353 | function projectedWorkEntryTone(item: OrchestrationV2TurnItem): WorkLogEntry["tone"] { |
| 1008 | return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; | 354 | if (item.status === "failed") return "error"; |
| 1009 | } | 355 | if (item.type === "reasoning") return "thinking"; |
| 1010 | 356 | switch (item.type) { | |
| 1011 | function formatCommandValue(value: unknown): string | null { | 357 | case "command_execution": |
| 1012 | const direct = asTrimmedString(value); | 358 | case "file_change": |
| 1013 | if (direct) { | 359 | case "file_search": |
| 1014 | return direct; | 360 | case "web_search": |
| 1015 | } | 361 | case "dynamic_tool": |
| 1016 | if (!Array.isArray(value)) { | 362 | case "subagent": |
| 1017 | return null; | 363 | return "tool"; |
| 1018 | } | 364 | default: |
| 1019 | const parts: Array<string> = []; | 365 | return "info"; |
| 1020 | for (const entry of value) { | 366 | } |
| 1021 | const part = asTrimmedString(entry); | 367 | } |
| 1022 | if (part !== null) { | 368 | |
| 1023 | parts.push(part); | 369 | function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry { |
| 1024 | } | 370 | const { item } = row; |
| 1025 | } | 371 | const title = item.title?.trim() || null; |
| 1026 | if (parts.length === 0) { | 372 | const common = { |
| 1027 | return null; | 373 | id: item.id, |
| 1028 | } | 374 | createdAt: projectedItemCreatedAt(row), |
| 1029 | return parts.map((part) => formatCommandArrayPart(part)).join(" "); | 375 | runId: item.runId, |
| 1030 | } | 376 | tone: projectedWorkEntryTone(item), |
| 1031 | 377 | itemType: item.type, | |
| 1032 | function normalizeCommandValue(value: unknown): string | null { | 378 | toolLifecycleStatus: projectedWorkEntryStatus(item), |
| 1033 | const formatted = formatCommandValue(value); | 379 | structuredPayload: item, |
| 1034 | return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; | 380 | projectedItem: row, |
| 1035 | } | 381 | } as const; |
| 1036 | 382 | ||
| 1037 | function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null { | 383 | switch (item.type) { |
| 1038 | const formatted = formatCommandValue(value); | 384 | case "reasoning": |
| 1039 | if (!formatted || normalizedCommand === null) { | 385 | return { |
| 1040 | return null; | 386 | ...common, |
| 387 | label: title ?? "Thinking", | ||
| 388 | ...(item.text ? { detail: item.text } : {}), | ||
| 389 | }; | ||
| 390 | case "command_execution": | ||
| 391 | return { | ||
| 392 | ...common, | ||
| 393 | label: title ?? "Ran command", | ||
| 394 | command: item.input, | ||
| 395 | rawCommand: item.input, | ||
| 396 | ...(item.output ? { detail: item.output } : {}), | ||
| 397 | toolTitle: title ?? "Command", | ||
| 398 | toolData: item, | ||
| 399 | }; | ||
| 400 | case "file_change": | ||
| 401 | return { | ||
| 402 | ...common, | ||
| 403 | label: title ?? `Changed ${item.fileName}`, | ||
| 404 | changedFiles: [item.fileName], | ||
| 405 | ...(item.diffStr ? { detail: item.diffStr } : {}), | ||
| 406 | toolTitle: title ?? "File change", | ||
| 407 | toolData: item, | ||
| 408 | }; | ||
| 409 | case "file_search": | ||
| 410 | return { | ||
| 411 | ...common, | ||
| 412 | label: title ?? "Searched files", | ||
| 413 | ...(item.pattern ? { detail: item.pattern } : {}), | ||
| 414 | toolTitle: title ?? "File search", | ||
| 415 | toolData: item, | ||
| 416 | }; | ||
| 417 | case "web_search": | ||
| 418 | return { | ||
| 419 | ...common, | ||
| 420 | label: title ?? "Searched the web", | ||
| 421 | ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}), | ||
| 422 | toolTitle: title ?? "Web search", | ||
| 423 | toolData: item, | ||
| 424 | }; | ||
| 425 | case "checkpoint": | ||
| 426 | return { | ||
| 427 | ...common, | ||
| 428 | label: title ?? "Checkpoint captured", | ||
| 429 | changedFiles: item.files.map((file) => file.path), | ||
| 430 | toolData: item, | ||
| 431 | }; | ||
| 432 | case "dynamic_tool": | ||
| 433 | return { | ||
| 434 | ...common, | ||
| 435 | label: title ?? item.toolName ?? "Tool call", | ||
| 436 | toolTitle: title ?? item.toolName ?? "Tool", | ||
| 437 | toolData: { input: item.input, output: item.output }, | ||
| 438 | }; | ||
| 439 | default: | ||
| 440 | return { | ||
| 441 | ...common, | ||
| 442 | label: title ?? item.type.replaceAll("_", " "), | ||
| 443 | toolData: item, | ||
| 444 | }; | ||
| 1041 | } | 445 | } |
| 1042 | return formatted === normalizedCommand ? null : formatted; | ||
| 1043 | } | 446 | } |
| 1044 | 447 | ||
| 1045 | function extractToolCommand(payload: Record<string, unknown> | null): { | 448 | /** |
| 1046 | command: string | null; | 449 | * Builds the web timeline in the exact order committed by `visibleTurnItems`. |
| 1047 | rawCommand: string | null; | 450 | * Committed rows are presented directly from their projected item. Optimistic |
| 1048 | } { | 451 | * messages are the only client-owned entries appended to that sequence. |
| 1049 | const data = asRecord(payload?.data); | 452 | */ |
| 1050 | const item = asRecord(data?.item); | 453 | export function deriveTimelineEntriesFromVisibleTurnItems(input: { |
| 1051 | const itemResult = asRecord(item?.result); | 454 | readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>; |
| 1052 | const itemInput = asRecord(item?.input); | 455 | readonly optimisticMessages: ReadonlyArray<ChatMessage>; |
| 1053 | const itemType = asTrimmedString(payload?.itemType); | 456 | readonly attachmentUrlById?: ReadonlyMap<string, string>; |
| 1054 | const detail = asTrimmedString(payload?.detail); | 457 | }): TimelineEntry[] { |
| 1055 | const candidates: unknown[] = [ | 458 | const committedMessageIds = new Set<string>(); |
| 1056 | item?.command, | 459 | const entries: TimelineEntry[] = []; |
| 1057 | itemInput?.command, | 460 | |
| 1058 | itemResult?.command, | 461 | for (const row of input.visibleTurnItems) { |
| 1059 | data?.command, | 462 | const { item } = row; |
| 1060 | itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, | 463 | const createdAt = projectedItemCreatedAt(row); |
| 1061 | ]; | 464 | if (item.type === "user_message" || item.type === "assistant_message") { |
| 1062 | 465 | const message: ChatMessage = { | |
| 1063 | for (const candidate of candidates) { | 466 | id: item.messageId, |
| 1064 | const command = normalizeCommandValue(candidate); | 467 | role: item.type === "user_message" ? "user" : "assistant", |
| 1065 | if (!command) { | 468 | text: item.text, |
| 469 | ...(item.type === "user_message" && item.attachments.length > 0 | ||
| 470 | ? { | ||
| 471 | attachments: item.attachments.map((attachment) => { | ||
| 472 | const previewUrl = input.attachmentUrlById?.get(attachment.id); | ||
| 473 | return previewUrl ? { ...attachment, previewUrl } : attachment; | ||
| 474 | }), | ||
| 475 | } | ||
| 476 | : {}), | ||
| 477 | runId: item.runId, | ||
| 478 | streaming: item.type === "assistant_message" && item.streaming, | ||
| 479 | createdAt, | ||
| 480 | updatedAt: DateTime.formatIso(item.updatedAt), | ||
| 481 | }; | ||
| 482 | committedMessageIds.add(message.id); | ||
| 483 | entries.push({ id: message.id, kind: "message", createdAt, message }); | ||
| 1066 | continue; | 484 | continue; |
| 1067 | } | 485 | } |
| 1068 | return { | ||
| 1069 | command, | ||
| 1070 | rawCommand: toRawToolCommand(candidate, command), | ||
| 1071 | }; | ||
| 1072 | } | ||
| 1073 | 486 | ||
| 1074 | return { | 487 | if (item.type === "proposed_plan") { |
| 1075 | command: null, | 488 | const proposedPlan = { |
| 1076 | rawCommand: null, | 489 | id: item.planId, |
| 1077 | }; | 490 | runId: item.runId, |
| 1078 | } | 491 | planMarkdown: item.markdown, |
| 1079 | 492 | status: "active" as const, | |
| 1080 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { | 493 | implementedAt: null, |
| 1081 | return asTrimmedString(payload?.title); | 494 | implementationThreadId: null, |
| 1082 | } | 495 | createdAt, |
| 1083 | 496 | updatedAt: DateTime.formatIso(item.updatedAt), | |
| 1084 | function extractToolCallId(payload: Record<string, unknown> | null): string | null { | 497 | }; |
| 1085 | const data = asRecord(payload?.data); | 498 | entries.push({ id: item.id, kind: "proposed-plan", createdAt, proposedPlan }); |
| 1086 | return asTrimmedString(data?.toolCallId); | ||
| 1087 | } | ||
| 1088 | |||
| 1089 | function normalizeInlinePreview(value: string): string { | ||
| 1090 | return value.replace(/\s+/g, " ").trim(); | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | function truncateInlinePreview(value: string, maxLength = 84): string { | ||
| 1094 | if (value.length <= maxLength) { | ||
| 1095 | return value; | ||
| 1096 | } | ||
| 1097 | return `${value.slice(0, maxLength - 1).trimEnd()}…`; | ||
| 1098 | } | ||
| 1099 | |||
| 1100 | function normalizePreviewForComparison(value: string | null | undefined): string | null { | ||
| 1101 | const normalized = asTrimmedString(value); | ||
| 1102 | if (!normalized) { | ||
| 1103 | return null; | ||
| 1104 | } | ||
| 1105 | return normalizeCompactToolLabel(normalizeInlinePreview(normalized)).toLowerCase(); | ||
| 1106 | } | ||
| 1107 | |||
| 1108 | function summarizeToolTextOutput(value: string): string | null { | ||
| 1109 | const lines: Array<string> = []; | ||
| 1110 | for (const rawLine of value.split(/\r?\n/u)) { | ||
| 1111 | const line = normalizeInlinePreview(rawLine); | ||
| 1112 | if (line.length > 0) { | ||
| 1113 | lines.push(line); | ||
| 1114 | } | ||
| 1115 | } | ||
| 1116 | const firstLine = lines.find((line) => line !== "```"); | ||
| 1117 | if (firstLine) { | ||
| 1118 | return truncateInlinePreview(firstLine); | ||
| 1119 | } | ||
| 1120 | if (lines.length > 1) { | ||
| 1121 | return `${lines.length.toLocaleString()} lines`; | ||
| 1122 | } | ||
| 1123 | return null; | ||
| 1124 | } | ||
| 1125 | |||
| 1126 | function summarizeToolRawOutput(payload: Record<string, unknown> | null): string | null { | ||
| 1127 | const data = asRecord(payload?.data); | ||
| 1128 | const rawOutput = asRecord(data?.rawOutput); | ||
| 1129 | if (!rawOutput) { | ||
| 1130 | return null; | ||
| 1131 | } | ||
| 1132 | |||
| 1133 | const totalFiles = asNumber(rawOutput.totalFiles); | ||
| 1134 | if (totalFiles !== null) { | ||
| 1135 | const suffix = rawOutput.truncated === true ? "+" : ""; | ||
| 1136 | return `${totalFiles.toLocaleString()} file${totalFiles === 1 ? "" : "s"}${suffix}`; | ||
| 1137 | } | ||
| 1138 | |||
| 1139 | const content = asTrimmedString(rawOutput.content); | ||
| 1140 | if (content) { | ||
| 1141 | return summarizeToolTextOutput(content); | ||
| 1142 | } | ||
| 1143 | |||
| 1144 | const stdout = asTrimmedString(rawOutput.stdout); | ||
| 1145 | if (stdout) { | ||
| 1146 | return summarizeToolTextOutput(stdout); | ||
| 1147 | } | ||
| 1148 | |||
| 1149 | return null; | ||
| 1150 | } | ||
| 1151 | |||
| 1152 | function isCommandToolDetail(payload: Record<string, unknown> | null, heading: string): boolean { | ||
| 1153 | const data = asRecord(payload?.data); | ||
| 1154 | const kind = asTrimmedString(data?.kind)?.toLowerCase(); | ||
| 1155 | const title = asTrimmedString(payload?.title ?? heading)?.toLowerCase(); | ||
| 1156 | return ( | ||
| 1157 | extractWorkLogItemType(payload) === "command_execution" || | ||
| 1158 | kind === "execute" || | ||
| 1159 | title === "terminal" || | ||
| 1160 | title === "ran command" | ||
| 1161 | ); | ||
| 1162 | } | ||
| 1163 | |||
| 1164 | function extractToolDetail( | ||
| 1165 | payload: Record<string, unknown> | null, | ||
| 1166 | heading: string, | ||
| 1167 | ): string | null { | ||
| 1168 | const rawDetail = asTrimmedString(payload?.detail); | ||
| 1169 | const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null; | ||
| 1170 | const normalizedHeading = normalizePreviewForComparison(heading); | ||
| 1171 | const normalizedDetail = normalizePreviewForComparison(detail); | ||
| 1172 | |||
| 1173 | if (detail && normalizedHeading !== normalizedDetail) { | ||
| 1174 | return detail; | ||
| 1175 | } | ||
| 1176 | |||
| 1177 | if (isCommandToolDetail(payload, heading)) { | ||
| 1178 | return null; | ||
| 1179 | } | ||
| 1180 | |||
| 1181 | const rawOutputSummary = summarizeToolRawOutput(payload); | ||
| 1182 | if (rawOutputSummary) { | ||
| 1183 | const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary); | ||
| 1184 | if (normalizedRawOutputSummary !== normalizedHeading) { | ||
| 1185 | return rawOutputSummary; | ||
| 1186 | } | ||
| 1187 | } | ||
| 1188 | |||
| 1189 | return null; | ||
| 1190 | } | ||
| 1191 | |||
| 1192 | function stripTrailingExitCode(value: string): { | ||
| 1193 | output: string | null; | ||
| 1194 | exitCode?: number | undefined; | ||
| 1195 | } { | ||
| 1196 | const trimmed = value.trim(); | ||
| 1197 | const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec( | ||
| 1198 | trimmed, | ||
| 1199 | ); | ||
| 1200 | if (!match?.groups) { | ||
| 1201 | return { | ||
| 1202 | output: trimmed.length > 0 ? trimmed : null, | ||
| 1203 | }; | ||
| 1204 | } | ||
| 1205 | const exitCode = Number.parseInt(match.groups.code ?? "", 10); | ||
| 1206 | const normalizedOutput = match.groups.output?.trim() ?? ""; | ||
| 1207 | return { | ||
| 1208 | output: normalizedOutput.length > 0 ? normalizedOutput : null, | ||
| 1209 | ...(Number.isInteger(exitCode) ? { exitCode } : {}), | ||
| 1210 | }; | ||
| 1211 | } | ||
| 1212 | |||
| 1213 | function extractWorkLogItemType( | ||
| 1214 | payload: Record<string, unknown> | null, | ||
| 1215 | ): WorkLogEntry["itemType"] | undefined { | ||
| 1216 | if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { | ||
| 1217 | return payload.itemType; | ||
| 1218 | } | ||
| 1219 | return undefined; | ||
| 1220 | } | ||
| 1221 | |||
| 1222 | function extractWorkLogRequestKind( | ||
| 1223 | payload: Record<string, unknown> | null, | ||
| 1224 | ): WorkLogEntry["requestKind"] | undefined { | ||
| 1225 | if ( | ||
| 1226 | payload?.requestKind === "command" || | ||
| 1227 | payload?.requestKind === "file-read" || | ||
| 1228 | payload?.requestKind === "file-change" | ||
| 1229 | ) { | ||
| 1230 | return payload.requestKind; | ||
| 1231 | } | ||
| 1232 | return requestKindFromRequestType(payload?.requestType) ?? undefined; | ||
| 1233 | } | ||
| 1234 | |||
| 1235 | function pushChangedFile(target: string[], seen: Set<string>, value: unknown) { | ||
| 1236 | const normalized = asTrimmedString(value); | ||
| 1237 | if (!normalized || seen.has(normalized)) { | ||
| 1238 | return; | ||
| 1239 | } | ||
| 1240 | seen.add(normalized); | ||
| 1241 | target.push(normalized); | ||
| 1242 | } | ||
| 1243 | |||
| 1244 | function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) { | ||
| 1245 | if (depth > 4 || target.length >= 12) { | ||
| 1246 | return; | ||
| 1247 | } | ||
| 1248 | if (Array.isArray(value)) { | ||
| 1249 | for (const entry of value) { | ||
| 1250 | collectChangedFiles(entry, target, seen, depth + 1); | ||
| 1251 | if (target.length >= 12) { | ||
| 1252 | return; | ||
| 1253 | } | ||
| 1254 | } | ||
| 1255 | return; | ||
| 1256 | } | ||
| 1257 | |||
| 1258 | const record = asRecord(value); | ||
| 1259 | if (!record) { | ||
| 1260 | return; | ||
| 1261 | } | ||
| 1262 | |||
| 1263 | pushChangedFile(target, seen, record.path); | ||
| 1264 | pushChangedFile(target, seen, record.filePath); | ||
| 1265 | pushChangedFile(target, seen, record.relativePath); | ||
| 1266 | pushChangedFile(target, seen, record.filename); | ||
| 1267 | pushChangedFile(target, seen, record.newPath); | ||
| 1268 | pushChangedFile(target, seen, record.oldPath); | ||
| 1269 | |||
| 1270 | for (const nestedKey of [ | ||
| 1271 | "item", | ||
| 1272 | "result", | ||
| 1273 | "input", | ||
| 1274 | "data", | ||
| 1275 | "changes", | ||
| 1276 | "files", | ||
| 1277 | "edits", | ||
| 1278 | "patch", | ||
| 1279 | "patches", | ||
| 1280 | "operations", | ||
| 1281 | ]) { | ||
| 1282 | if (!(nestedKey in record)) { | ||
| 1283 | continue; | 499 | continue; |
| 1284 | } | 500 | } |
| 1285 | collectChangedFiles(record[nestedKey], target, seen, depth + 1); | ||
| 1286 | if (target.length >= 12) { | ||
| 1287 | return; | ||
| 1288 | } | ||
| 1289 | } | ||
| 1290 | } | ||
| 1291 | |||
| 1292 | function extractChangedFiles(payload: Record<string, unknown> | null): string[] { | ||
| 1293 | const changedFiles: string[] = []; | ||
| 1294 | const seen = new Set<string>(); | ||
| 1295 | collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); | ||
| 1296 | return changedFiles; | ||
| 1297 | } | ||
| 1298 | 501 | ||
| 1299 | function compareActivitiesByOrder( | 502 | if (STANDALONE_V2_ITEM_TYPES.has(item.type)) { |
| 1300 | left: OrchestrationThreadActivity, | 503 | entries.push({ id: item.id, kind: "event", createdAt, projectedItem: row }); |
| 1301 | right: OrchestrationThreadActivity, | 504 | continue; |
| 1302 | ): number { | ||
| 1303 | if (left.sequence !== undefined && right.sequence !== undefined) { | ||
| 1304 | if (left.sequence !== right.sequence) { | ||
| 1305 | return left.sequence - right.sequence; | ||
| 1306 | } | 505 | } |
| 1307 | } else if (left.sequence !== undefined) { | ||
| 1308 | return 1; | ||
| 1309 | } else if (right.sequence !== undefined) { | ||
| 1310 | return -1; | ||
| 1311 | } | ||
| 1312 | 506 | ||
| 1313 | const createdAtComparison = left.createdAt.localeCompare(right.createdAt); | 507 | entries.push({ id: item.id, kind: "work", createdAt, entry: projectedWorkEntry(row) }); |
| 1314 | if (createdAtComparison !== 0) { | ||
| 1315 | return createdAtComparison; | ||
| 1316 | } | 508 | } |
| 1317 | 509 | ||
| 1318 | const lifecycleRankComparison = | 510 | for (const message of input.optimisticMessages) { |
| 1319 | compareActivityLifecycleRank(left.kind) - compareActivityLifecycleRank(right.kind); | 511 | if (!committedMessageIds.has(message.id)) { |
| 1320 | if (lifecycleRankComparison !== 0) { | 512 | entries.push({ |
| 1321 | return lifecycleRankComparison; | 513 | id: message.id, |
| 514 | kind: "message", | ||
| 515 | createdAt: message.createdAt, | ||
| 516 | message, | ||
| 517 | }); | ||
| 518 | } | ||
| 1322 | } | 519 | } |
| 1323 | 520 | ||
| 1324 | return left.id.localeCompare(right.id); | 521 | return entries; |
| 1325 | } | 522 | } |
| 1326 | 523 | ||
| 1327 | function compareActivityLifecycleRank(kind: string): number { | 524 | export function inferCheckpointTurnCountByRunId( |
| 1328 | if (kind.endsWith(".started") || kind === "tool.started") { | 525 | summaries: ReadonlyArray<ThreadCheckpointSummary>, |
| 1329 | return 0; | 526 | ): Record<string, number> { |
| 1330 | } | 527 | return Object.fromEntries( |
| 1331 | if (kind.endsWith(".progress") || kind.endsWith(".updated")) { | 528 | summaries.flatMap((summary) => |
| 1332 | return 1; | 529 | summary.runId === null ? [] : [[summary.runId, summary.checkpointTurnCount] as const], |
| 1333 | } | 530 | ), |
| 1334 | if (kind.endsWith(".completed") || kind.endsWith(".resolved")) { | ||
| 1335 | return 2; | ||
| 1336 | } | ||
| 1337 | return 1; | ||
| 1338 | } | ||
| 1339 | |||
| 1340 | export function deriveTimelineEntries( | ||
| 1341 | messages: ReadonlyArray<ChatMessage>, | ||
| 1342 | proposedPlans: ReadonlyArray<ProposedPlan>, | ||
| 1343 | workEntries: ReadonlyArray<WorkLogEntry>, | ||
| 1344 | ): TimelineEntry[] { | ||
| 1345 | const messageRows: TimelineEntry[] = messages.map((message) => ({ | ||
| 1346 | id: message.id, | ||
| 1347 | kind: "message", | ||
| 1348 | createdAt: message.createdAt, | ||
| 1349 | message, | ||
| 1350 | })); | ||
| 1351 | const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ | ||
| 1352 | id: proposedPlan.id, | ||
| 1353 | kind: "proposed-plan", | ||
| 1354 | createdAt: proposedPlan.createdAt, | ||
| 1355 | proposedPlan, | ||
| 1356 | })); | ||
| 1357 | const workRows: TimelineEntry[] = workEntries.map((entry) => ({ | ||
| 1358 | id: entry.id, | ||
| 1359 | kind: "work", | ||
| 1360 | createdAt: entry.createdAt, | ||
| 1361 | entry, | ||
| 1362 | })); | ||
| 1363 | return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => | ||
| 1364 | a.createdAt.localeCompare(b.createdAt), | ||
| 1365 | ); | 531 | ); |
| 1366 | } | 532 | } |
| 1367 | 533 | ||
| 1368 | export function inferCheckpointTurnCountByTurnId( | 534 | export function derivePhase(runtime: ThreadRuntimeSummary | null): SessionPhase { |
| 1369 | summaries: ReadonlyArray<TurnDiffSummary>, | 535 | if (runtime === null) return "disconnected"; |
| 1370 | ): Record<TurnId, number> { | 536 | if (runtime.status === "starting" || runtime.status === "queued") return "connecting"; |
| 1371 | const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); | 537 | if (runtime.status === "running" || runtime.status === "waiting") return "running"; |
| 1372 | const result: Record<TurnId, number> = {}; | ||
| 1373 | for (let index = 0; index < sorted.length; index += 1) { | ||
| 1374 | const summary = sorted[index]; | ||
| 1375 | if (!summary) continue; | ||
| 1376 | result[summary.turnId] = index + 1; | ||
| 1377 | } | ||
| 1378 | return result; | ||
| 1379 | } | ||
| 1380 | |||
| 1381 | export function derivePhase(session: ThreadSession | null): SessionPhase { | ||
| 1382 | if ( | ||
| 1383 | !session || | ||
| 1384 | session.status === "stopped" || | ||
| 1385 | session.status === "interrupted" || | ||
| 1386 | session.status === "error" | ||
| 1387 | ) { | ||
| 1388 | return "disconnected"; | ||
| 1389 | } | ||
| 1390 | if (session.status === "starting") return "connecting"; | ||
| 1391 | if (session.status === "running") return "running"; | ||
| 1392 | return "ready"; | 538 | return "ready"; |
| 1393 | } | 539 | } |
| 540 | |||
| 541 | export type { TurnDiffSummary }; | ||
| @@ -3,21 +3,14 @@ import type { · 2 unmodified lines | |||
| 3 | EnvironmentProject, | 3 | EnvironmentProject, |
| 4 | EnvironmentThread, | 4 | EnvironmentThread, |
| 5 | EnvironmentThreadShell, | 5 | EnvironmentThreadShell, |
| 6 | ThreadConversationMessage, | ||
| 7 | ThreadProposedPlan, | ||
| 8 | ThreadRuntimeSummary, | ||
| 9 | ThreadWorkEntry, | ||
| 6 | } from "@t3tools/client-runtime/state/shell"; | 10 | } from "@t3tools/client-runtime/state/shell"; |
| 7 | import { | 11 | import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; |
| 8 | type EnvironmentThreadStatus, | 12 | import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; |
| 9 | mergeEnvironmentThread, | 13 | import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts"; |
| 10 | } from "@t3tools/client-runtime/state/threads"; | ||
| 11 | import type { | ||
| 12 | OrchestrationMessage, | ||
| 13 | OrchestrationProposedPlan, | ||
| 14 | OrchestrationSession, | ||
| 15 | OrchestrationThreadActivity, | ||
| 16 | ScopedProjectRef, | ||
| 17 | ScopedThreadRef, | ||
| 18 | ServerConfig, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 21 | import { Atom } from "effect/unstable/reactivity"; | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 22 | import { useMemo } from "react"; | 15 | import { useMemo } from "react"; |
| 23 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| @@ -28,9 +21,10 @@ import { environmentThreadDetails, environmentThreadShells } from "./threads"; · 4 unmodified lines | |||
| 28 | 21 | ||
| 29 | const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]); | 22 | const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]); |
| 30 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); | 23 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); |
| 31 | const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]); | 24 | const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]); |
| 32 | const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]); | 25 | const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]); |
| 33 | const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]); | 26 | const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = Object.freeze([]); |
| 27 | const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]); | ||
| 34 | 28 | ||
| 35 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( | 29 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( |
| 36 | Atom.withLabel("web-project:empty"), | 30 | Atom.withLabel("web-project:empty"), |
| @@ -53,14 +47,17 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pip · 16 unmodified lines | |||
| 53 | const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( | 47 | const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( |
| 54 | Atom.withLabel("web-thread-messages:empty"), | 48 | Atom.withLabel("web-thread-messages:empty"), |
| 55 | ); | 49 | ); |
| 56 | const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( | 50 | const EMPTY_WORK_ENTRIES_ATOM = Atom.make(EMPTY_WORK_ENTRIES).pipe( |
| 57 | Atom.withLabel("web-thread-activities:empty"), | 51 | Atom.withLabel("web-thread-work-entries:empty"), |
| 58 | ); | 52 | ); |
| 59 | const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( | 53 | const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( |
| 60 | Atom.withLabel("web-thread-proposed-plans:empty"), | 54 | Atom.withLabel("web-thread-proposed-plans:empty"), |
| 61 | ); | 55 | ); |
| 62 | const EMPTY_SESSION_ATOM = Atom.make<OrchestrationSession | null>(null).pipe( | 56 | const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe( |
| 63 | Atom.withLabel("web-thread-session:empty"), | 57 | Atom.withLabel("web-thread-visible-turn-items:empty"), |
| 58 | ); | ||
| 59 | const EMPTY_RUNTIME_ATOM = Atom.make<ThreadRuntimeSummary | null>(null).pipe( | ||
| 60 | Atom.withLabel("web-thread-runtime:empty"), | ||
| 64 | ); | 61 | ); |
| 65 | 62 | ||
| 66 | export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe( | 63 | export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe( |
| @@ -186,31 +183,39 @@ export function useThread( · 119 unmodified lines | |||
| 186 | 183 | ||
| 187 | export function useThreadMessages( | 184 | export function useThreadMessages( |
| 188 | ref: ScopedThreadRef | null, | 185 | ref: ScopedThreadRef | null, |
| 189 | ): ReadonlyArray<OrchestrationMessage> { | 186 | ): ReadonlyArray<ThreadConversationMessage> { |
| 190 | return useAtomValue( | 187 | return useAtomValue( |
| 191 | ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), | 188 | ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), |
| 192 | ); | 189 | ); |
| 193 | } | 190 | } |
| 194 | 191 | ||
| 195 | export function useThreadActivities( | 192 | export function useThreadWorkEntries(ref: ScopedThreadRef | null): ReadonlyArray<ThreadWorkEntry> { |
| 196 | ref: ScopedThreadRef | null, | ||
| 197 | ): ReadonlyArray<OrchestrationThreadActivity> { | ||
| 198 | return useAtomValue( | 193 | return useAtomValue( |
| 199 | ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref), | 194 | ref === null ? EMPTY_WORK_ENTRIES_ATOM : environmentThreadDetails.workEntriesAtom(ref), |
| 200 | ); | 195 | ); |
| 201 | } | 196 | } |
| 202 | 197 | ||
| 203 | export function useThreadProposedPlans( | 198 | export function useThreadProposedPlans( |
| 204 | ref: ScopedThreadRef | null, | 199 | ref: ScopedThreadRef | null, |
| 205 | ): ReadonlyArray<OrchestrationProposedPlan> { | 200 | ): ReadonlyArray<ThreadProposedPlan> { |
| 206 | return useAtomValue( | 201 | return useAtomValue( |
| 207 | ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), | 202 | ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), |
| 208 | ); | 203 | ); |
| 209 | } | 204 | } |
| 210 | 205 | ||
| 211 | export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null { | 206 | export function useThreadVisibleTurnItems( |
| 207 | ref: ScopedThreadRef | null, | ||
| 208 | ): ReadonlyArray<OrchestrationV2ProjectedTurnItem> { | ||
| 209 | return useAtomValue( | ||
| 210 | ref === null | ||
| 211 | ? EMPTY_VISIBLE_TURN_ITEMS_ATOM | ||
| 212 | : environmentThreadDetails.visibleTurnItemsAtom(ref), | ||
| 213 | ); | ||
| 214 | } | ||
| 215 | |||
| 216 | export function useThreadRuntime(ref: ScopedThreadRef | null): ThreadRuntimeSummary | null { | ||
| 212 | return useAtomValue( | 217 | return useAtomValue( |
| 213 | ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref), | 218 | ref === null ? EMPTY_RUNTIME_ATOM : environmentThreadDetails.runtimeAtom(ref), |
| 214 | ); | 219 | ); |
| 215 | } | 220 | } |
| 216 | 221 | ||
| @@ -9,15 +9,8 @@ import { · 8 unmodified lines | |||
| 9 | type EnvironmentThreadSearchMatch, | 9 | type EnvironmentThreadSearchMatch, |
| 10 | } from "@t3tools/client-runtime/state/thread-search"; | 10 | } from "@t3tools/client-runtime/state/thread-search"; |
| 11 | import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; | 11 | import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 12 | import type { | 12 | import type { EnvironmentId, ThreadId, VcsListRefsResult, VcsRef } from "@t3tools/contracts"; |
| 13 | EnvironmentId, | 13 | import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; |
| 14 | OrchestrationThread, | ||
| 15 | ProjectContentMatch, | ||
| 16 | ProjectEntryKind, | ||
| 17 | ThreadId, | ||
| 18 | VcsListRefsResult, | ||
| 19 | VcsRef, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import * as Cause from "effect/Cause"; | 14 | import * as Cause from "effect/Cause"; |
| 22 | import * as Option from "effect/Option"; | 15 | import * as Option from "effect/Option"; |
| 23 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 16 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| @@ -56,7 +49,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 32 unmodified lines | |||
| 56 | }); | 49 | }); |
| 57 | 50 | ||
| 58 | export interface ThreadDetailView { | 51 | export interface ThreadDetailView { |
| 59 | readonly data: OrchestrationThread | null; | 52 | readonly data: EnvironmentThread | null; |
| 60 | readonly error: string | null; | 53 | readonly error: string | null; |
| 61 | readonly isPending: boolean; | 54 | readonly isPending: boolean; |
| 62 | readonly isDeleted: boolean; | 55 | readonly isDeleted: boolean; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | type EnvironmentThreadState, | 7 | type EnvironmentThreadState, |
| 8 | createThreadEnvironmentAtoms, | 8 | createThreadEnvironmentAtoms, |
| 9 | } from "@t3tools/client-runtime/state/threads"; | 9 | } from "@t3tools/client-runtime/state/threads"; |
| 10 | import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell"; | ||
| 10 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 11 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 11 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 13 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| @@ -32,14 +33,21 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ · 19 unmodified lines | |||
| 32 | export function useEnvironmentThread( | 33 | export function useEnvironmentThread( |
| 33 | environmentId: EnvironmentId | null, | 34 | environmentId: EnvironmentId | null, |
| 34 | threadId: ThreadId | null, | 35 | threadId: ThreadId | null, |
| 35 | ): EnvironmentThreadState { | 36 | ): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } { |
| 36 | const result = useAtomValue( | 37 | const result = useAtomValue( |
| 37 | environmentId !== null && threadId !== null | 38 | environmentId !== null && threadId !== null |
| 38 | ? environmentThreads.stateAtom(environmentId, threadId) | 39 | ? environmentThreads.stateAtom(environmentId, threadId) |
| 39 | : EMPTY_THREAD_STATE_ATOM, | 40 | : EMPTY_THREAD_STATE_ATOM, |
| 40 | ); | 41 | ); |
| 41 | return Option.getOrElse( | 42 | const state = Option.getOrElse( |
| 42 | AsyncResult.value(result), | 43 | AsyncResult.value(result), |
| 43 | () => EMPTY_ENVIRONMENT_THREAD_STATE, | 44 | () => EMPTY_ENVIRONMENT_THREAD_STATE, |
| 44 | ) as EnvironmentThreadState; | 45 | ) as EnvironmentThreadState; |
| 46 | return { | ||
| 47 | ...state, | ||
| 48 | data: | ||
| 49 | environmentId === null | ||
| 50 | ? Option.none() | ||
| 51 | : Option.map(state.data, (projection) => presentThread(environmentId, projection)), | ||
| 52 | }; | ||
| 45 | } | 53 | } |
| @@ -0,0 +1,89 @@ | |||
| 1 | import { presentThread } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ThreadId, | ||
| 7 | type OrchestrationV2ThreadProjection, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | |||
| 11 | import type { Thread } from "./types"; | ||
| 12 | |||
| 13 | const DEFAULT_TIMESTAMP = "2026-01-01T00:00:00.000Z"; | ||
| 14 | |||
| 15 | /** Creates a structurally complete V2 thread for UI unit tests. */ | ||
| 16 | export function makeThreadFixture(overrides: Partial<Thread> = {}): Thread { | ||
| 17 | const environmentId = overrides.environmentId ?? EnvironmentId.make("environment-test"); | ||
| 18 | const id = overrides.id ?? ThreadId.make("thread-test"); | ||
| 19 | const projectId = overrides.projectId ?? ProjectId.make("project-test"); | ||
| 20 | const providerInstanceId = | ||
| 21 | overrides.providerInstanceId ?? | ||
| 22 | overrides.modelSelection?.instanceId ?? | ||
| 23 | ProviderInstanceId.make("codex"); | ||
| 24 | const modelSelection = overrides.modelSelection ?? { | ||
| 25 | instanceId: providerInstanceId, | ||
| 26 | model: "gpt-5.4", | ||
| 27 | }; | ||
| 28 | // Presentation-level overrides are applied after projection. Keep the source | ||
| 29 | // projection valid so tests can deliberately exercise malformed persisted | ||
| 30 | // timestamps without failing inside Effect's DateTime constructor first. | ||
| 31 | const createdAt = DateTime.makeUnsafe(DEFAULT_TIMESTAMP); | ||
| 32 | const updatedAt = DateTime.makeUnsafe(DEFAULT_TIMESTAMP); | ||
| 33 | const archivedAt = | ||
| 34 | overrides.archivedAt === null || overrides.archivedAt === undefined | ||
| 35 | ? null | ||
| 36 | : DateTime.makeUnsafe(overrides.archivedAt); | ||
| 37 | const deletedAt = | ||
| 38 | overrides.deletedAt === null || overrides.deletedAt === undefined | ||
| 39 | ? null | ||
| 40 | : DateTime.makeUnsafe(overrides.deletedAt); | ||
| 41 | const projection: OrchestrationV2ThreadProjection = { | ||
| 42 | thread: { | ||
| 43 | id, | ||
| 44 | projectId, | ||
| 45 | title: overrides.title ?? "Thread", | ||
| 46 | providerInstanceId, | ||
| 47 | modelSelection, | ||
| 48 | runtimeMode: overrides.runtimeMode ?? "full-access", | ||
| 49 | interactionMode: overrides.interactionMode ?? "default", | ||
| 50 | branch: overrides.branch ?? null, | ||
| 51 | worktreePath: overrides.worktreePath ?? null, | ||
| 52 | activeProviderThreadId: overrides.activeProviderThreadId ?? null, | ||
| 53 | lineage: overrides.lineage ?? { | ||
| 54 | rootThreadId: id, | ||
| 55 | parentThreadId: null, | ||
| 56 | relationshipToParent: null, | ||
| 57 | }, | ||
| 58 | forkedFrom: overrides.forkedFrom ?? null, | ||
| 59 | createdBy: "user", | ||
| 60 | creationSource: "web", | ||
| 61 | createdAt, | ||
| 62 | updatedAt, | ||
| 63 | archivedAt, | ||
| 64 | deletedAt, | ||
| 65 | }, | ||
| 66 | runs: [], | ||
| 67 | attempts: [], | ||
| 68 | nodes: [], | ||
| 69 | subagents: [], | ||
| 70 | providerSessions: [], | ||
| 71 | providerThreads: [], | ||
| 72 | providerTurns: [], | ||
| 73 | runtimeRequests: [], | ||
| 74 | messages: [], | ||
| 75 | plans: [], | ||
| 76 | turnItems: [], | ||
| 77 | checkpointScopes: [], | ||
| 78 | checkpoints: [], | ||
| 79 | contextHandoffs: [], | ||
| 80 | contextTransfers: [], | ||
| 81 | visibleTurnItems: [], | ||
| 82 | updatedAt, | ||
| 83 | }; | ||
| 84 | |||
| 85 | return { | ||
| 86 | ...presentThread(environmentId, projection), | ||
| 87 | ...overrides, | ||
| 88 | }; | ||
| 89 | } | ||
| @@ -1,11 +1,5 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ChatImageAttachment as ContractChatImageAttachment, | 2 | ChatImageAttachment as ContractChatImageAttachment, |
| 3 | OrchestrationCheckpointFile, | ||
| 4 | OrchestrationCheckpointSummary, | ||
| 5 | OrchestrationLatestTurn, | ||
| 6 | OrchestrationMessage, | ||
| 7 | OrchestrationProposedPlan, | ||
| 8 | OrchestrationSession, | ||
| 9 | ProjectScript as ContractProjectScript, | 3 | ProjectScript as ContractProjectScript, |
| 10 | ProviderInteractionMode, | 4 | ProviderInteractionMode, |
| 11 | RuntimeMode, | 5 | RuntimeMode, |
| @@ -14,6 +8,11 @@ import type { · 2 unmodified lines | |||
| 14 | EnvironmentProject, | 8 | EnvironmentProject, |
| 15 | EnvironmentThread, | 9 | EnvironmentThread, |
| 16 | EnvironmentThreadShell, | 10 | EnvironmentThreadShell, |
| 11 | ThreadCheckpointSummary, | ||
| 12 | ThreadConversationMessage, | ||
| 13 | ThreadProposedPlan, | ||
| 14 | ThreadRunSummary, | ||
| 15 | ThreadRuntimeSummary, | ||
| 17 | } from "@t3tools/client-runtime/state/shell"; | 16 | } from "@t3tools/client-runtime/state/shell"; |
| 18 | 17 | ||
| 19 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 18 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| @@ -37,21 +36,21 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { · 17 unmodified lines | |||
| 37 | 36 | ||
| 38 | export type ChatAttachment = ChatImageAttachment; | 37 | export type ChatAttachment = ChatImageAttachment; |
| 39 | 38 | ||
| 40 | export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { | 39 | export interface ChatMessage extends Omit<ThreadConversationMessage, "attachments"> { |
| 41 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | 40 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 42 | } | 41 | } |
| 43 | 42 | ||
| 44 | export type ProposedPlan = OrchestrationProposedPlan; | 43 | export type ProposedPlan = ThreadProposedPlan; |
| 45 | export type TurnDiffFileChange = OrchestrationCheckpointFile; | 44 | export type TurnDiffFileChange = ThreadCheckpointSummary["files"][number]; |
| 46 | export type TurnDiffSummary = OrchestrationCheckpointSummary; | 45 | export type TurnDiffSummary = ThreadCheckpointSummary; |
| 47 | 46 | ||
| 48 | export type Project = EnvironmentProject; | 47 | export type Project = EnvironmentProject; |
| 49 | export type Thread = EnvironmentThread; | 48 | export type Thread = EnvironmentThread; |
| 50 | export type ThreadShell = EnvironmentThreadShell; | 49 | export type ThreadShell = EnvironmentThreadShell; |
| 51 | 50 | ||
| 52 | export interface ThreadTurnState { | 51 | export interface ThreadTurnState { |
| 53 | latestTurn: OrchestrationLatestTurn | null; | 52 | latestRun: ThreadRunSummary | null; |
| 54 | } | 53 | } |
| 55 | 54 | ||
| 56 | export type SidebarThreadSummary = EnvironmentThreadShell; | 55 | export type SidebarThreadSummary = EnvironmentThreadShell; |
| 57 | export type ThreadSession = OrchestrationSession; | 56 | export type ThreadSession = ThreadRuntimeSummary; |
| @@ -2,12 +2,13 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools · 1 unmodified line | |||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 5 | import { makeThreadFixture } from "./test-fixtures"; | ||
| 5 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; | 6 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; |
| 6 | 7 | ||
| 7 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 8 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 8 | 9 | ||
| 9 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 10 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 10 | return { | 11 | return makeThreadFixture({ |
| 11 | id: ThreadId.make("thread-1"), | 12 | id: ThreadId.make("thread-1"), |
| 12 | environmentId: localEnvironmentId, | 13 | environmentId: localEnvironmentId, |
| 13 | projectId: ProjectId.make("project-1"), | 14 | projectId: ProjectId.make("project-1"), |
| @@ -18,10 +19,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines | |||
| 18 | }, | 19 | }, |
| 19 | runtimeMode: DEFAULT_RUNTIME_MODE, | 20 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 20 | interactionMode: DEFAULT_INTERACTION_MODE, | 21 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 21 | session: null, | 22 | runtime: null, |
| 22 | messages: [], | 23 | messages: [], |
| 23 | checkpoints: [], | ||
| 24 | activities: [], | ||
| 25 | proposedPlans: [], | 24 | proposedPlans: [], |
| 26 | createdAt: "2026-02-13T00:00:00.000Z", | 25 | createdAt: "2026-02-13T00:00:00.000Z", |
| 27 | updatedAt: "2026-02-13T00:00:00.000Z", | 26 | updatedAt: "2026-02-13T00:00:00.000Z", |
| @@ -29,11 +28,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line | |||
| 29 | settledOverride: null, | 28 | settledOverride: null, |
| 30 | settledAt: null, | 29 | settledAt: null, |
| 31 | deletedAt: null, | 30 | deletedAt: null, |
| 32 | latestTurn: null, | 31 | latestRun: null, |
| 33 | branch: null, | 32 | branch: null, |
| 34 | worktreePath: null, | 33 | worktreePath: null, |
| 35 | ...overrides, | 34 | ...overrides, |
| 36 | }; | 35 | }); |
| 37 | } | 36 | } |
| 38 | 37 | ||
| 39 | describe("getOrphanedWorktreePathForThread", () => { | 38 | describe("getOrphanedWorktreePathForThread", () => { |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type DesktopSshEnvironmentTarget, | 2 | type DesktopSshEnvironmentTarget, |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | type OrchestrationShellSnapshot, | 4 | type OrchestrationV2ShellSnapshot, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as Deferred from "effect/Deferred"; | 7 | import * as Deferred from "effect/Deferred"; |
| @@ -46,6 +46,7 @@ import * as EnvironmentRegistry from "./registry.ts"; · 38 unmodified lines | |||
| 46 | import * as RpcSession from "../rpc/session.ts"; | 46 | import * as RpcSession from "../rpc/session.ts"; |
| 47 | import * as EnvironmentSupervisor from "./supervisor.ts"; | 47 | import * as EnvironmentSupervisor from "./supervisor.ts"; |
| 48 | import * as ConnectionWakeups from "./wakeups.ts"; | 48 | import * as ConnectionWakeups from "./wakeups.ts"; |
| 49 | import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts"; | ||
| 49 | 50 | ||
| 50 | const TARGET = new PrimaryConnectionTarget({ | 51 | const TARGET = new PrimaryConnectionTarget({ |
| 51 | environmentId: EnvironmentId.make("environment-1"), | 52 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -112,11 +113,9 @@ const SSH_PROFILE = new SshConnectionProfile({ · 60 unmodified lines | |||
| 112 | target: SSH_TARGET, | 113 | target: SSH_TARGET, |
| 113 | }); | 114 | }); |
| 114 | 115 | ||
| 115 | const CACHED_SNAPSHOT: OrchestrationShellSnapshot = { | 116 | const CACHED_SNAPSHOT: OrchestrationV2ShellSnapshot = { |
| 117 | ...v2ShellSnapshot, | ||
| 116 | snapshotSequence: 1, | 118 | snapshotSequence: 1, |
| 117 | projects: [], | ||
| 118 | threads: [], | ||
| 119 | updatedAt: "2026-06-06T00:00:00.000Z", | ||
| 120 | }; | 119 | }; |
| 121 | 120 | ||
| 122 | interface SessionControl { | 121 | interface SessionControl { |
| @@ -1,13 +1,22 @@ | |||
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | ORCHESTRATION_WS_METHODS, | 4 | MessageId, |
| 5 | ORCHESTRATION_V2_WS_METHODS, | ||
| 6 | PlanId, | ||
| 5 | ProjectId, | 7 | ProjectId, |
| 8 | ProviderInstanceId, | ||
| 9 | RunId, | ||
| 6 | ThreadId, | 10 | ThreadId, |
| 7 | type ClientOrchestrationCommand, | 11 | WS_METHODS, |
| 12 | type OrchestrationV2Command, | ||
| 13 | type OrchestrationV2ThreadLaunchInput, | ||
| 14 | type OrchestrationV2ThreadProjection, | ||
| 15 | type ProjectMutation, | ||
| 8 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 9 | import { describe, expect, it } from "@effect/vitest"; | 17 | import { describe, expect, it } from "@effect/vitest"; |
| 10 | import * as Crypto from "effect/Crypto"; | 18 | import * as Crypto from "effect/Crypto"; |
| 19 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 21 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| @@ -21,13 +30,8 @@ import { · 7 unmodified lines | |||
| 21 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 30 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 22 | import * as RpcSession from "../rpc/session.ts"; | 31 | import * as RpcSession from "../rpc/session.ts"; |
| 23 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 32 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 24 | import { | 33 | import { v2Projection, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts"; |
| 25 | archiveThread, | 34 | import { archiveThread, createProject, startThreadTurn } from "./commands.ts"; |
| 26 | createProject, | ||
| 27 | settleThread, | ||
| 28 | stopThreadSession, | ||
| 29 | unsettleThread, | ||
| 30 | } from "./commands.ts"; | ||
| 31 | 35 | ||
| 32 | const TEST_CRYPTO_LAYER = Layer.succeed( | 36 | const TEST_CRYPTO_LAYER = Layer.succeed( |
| 33 | Crypto.Crypto, | 37 | Crypto.Crypto, |
| @@ -44,14 +48,45 @@ const TARGET = new PrimaryConnectionTarget({ · 10 unmodified lines | |||
| 44 | wsBaseUrl: "wss://environment.example.test", | 48 | wsBaseUrl: "wss://environment.example.test", |
| 45 | }); | 49 | }); |
| 46 | 50 | ||
| 47 | const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* ( | 51 | const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (input: { |
| 48 | dispatched: ClientOrchestrationCommand[], | 52 | readonly commands: OrchestrationV2Command[]; |
| 49 | ) { | 53 | readonly projects: ProjectMutation[]; |
| 54 | readonly launches?: OrchestrationV2ThreadLaunchInput[]; | ||
| 55 | readonly projection?: OrchestrationV2ThreadProjection; | ||
| 56 | }) { | ||
| 50 | const client = { | 57 | const client = { |
| 51 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) => | 58 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => |
| 59 | Effect.sync(() => { | ||
| 60 | input.commands.push(command); | ||
| 61 | return { sequence: input.commands.length }; | ||
| 62 | }), | ||
| 63 | [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: () => | ||
| 64 | Effect.succeed(input.projection ?? v2Projection), | ||
| 65 | [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) => | ||
| 52 | Effect.sync(() => { | 66 | Effect.sync(() => { |
| 53 | dispatched.push(command); | 67 | input.launches?.push(launchInput); |
| 54 | return { sequence: dispatched.length }; | 68 | return { |
| 69 | threadId: launchInput.threadId ?? v2ThreadId, | ||
| 70 | projection: input.projection ?? v2Projection, | ||
| 71 | resumed: false, | ||
| 72 | }; | ||
| 73 | }), | ||
| 74 | [WS_METHODS.projectsMutate]: (mutation: ProjectMutation) => | ||
| 75 | Effect.sync(() => { | ||
| 76 | input.projects.push(mutation); | ||
| 77 | return { | ||
| 78 | id: mutation.projectId, | ||
| 79 | title: mutation.type === "project.create" ? mutation.title : "Project", | ||
| 80 | workspaceRoot: | ||
| 81 | mutation.type === "project.create" ? mutation.workspaceRoot : "/workspace/project", | ||
| 82 | repositoryIdentity: null, | ||
| 83 | faviconPath: null, | ||
| 84 | defaultModelSelection: null, | ||
| 85 | scripts: [], | ||
| 86 | createdAt: "2026-06-06T00:00:00.000Z", | ||
| 87 | updatedAt: "2026-06-06T00:00:00.000Z", | ||
| 88 | deletedAt: null, | ||
| 89 | }; | ||
| 55 | }), | 90 | }), |
| 56 | } as unknown as WsRpcProtocolClient; | 91 | } as unknown as WsRpcProtocolClient; |
| 57 | const session: RpcSession.RpcSession = { | 92 | const session: RpcSession.RpcSession = { |
| @@ -72,103 +107,222 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 14 unmodified lines | |||
| 72 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | 107 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); |
| 73 | }); | 108 | }); |
| 74 | 109 | ||
| 75 | describe("environment commands", () => { | 110 | describe("V2 environment commands", () => { |
| 76 | it.effect("adds generated command metadata", () => | 111 | it.effect("routes projects through the event-sourced project transport", () => |
| 77 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 78 | const dispatched: ClientOrchestrationCommand[] = []; | 113 | const projects: ProjectMutation[] = []; |
| 79 | const supervisor = yield* makeSupervisor(dispatched); | 114 | const supervisor = yield* makeSupervisor({ commands: [], projects }); |
| 80 | 115 | ||
| 81 | const result = yield* createProject({ | 116 | yield* createProject({ |
| 82 | projectId: ProjectId.make("project-1"), | 117 | projectId: ProjectId.make("project-1"), |
| 83 | title: "Project", | 118 | title: "Project", |
| 84 | workspaceRoot: "/workspace/project", | 119 | workspaceRoot: "/workspace/project", |
| 85 | createdAt: "2026-06-06T00:00:00.000Z", | 120 | createWorkspaceRootIfMissing: true, |
| 86 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | 121 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 87 | 122 | ||
| 88 | expect(result).toEqual({ sequence: 1 }); | 123 | expect(projects).toEqual([ |
| 89 | expect(dispatched).toEqual([ | ||
| 90 | { | 124 | { |
| 91 | type: "project.create", | 125 | type: "project.create", |
| 92 | commandId: "00000000-0000-4000-8000-000000000000", | 126 | commandId: "00000000-0000-4000-8000-000000000000", |
| 93 | projectId: "project-1", | 127 | projectId: "project-1", |
| 94 | title: "Project", | 128 | title: "Project", |
| 95 | workspaceRoot: "/workspace/project", | 129 | workspaceRoot: "/workspace/project", |
| 96 | createdAt: "2026-06-06T00:00:00.000Z", | 130 | createWorkspaceRootIfMissing: true, |
| 97 | }, | 131 | }, |
| 98 | ]); | 132 | ]); |
| 99 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | 133 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 100 | ); | 134 | ); |
| 101 | 135 | ||
| 102 | it.effect("preserves caller metadata for idempotent queued commands", () => | 136 | it.effect("preserves caller command ids for idempotent V2 commands", () => |
| 103 | Effect.gen(function* () { | 137 | Effect.gen(function* () { |
| 104 | const dispatched: ClientOrchestrationCommand[] = []; | 138 | const commands: OrchestrationV2Command[] = []; |
| 105 | const supervisor = yield* makeSupervisor(dispatched); | 139 | const supervisor = yield* makeSupervisor({ commands, projects: [] }); |
| 106 | 140 | ||
| 107 | yield* stopThreadSession({ | 141 | yield* archiveThread({ |
| 108 | commandId: CommandId.make("queued-command"), | 142 | commandId: CommandId.make("queued-command"), |
| 109 | threadId: ThreadId.make("thread-1"), | 143 | threadId: ThreadId.make("thread-1"), |
| 110 | createdAt: "2026-06-06T00:01:00.000Z", | ||
| 111 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | 144 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 112 | 145 | ||
| 113 | expect(dispatched).toEqual([ | 146 | expect(commands).toEqual([ |
| 114 | { | 147 | { type: "thread.archive", commandId: "queued-command", threadId: "thread-1" }, |
| 115 | type: "thread.session.stop", | ||
| 116 | commandId: "queued-command", | ||
| 117 | threadId: "thread-1", | ||
| 118 | createdAt: "2026-06-06T00:01:00.000Z", | ||
| 119 | }, | ||
| 120 | ]); | 148 | ]); |
| 121 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | 149 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 122 | ); | 150 | ); |
| 123 | 151 | ||
| 124 | it.effect("does not add timestamps to commands without createdAt", () => | 152 | it.effect("preserves plan implementation provenance on V2 runs", () => |
| 125 | Effect.gen(function* () { | 153 | Effect.gen(function* () { |
| 126 | const dispatched: ClientOrchestrationCommand[] = []; | 154 | const commands: OrchestrationV2Command[] = []; |
| 127 | const supervisor = yield* makeSupervisor(dispatched); | 155 | const supervisor = yield* makeSupervisor({ commands, projects: [] }); |
| 128 | 156 | ||
| 129 | yield* archiveThread({ | 157 | yield* startThreadTurn({ |
| 130 | commandId: CommandId.make("archive-command"), | 158 | commandId: CommandId.make("implement-plan"), |
| 131 | threadId: ThreadId.make("thread-1"), | 159 | threadId: v2ThreadId, |
| 160 | message: { | ||
| 161 | messageId: MessageId.make("message-implementation"), | ||
| 162 | role: "user", | ||
| 163 | text: "Implement the plan", | ||
| 164 | attachments: [], | ||
| 165 | }, | ||
| 166 | modelSelection: { | ||
| 167 | instanceId: ProviderInstanceId.make("codex"), | ||
| 168 | model: "gpt-5.4", | ||
| 169 | }, | ||
| 170 | runtimeMode: "full-access", | ||
| 171 | interactionMode: "default", | ||
| 172 | sourceProposedPlan: { | ||
| 173 | threadId: ThreadId.make("thread-plan"), | ||
| 174 | planId: PlanId.make("plan-1"), | ||
| 175 | }, | ||
| 132 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | 176 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 133 | 177 | ||
| 134 | expect(dispatched).toEqual([ | 178 | expect(commands).toHaveLength(1); |
| 135 | { | 179 | expect(commands[0]).toMatchObject({ |
| 136 | type: "thread.archive", | 180 | type: "message.dispatch", |
| 137 | commandId: "archive-command", | 181 | commandId: "implement-plan", |
| 138 | threadId: "thread-1", | 182 | threadId: v2ThreadId, |
| 139 | }, | 183 | sourcePlanRef: { threadId: "thread-plan", planId: "plan-1" }, |
| 140 | ]); | 184 | dispatchMode: { type: "start_immediately" }, |
| 185 | }); | ||
| 141 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | 186 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 142 | ); | 187 | ); |
| 143 | 188 | ||
| 144 | it.effect("dispatches settle and unsettle commands without timestamps", () => | 189 | it.effect("preserves an existing worktree and branch during first-message launch", () => |
| 145 | Effect.gen(function* () { | 190 | Effect.gen(function* () { |
| 146 | const dispatched: ClientOrchestrationCommand[] = []; | 191 | const launches: OrchestrationV2ThreadLaunchInput[] = []; |
| 147 | const supervisor = yield* makeSupervisor(dispatched); | 192 | const supervisor = yield* makeSupervisor({ commands: [], projects: [], launches }); |
| 148 | 193 | ||
| 149 | yield* settleThread({ | 194 | yield* startThreadTurn({ |
| 150 | commandId: CommandId.make("settle-command"), | 195 | commandId: CommandId.make("launch-existing-worktree"), |
| 151 | threadId: ThreadId.make("thread-1"), | 196 | threadId: v2ThreadId, |
| 152 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | 197 | message: { |
| 153 | yield* unsettleThread({ | 198 | messageId: MessageId.make("message-existing-worktree"), |
| 154 | commandId: CommandId.make("unsettle-command"), | 199 | role: "user", |
| 155 | threadId: ThreadId.make("thread-1"), | 200 | text: "Continue here", |
| 156 | reason: "user", | 201 | attachments: [], |
| 202 | }, | ||
| 203 | runtimeMode: "full-access", | ||
| 204 | interactionMode: "default", | ||
| 205 | bootstrap: { | ||
| 206 | createThread: { | ||
| 207 | projectId: ProjectId.make("project-1"), | ||
| 208 | title: "Thread", | ||
| 209 | modelSelection: v2Projection.thread.modelSelection, | ||
| 210 | runtimeMode: "full-access", | ||
| 211 | interactionMode: "default", | ||
| 212 | branch: "feature", | ||
| 213 | worktreePath: "/workspace/project-worktrees/feature", | ||
| 214 | createdAt: "2026-06-20T00:00:00.000Z", | ||
| 215 | }, | ||
| 216 | }, | ||
| 157 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | 217 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 158 | 218 | ||
| 159 | expect(dispatched).toEqual([ | 219 | expect(launches[0]).toMatchObject({ |
| 160 | { | 220 | threadId: v2ThreadId, |
| 161 | type: "thread.settle", | 221 | workspaceStrategy: { |
| 162 | commandId: "settle-command", | 222 | type: "existing_worktree", |
| 163 | threadId: "thread-1", | 223 | worktreePath: "/workspace/project-worktrees/feature", |
| 224 | branch: "feature", | ||
| 164 | }, | 225 | }, |
| 165 | { | 226 | }); |
| 166 | type: "thread.unsettle", | 227 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 167 | commandId: "unsettle-command", | 228 | ); |
| 168 | threadId: "thread-1", | 229 | |
| 169 | reason: "user", | 230 | it.effect("provisions an origin-based worktree for an existing empty thread", () => |
| 231 | Effect.gen(function* () { | ||
| 232 | const launches: OrchestrationV2ThreadLaunchInput[] = []; | ||
| 233 | const supervisor = yield* makeSupervisor({ commands: [], projects: [], launches }); | ||
| 234 | |||
| 235 | yield* startThreadTurn({ | ||
| 236 | commandId: CommandId.make("launch-existing-thread-worktree"), | ||
| 237 | threadId: v2ThreadId, | ||
| 238 | message: { | ||
| 239 | messageId: MessageId.make("message-existing-thread-worktree"), | ||
| 240 | role: "user", | ||
| 241 | text: "Move to a worktree", | ||
| 242 | attachments: [], | ||
| 170 | }, | 243 | }, |
| 171 | ]); | 244 | runtimeMode: "full-access", |
| 245 | interactionMode: "default", | ||
| 246 | bootstrap: { | ||
| 247 | prepareWorktree: { | ||
| 248 | projectCwd: "/workspace/project", | ||
| 249 | baseBranch: "main", | ||
| 250 | branch: "feature", | ||
| 251 | startFromOrigin: true, | ||
| 252 | }, | ||
| 253 | }, | ||
| 254 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | ||
| 255 | |||
| 256 | expect(launches[0]).toMatchObject({ | ||
| 257 | threadId: v2ThreadId, | ||
| 258 | reuseExistingThread: true, | ||
| 259 | projectId: v2Projection.thread.projectId, | ||
| 260 | workspaceStrategy: { | ||
| 261 | type: "worktree", | ||
| 262 | baseRef: "main", | ||
| 263 | branch: "feature", | ||
| 264 | startFromOrigin: true, | ||
| 265 | }, | ||
| 266 | }); | ||
| 267 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | ||
| 268 | ); | ||
| 269 | |||
| 270 | it.effect("maps explicit active-run delivery modes to V2 dispatch semantics", () => | ||
| 271 | Effect.gen(function* () { | ||
| 272 | const activeRunId = RunId.make("run-active"); | ||
| 273 | const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"); | ||
| 274 | const projection: OrchestrationV2ThreadProjection = { | ||
| 275 | ...v2Projection, | ||
| 276 | runs: [ | ||
| 277 | { | ||
| 278 | id: activeRunId, | ||
| 279 | threadId: v2ThreadId, | ||
| 280 | ordinal: 1, | ||
| 281 | providerInstanceId: v2Projection.thread.providerInstanceId, | ||
| 282 | modelSelection: v2Projection.thread.modelSelection, | ||
| 283 | providerThreadId: null, | ||
| 284 | userMessageId: MessageId.make("message-active"), | ||
| 285 | rootNodeId: null, | ||
| 286 | activeAttemptId: null, | ||
| 287 | status: "running", | ||
| 288 | requestedAt: now, | ||
| 289 | startedAt: now, | ||
| 290 | completedAt: null, | ||
| 291 | checkpointId: null, | ||
| 292 | contextHandoffId: null, | ||
| 293 | }, | ||
| 294 | ], | ||
| 295 | }; | ||
| 296 | const commands: OrchestrationV2Command[] = []; | ||
| 297 | const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); | ||
| 298 | |||
| 299 | for (const [mode, expectedType] of [ | ||
| 300 | ["queue", "queue_after_active"], | ||
| 301 | ["steer", "steer_active"], | ||
| 302 | ["restart", "restart_active"], | ||
| 303 | ] as const) { | ||
| 304 | yield* startThreadTurn({ | ||
| 305 | commandId: CommandId.make(`command-${mode}`), | ||
| 306 | threadId: v2ThreadId, | ||
| 307 | message: { | ||
| 308 | messageId: MessageId.make(`message-${mode}`), | ||
| 309 | role: "user", | ||
| 310 | text: mode, | ||
| 311 | attachments: [], | ||
| 312 | }, | ||
| 313 | runtimeMode: "full-access", | ||
| 314 | interactionMode: "default", | ||
| 315 | dispatchMode: mode, | ||
| 316 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | ||
| 317 | |||
| 318 | expect(commands.at(-1)).toMatchObject({ | ||
| 319 | type: "message.dispatch", | ||
| 320 | dispatchMode: { | ||
| 321 | type: expectedType, | ||
| 322 | ...(mode === "queue" ? {} : { targetRunId: activeRunId }), | ||
| 323 | }, | ||
| 324 | }); | ||
| 325 | } | ||
| 172 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | 326 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 173 | ); | 327 | ); |
| 174 | }); | 328 | }); |
| @@ -1,300 +1,548 @@ | |||
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | ORCHESTRATION_WS_METHODS, | 3 | ORCHESTRATION_V2_WS_METHODS, |
| 4 | type ClientOrchestrationCommand, | 4 | WS_METHODS, |
| 5 | type ChatAttachment, | ||
| 6 | type MessageId, | ||
| 7 | type ModelSelection, | ||
| 8 | type OrchestrationV2Command, | ||
| 9 | type OrchestrationV2CreationSource, | ||
| 10 | type PlanId, | ||
| 11 | type ProjectId, | ||
| 12 | type ProjectScript, | ||
| 13 | type ProviderApprovalDecision, | ||
| 14 | type ProviderInteractionMode, | ||
| 15 | type ProviderUserInputAnswers, | ||
| 16 | type RunId, | ||
| 17 | type RuntimeMode, | ||
| 18 | type RuntimeRequestId, | ||
| 19 | type ThreadId, | ||
| 20 | type UploadChatAttachment, | ||
| 5 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 6 | import * as Crypto from "effect/Crypto"; | 22 | import * as Crypto from "effect/Crypto"; |
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as Effect from "effect/Effect"; | 23 | import * as Effect from "effect/Effect"; |
| 9 | 24 | ||
| 10 | import type { EnvironmentSupervisor } from "../connection/supervisor.ts"; | 25 | import { request } from "../rpc/client.ts"; |
| 11 | import { | ||
| 12 | type EnvironmentRpcFailure, | ||
| 13 | type EnvironmentRpcSuccess, | ||
| 14 | type EnvironmentRpcUnavailableError, | ||
| 15 | request, | ||
| 16 | } from "../rpc/client.ts"; | ||
| 17 | |||
| 18 | type CommandType = ClientOrchestrationCommand["type"]; | ||
| 19 | type CommandOf<T extends CommandType> = Extract<ClientOrchestrationCommand, { readonly type: T }>; | ||
| 20 | type CommandInput<T extends CommandType> = Omit< | ||
| 21 | CommandOf<T>, | ||
| 22 | "type" | "commandId" | "createdAt" | ||
| 23 | > & { | ||
| 24 | readonly commandId?: CommandId; | ||
| 25 | } & ("createdAt" extends keyof CommandOf<T> | ||
| 26 | ? { | ||
| 27 | readonly createdAt?: CommandOf<T>["createdAt"]; | ||
| 28 | } | ||
| 29 | : {}); | ||
| 30 | |||
| 31 | export type CreateProjectInput = CommandInput<"project.create">; | ||
| 32 | export type UpdateProjectInput = CommandInput<"project.meta.update">; | ||
| 33 | export type DeleteProjectInput = CommandInput<"project.delete">; | ||
| 34 | export type CreateThreadInput = CommandInput<"thread.create">; | ||
| 35 | export type DeleteThreadInput = CommandInput<"thread.delete">; | ||
| 36 | export type ArchiveThreadInput = CommandInput<"thread.archive">; | ||
| 37 | export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; | ||
| 38 | export type SettleThreadInput = CommandInput<"thread.settle">; | ||
| 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; | ||
| 40 | export type SnoozeThreadInput = CommandInput<"thread.snooze">; | ||
| 41 | export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; | ||
| 42 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; | ||
| 43 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; | ||
| 44 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; | ||
| 45 | export type StartThreadTurnInput = CommandInput<"thread.turn.start">; | ||
| 46 | export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; | ||
| 47 | export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; | ||
| 48 | export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; | ||
| 49 | export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; | ||
| 50 | export type StopThreadSessionInput = CommandInput<"thread.session.stop">; | ||
| 51 | |||
| 52 | type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; | ||
| 53 | type CommandEffect = Effect.Effect< | ||
| 54 | EnvironmentRpcSuccess<DispatchTag>, | ||
| 55 | EnvironmentRpcFailure<DispatchTag> | EnvironmentRpcUnavailableError, | ||
| 56 | Crypto.Crypto | EnvironmentSupervisor | ||
| 57 | >; | ||
| 58 | |||
| 59 | function commandId(input: { readonly commandId?: CommandId }) { | ||
| 60 | return Effect.gen(function* () { | ||
| 61 | if (input.commandId !== undefined) { | ||
| 62 | return input.commandId; | ||
| 63 | } | ||
| 64 | const crypto = yield* Crypto.Crypto; | ||
| 65 | return yield* crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(CommandId.make)); | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | 26 | ||
| 69 | function timestampedCommandMetadata(input: { | 27 | interface CommandMetadata { |
| 70 | readonly commandId?: CommandId; | 28 | readonly commandId?: CommandId; |
| 71 | readonly createdAt?: string; | 29 | readonly createdAt?: string; |
| 72 | }) { | 30 | readonly creationSource?: OrchestrationV2CreationSource; |
| 73 | return Effect.all({ | ||
| 74 | commandId: commandId(input), | ||
| 75 | createdAt: | ||
| 76 | input.createdAt === undefined | ||
| 77 | ? DateTime.now.pipe(Effect.map(DateTime.formatIso)) | ||
| 78 | : Effect.succeed(input.createdAt), | ||
| 79 | }); | ||
| 80 | } | 31 | } |
| 81 | 32 | ||
| 82 | function dispatch(command: ClientOrchestrationCommand) { | 33 | export interface CreateProjectInput extends CommandMetadata { |
| 83 | return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command); | 34 | readonly projectId: ProjectId; |
| 35 | readonly title: string; | ||
| 36 | readonly workspaceRoot: string; | ||
| 37 | readonly createWorkspaceRootIfMissing?: boolean; | ||
| 38 | readonly defaultModelSelection?: ModelSelection | null; | ||
| 39 | readonly scripts?: ReadonlyArray<ProjectScript>; | ||
| 84 | } | 40 | } |
| 85 | 41 | ||
| 86 | export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn( | 42 | export interface UpdateProjectInput extends CommandMetadata { |
| 87 | "EnvironmentCommands.createProject", | 43 | readonly projectId: ProjectId; |
| 88 | )(function* (input) { | 44 | readonly title?: string; |
| 89 | const metadata = yield* timestampedCommandMetadata(input); | 45 | readonly workspaceRoot?: string; |
| 90 | return yield* dispatch({ | 46 | readonly defaultModelSelection?: ModelSelection | null; |
| 91 | ...input, | 47 | readonly scripts?: ReadonlyArray<ProjectScript>; |
| 92 | type: "project.create", | 48 | } |
| 93 | commandId: metadata.commandId, | ||
| 94 | createdAt: metadata.createdAt, | ||
| 95 | }); | ||
| 96 | }); | ||
| 97 | 49 | ||
| 98 | export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn( | 50 | export interface DeleteProjectInput extends CommandMetadata { |
| 99 | "EnvironmentCommands.updateProject", | 51 | readonly projectId: ProjectId; |
| 100 | )(function* (input) { | 52 | readonly force?: boolean; |
| 101 | return yield* dispatch({ | 53 | } |
| 102 | ...input, | ||
| 103 | type: "project.meta.update", | ||
| 104 | commandId: yield* commandId(input), | ||
| 105 | }); | ||
| 106 | }); | ||
| 107 | 54 | ||
| 108 | export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn( | 55 | export interface CreateThreadInput extends CommandMetadata { |
| 109 | "EnvironmentCommands.deleteProject", | 56 | readonly threadId: ThreadId; |
| 110 | )(function* (input) { | 57 | readonly projectId: ProjectId; |
| 111 | return yield* dispatch({ | 58 | readonly title: string; |
| 112 | ...input, | 59 | readonly modelSelection: ModelSelection; |
| 113 | type: "project.delete", | 60 | readonly runtimeMode: RuntimeMode; |
| 114 | commandId: yield* commandId(input), | 61 | readonly interactionMode: ProviderInteractionMode; |
| 115 | }); | 62 | readonly branch: string | null; |
| 116 | }); | 63 | readonly worktreePath: string | null; |
| 64 | } | ||
| 117 | 65 | ||
| 118 | export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn( | 66 | export interface ThreadCommandInput extends CommandMetadata { |
| 119 | "EnvironmentCommands.createThread", | 67 | readonly threadId: ThreadId; |
| 120 | )(function* (input) { | 68 | } |
| 121 | const metadata = yield* timestampedCommandMetadata(input); | 69 | |
| 122 | return yield* dispatch({ | 70 | export type DeleteThreadInput = ThreadCommandInput; |
| 123 | ...input, | 71 | export type ArchiveThreadInput = ThreadCommandInput; |
| 124 | type: "thread.create", | 72 | export type UnarchiveThreadInput = ThreadCommandInput; |
| 125 | commandId: metadata.commandId, | 73 | |
| 126 | createdAt: metadata.createdAt, | 74 | export interface UpdateThreadMetadataInput extends ThreadCommandInput { |
| 127 | }); | 75 | readonly title?: string; |
| 76 | readonly modelSelection?: ModelSelection; | ||
| 77 | readonly branch?: string | null; | ||
| 78 | readonly worktreePath?: string | null; | ||
| 79 | } | ||
| 80 | |||
| 81 | export interface SetThreadRuntimeModeInput extends ThreadCommandInput { | ||
| 82 | readonly runtimeMode: RuntimeMode; | ||
| 83 | } | ||
| 84 | |||
| 85 | export interface SetThreadInteractionModeInput extends ThreadCommandInput { | ||
| 86 | readonly interactionMode: ProviderInteractionMode; | ||
| 87 | } | ||
| 88 | |||
| 89 | interface StartThreadBootstrap { | ||
| 90 | readonly createThread?: { | ||
| 91 | readonly projectId: ProjectId; | ||
| 92 | readonly title: string; | ||
| 93 | readonly modelSelection: ModelSelection; | ||
| 94 | readonly runtimeMode: RuntimeMode; | ||
| 95 | readonly interactionMode: ProviderInteractionMode; | ||
| 96 | readonly branch: string | null; | ||
| 97 | readonly worktreePath: string | null; | ||
| 98 | readonly createdAt: string; | ||
| 99 | }; | ||
| 100 | readonly prepareWorktree?: { | ||
| 101 | readonly projectCwd: string; | ||
| 102 | readonly baseBranch: string; | ||
| 103 | readonly branch?: string; | ||
| 104 | readonly startFromOrigin?: boolean; | ||
| 105 | }; | ||
| 106 | readonly runSetupScript?: boolean; | ||
| 107 | } | ||
| 108 | |||
| 109 | export interface StartThreadTurnInput extends ThreadCommandInput { | ||
| 110 | readonly message: { | ||
| 111 | readonly messageId: MessageId; | ||
| 112 | readonly role: "user"; | ||
| 113 | readonly text: string; | ||
| 114 | readonly attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>; | ||
| 115 | }; | ||
| 116 | readonly modelSelection?: ModelSelection; | ||
| 117 | readonly titleSeed?: string; | ||
| 118 | readonly runtimeMode: RuntimeMode; | ||
| 119 | readonly interactionMode: ProviderInteractionMode; | ||
| 120 | readonly bootstrap?: StartThreadBootstrap; | ||
| 121 | readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId }; | ||
| 122 | readonly dispatchMode?: "auto" | "queue" | "steer" | "restart"; | ||
| 123 | } | ||
| 124 | |||
| 125 | export interface InterruptThreadTurnInput extends ThreadCommandInput { | ||
| 126 | readonly runId?: RunId; | ||
| 127 | /** Temporary caller compatibility while UI naming moves from turns to runs. */ | ||
| 128 | readonly turnId?: string; | ||
| 129 | } | ||
| 130 | |||
| 131 | export interface RespondToThreadApprovalInput extends ThreadCommandInput { | ||
| 132 | readonly requestId: RuntimeRequestId; | ||
| 133 | readonly decision: ProviderApprovalDecision; | ||
| 134 | } | ||
| 135 | |||
| 136 | export interface RespondToThreadUserInputInput extends ThreadCommandInput { | ||
| 137 | readonly requestId: RuntimeRequestId; | ||
| 138 | readonly answers: ProviderUserInputAnswers; | ||
| 139 | } | ||
| 140 | |||
| 141 | export interface RevertThreadCheckpointInput extends ThreadCommandInput { | ||
| 142 | readonly checkpointId?: string; | ||
| 143 | readonly scopeId?: string; | ||
| 144 | readonly turnCount?: number; | ||
| 145 | } | ||
| 146 | |||
| 147 | export type StopThreadSessionInput = ThreadCommandInput; | ||
| 148 | |||
| 149 | const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(function* ( | ||
| 150 | input: CommandMetadata, | ||
| 151 | ) { | ||
| 152 | if (input.commandId !== undefined) return input.commandId; | ||
| 153 | const crypto = yield* Crypto.Crypto; | ||
| 154 | return CommandId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie)); | ||
| 128 | }); | 155 | }); |
| 129 | 156 | ||
| 130 | export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn( | 157 | const dispatch = (command: OrchestrationV2Command) => |
| 131 | "EnvironmentCommands.deleteThread", | 158 | request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command); |
| 132 | )(function* (input) { | 159 | |
| 133 | return yield* dispatch({ | 160 | const getProjection = (threadId: ThreadId) => |
| 134 | ...input, | 161 | request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); |
| 135 | type: "thread.delete", | 162 | |
| 136 | commandId: yield* commandId(input), | 163 | const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(function* ( |
| 164 | threadId: ThreadId, | ||
| 165 | messageId: MessageId, | ||
| 166 | attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>, | ||
| 167 | ) { | ||
| 168 | const stored = attachments.filter( | ||
| 169 | (attachment): attachment is ChatAttachment => "id" in attachment, | ||
| 170 | ); | ||
| 171 | const uploads = attachments.filter( | ||
| 172 | (attachment): attachment is UploadChatAttachment => "dataUrl" in attachment, | ||
| 173 | ); | ||
| 174 | if (uploads.length === 0) return stored; | ||
| 175 | const result = yield* request(WS_METHODS.assetsPersistChatAttachments, { | ||
| 176 | threadId, | ||
| 177 | messageId, | ||
| 178 | attachments: uploads, | ||
| 179 | }); | ||
| 180 | if (stored.length === 0) return result.attachments; | ||
| 181 | const byUpload = new Map( | ||
| 182 | uploads.map((attachment, index) => [attachment, result.attachments[index]]), | ||
| 183 | ); | ||
| 184 | return attachments.flatMap((attachment) => { | ||
| 185 | if ("id" in attachment) return [attachment]; | ||
| 186 | const persisted = byUpload.get(attachment); | ||
| 187 | return persisted === undefined ? [] : [persisted]; | ||
| 137 | }); | 188 | }); |
| 138 | }); | 189 | }); |
| 139 | 190 | ||
| 140 | export const archiveThread: (input: ArchiveThreadInput) => CommandEffect = Effect.fn( | 191 | const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( |
| 141 | "EnvironmentCommands.archiveThread", | 192 | mutation: |
| 142 | )(function* (input) { | 193 | | { |
| 143 | return yield* dispatch({ | 194 | readonly type: "project.create"; |
| 144 | ...input, | 195 | readonly commandId: CommandId; |
| 145 | type: "thread.archive", | 196 | readonly projectId: ProjectId; |
| 146 | commandId: yield* commandId(input), | 197 | readonly title: string; |
| 147 | }); | 198 | readonly workspaceRoot: string; |
| 199 | readonly createWorkspaceRootIfMissing?: boolean; | ||
| 200 | readonly defaultModelSelection?: ModelSelection | null; | ||
| 201 | readonly scripts?: ReadonlyArray<ProjectScript>; | ||
| 202 | } | ||
| 203 | | { | ||
| 204 | readonly type: "project.update"; | ||
| 205 | readonly commandId: CommandId; | ||
| 206 | readonly projectId: ProjectId; | ||
| 207 | readonly title?: string; | ||
| 208 | readonly workspaceRoot?: string; | ||
| 209 | readonly defaultModelSelection?: ModelSelection | null; | ||
| 210 | readonly scripts?: ReadonlyArray<ProjectScript>; | ||
| 211 | } | ||
| 212 | | { | ||
| 213 | readonly type: "project.delete"; | ||
| 214 | readonly commandId: CommandId; | ||
| 215 | readonly projectId: ProjectId; | ||
| 216 | readonly force?: boolean; | ||
| 217 | }, | ||
| 218 | ) { | ||
| 219 | return yield* request(WS_METHODS.projectsMutate, mutation); | ||
| 148 | }); | 220 | }); |
| 149 | 221 | ||
| 150 | export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = Effect.fn( | 222 | export const createProject = Effect.fn("EnvironmentCommands.createProject")(function* ( |
| 151 | "EnvironmentCommands.unarchiveThread", | 223 | input: CreateProjectInput, |
| 152 | )(function* (input) { | 224 | ) { |
| 153 | return yield* dispatch({ | 225 | return yield* mutateProject({ |
| 154 | ...input, | 226 | type: "project.create", |
| 155 | type: "thread.unarchive", | 227 | commandId: yield* allocateCommandId(input), |
| 156 | commandId: yield* commandId(input), | 228 | projectId: input.projectId, |
| 229 | title: input.title, | ||
| 230 | workspaceRoot: input.workspaceRoot, | ||
| 231 | ...(input.createWorkspaceRootIfMissing === undefined | ||
| 232 | ? {} | ||
| 233 | : { createWorkspaceRootIfMissing: input.createWorkspaceRootIfMissing }), | ||
| 234 | ...(input.defaultModelSelection === undefined | ||
| 235 | ? {} | ||
| 236 | : { defaultModelSelection: input.defaultModelSelection }), | ||
| 237 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), | ||
| 157 | }); | 238 | }); |
| 158 | }); | 239 | }); |
| 159 | 240 | ||
| 160 | export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( | 241 | export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(function* ( |
| 161 | "EnvironmentCommands.settleThread", | 242 | input: UpdateProjectInput, |
| 162 | )(function* (input) { | 243 | ) { |
| 163 | return yield* dispatch({ | 244 | return yield* mutateProject({ |
| 164 | ...input, | 245 | type: "project.update", |
| 165 | type: "thread.settle", | 246 | commandId: yield* allocateCommandId(input), |
| 166 | commandId: yield* commandId(input), | 247 | projectId: input.projectId, |
| 248 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 249 | ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), | ||
| 250 | ...(input.defaultModelSelection === undefined | ||
| 251 | ? {} | ||
| 252 | : { defaultModelSelection: input.defaultModelSelection }), | ||
| 253 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), | ||
| 167 | }); | 254 | }); |
| 168 | }); | 255 | }); |
| 169 | 256 | ||
| 170 | export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( | 257 | export const deleteProject = Effect.fn("EnvironmentCommands.deleteProject")(function* ( |
| 171 | "EnvironmentCommands.unsettleThread", | 258 | input: DeleteProjectInput, |
| 172 | )(function* (input) { | 259 | ) { |
| 173 | return yield* dispatch({ | 260 | return yield* mutateProject({ |
| 174 | ...input, | 261 | type: "project.delete", |
| 175 | type: "thread.unsettle", | 262 | commandId: yield* allocateCommandId(input), |
| 176 | commandId: yield* commandId(input), | 263 | projectId: input.projectId, |
| 264 | ...(input.force === undefined ? {} : { force: input.force }), | ||
| 177 | }); | 265 | }); |
| 178 | }); | 266 | }); |
| 179 | 267 | ||
| 180 | export const snoozeThread: (input: SnoozeThreadInput) => CommandEffect = Effect.fn( | 268 | export const createThread = Effect.fn("EnvironmentCommands.createThread")(function* ( |
| 181 | "EnvironmentCommands.snoozeThread", | 269 | input: CreateThreadInput, |
| 182 | )(function* (input) { | 270 | ) { |
| 183 | return yield* dispatch({ | 271 | return yield* dispatch({ |
| 184 | ...input, | 272 | type: "thread.create", |
| 185 | type: "thread.snooze", | 273 | commandId: yield* allocateCommandId(input), |
| 186 | commandId: yield* commandId(input), | 274 | createdBy: "user", |
| 275 | creationSource: input.creationSource ?? "web", | ||
| 276 | threadId: input.threadId, | ||
| 277 | projectId: input.projectId, | ||
| 278 | title: input.title, | ||
| 279 | modelSelection: input.modelSelection, | ||
| 280 | runtimeMode: input.runtimeMode, | ||
| 281 | interactionMode: input.interactionMode, | ||
| 282 | branch: input.branch, | ||
| 283 | worktreePath: input.worktreePath, | ||
| 187 | }); | 284 | }); |
| 188 | }); | 285 | }); |
| 189 | 286 | ||
| 190 | export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Effect.fn( | 287 | function simpleThreadCommand( |
| 191 | "EnvironmentCommands.unsnoozeThread", | 288 | type: "thread.delete" | "thread.archive" | "thread.unarchive", |
| 192 | )(function* (input) { | 289 | input: ThreadCommandInput, |
| 193 | return yield* dispatch({ | 290 | ) { |
| 194 | ...input, | 291 | return allocateCommandId(input).pipe( |
| 195 | type: "thread.unsnooze", | 292 | Effect.flatMap((commandId) => dispatch({ type, commandId, threadId: input.threadId })), |
| 196 | commandId: yield* commandId(input), | 293 | ); |
| 197 | }); | 294 | } |
| 295 | |||
| 296 | export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(function* ( | ||
| 297 | input: DeleteThreadInput, | ||
| 298 | ) { | ||
| 299 | return yield* simpleThreadCommand("thread.delete", input); | ||
| 198 | }); | 300 | }); |
| 199 | 301 | ||
| 200 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( | 302 | export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* ( |
| 201 | "EnvironmentCommands.updateThreadMetadata", | 303 | input: ArchiveThreadInput, |
| 202 | )(function* (input) { | 304 | ) { |
| 203 | return yield* dispatch({ | 305 | return yield* simpleThreadCommand("thread.archive", input); |
| 204 | ...input, | ||
| 205 | type: "thread.meta.update", | ||
| 206 | commandId: yield* commandId(input), | ||
| 207 | }); | ||
| 208 | }); | 306 | }); |
| 209 | 307 | ||
| 210 | export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( | 308 | export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* ( |
| 211 | "EnvironmentCommands.setThreadRuntimeMode", | 309 | input: UnarchiveThreadInput, |
| 212 | )(function* (input) { | 310 | ) { |
| 213 | const metadata = yield* timestampedCommandMetadata(input); | 311 | return yield* simpleThreadCommand("thread.unarchive", input); |
| 214 | return yield* dispatch({ | ||
| 215 | ...input, | ||
| 216 | type: "thread.runtime-mode.set", | ||
| 217 | commandId: metadata.commandId, | ||
| 218 | createdAt: metadata.createdAt, | ||
| 219 | }); | ||
| 220 | }); | 312 | }); |
| 221 | 313 | ||
| 222 | export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect = | 314 | export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadMetadata")( |
| 223 | Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) { | 315 | function* (input: UpdateThreadMetadataInput) { |
| 224 | const metadata = yield* timestampedCommandMetadata(input); | 316 | const commandId = yield* allocateCommandId(input); |
| 317 | let result = null; | ||
| 318 | if ( | ||
| 319 | input.title !== undefined || | ||
| 320 | input.branch !== undefined || | ||
| 321 | input.worktreePath !== undefined | ||
| 322 | ) { | ||
| 323 | result = yield* dispatch({ | ||
| 324 | type: "thread.metadata.update", | ||
| 325 | commandId, | ||
| 326 | threadId: input.threadId, | ||
| 327 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 328 | ...(input.branch === undefined ? {} : { branch: input.branch }), | ||
| 329 | ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }), | ||
| 330 | }); | ||
| 331 | } | ||
| 332 | if (input.modelSelection !== undefined) { | ||
| 333 | result = yield* dispatch({ | ||
| 334 | type: "thread.model-selection.set", | ||
| 335 | commandId: result === null ? commandId : CommandId.make(`${commandId}:model-selection`), | ||
| 336 | threadId: input.threadId, | ||
| 337 | modelSelection: input.modelSelection, | ||
| 338 | }); | ||
| 339 | } | ||
| 340 | return result ?? { sequence: 0 }; | ||
| 341 | }, | ||
| 342 | ); | ||
| 343 | |||
| 344 | export const setThreadRuntimeMode = Effect.fn("EnvironmentCommands.setThreadRuntimeMode")( | ||
| 345 | function* (input: SetThreadRuntimeModeInput) { | ||
| 346 | return yield* dispatch({ | ||
| 347 | type: "thread.runtime-mode.set", | ||
| 348 | commandId: yield* allocateCommandId(input), | ||
| 349 | threadId: input.threadId, | ||
| 350 | runtimeMode: input.runtimeMode, | ||
| 351 | }); | ||
| 352 | }, | ||
| 353 | ); | ||
| 354 | |||
| 355 | export const setThreadInteractionMode = Effect.fn("EnvironmentCommands.setThreadInteractionMode")( | ||
| 356 | function* (input: SetThreadInteractionModeInput) { | ||
| 225 | return yield* dispatch({ | 357 | return yield* dispatch({ |
| 226 | ...input, | ||
| 227 | type: "thread.interaction-mode.set", | 358 | type: "thread.interaction-mode.set", |
| 228 | commandId: metadata.commandId, | 359 | commandId: yield* allocateCommandId(input), |
| 229 | createdAt: metadata.createdAt, | 360 | threadId: input.threadId, |
| 361 | interactionMode: input.interactionMode, | ||
| 230 | }); | 362 | }); |
| 231 | }); | 363 | }, |
| 364 | ); | ||
| 232 | 365 | ||
| 233 | export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn( | 366 | export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(function* ( |
| 234 | "EnvironmentCommands.startThreadTurn", | 367 | input: StartThreadTurnInput, |
| 235 | )(function* (input) { | 368 | ) { |
| 236 | const metadata = yield* timestampedCommandMetadata(input); | 369 | const commandId = yield* allocateCommandId(input); |
| 370 | const attachments = yield* persistAttachments( | ||
| 371 | input.threadId, | ||
| 372 | input.message.messageId, | ||
| 373 | input.message.attachments, | ||
| 374 | ); | ||
| 375 | const bootstrap = input.bootstrap?.createThread; | ||
| 376 | const prepareWorktree = input.bootstrap?.prepareWorktree; | ||
| 377 | if (bootstrap !== undefined || prepareWorktree !== undefined) { | ||
| 378 | const existingProjection = | ||
| 379 | bootstrap === undefined ? yield* getProjection(input.threadId) : null; | ||
| 380 | const thread = bootstrap ?? existingProjection!.thread; | ||
| 381 | const workspaceStrategy = | ||
| 382 | prepareWorktree !== undefined | ||
| 383 | ? { | ||
| 384 | type: "worktree" as const, | ||
| 385 | baseRef: prepareWorktree.baseBranch, | ||
| 386 | ...(prepareWorktree.branch === undefined ? {} : { branch: prepareWorktree.branch }), | ||
| 387 | ...(prepareWorktree.startFromOrigin === undefined | ||
| 388 | ? {} | ||
| 389 | : { startFromOrigin: prepareWorktree.startFromOrigin }), | ||
| 390 | } | ||
| 391 | : bootstrap?.worktreePath | ||
| 392 | ? { | ||
| 393 | type: "existing_worktree" as const, | ||
| 394 | worktreePath: bootstrap.worktreePath, | ||
| 395 | ...(bootstrap.branch === null ? {} : { branch: bootstrap.branch }), | ||
| 396 | } | ||
| 397 | : { | ||
| 398 | type: "root" as const, | ||
| 399 | ...(bootstrap?.branch === null || bootstrap?.branch === undefined | ||
| 400 | ? {} | ||
| 401 | : { branch: bootstrap.branch }), | ||
| 402 | }; | ||
| 403 | return yield* request(ORCHESTRATION_V2_WS_METHODS.launchThread, { | ||
| 404 | commandId, | ||
| 405 | creationSource: input.creationSource ?? "web", | ||
| 406 | threadId: input.threadId, | ||
| 407 | ...(bootstrap === undefined ? { reuseExistingThread: true } : {}), | ||
| 408 | projectId: thread.projectId, | ||
| 409 | title: thread.title, | ||
| 410 | modelSelection: input.modelSelection ?? thread.modelSelection, | ||
| 411 | runtimeMode: input.runtimeMode, | ||
| 412 | interactionMode: input.interactionMode, | ||
| 413 | workspaceStrategy, | ||
| 414 | initialMessage: { | ||
| 415 | messageId: input.message.messageId, | ||
| 416 | text: input.message.text, | ||
| 417 | attachments, | ||
| 418 | }, | ||
| 419 | }); | ||
| 420 | } | ||
| 421 | |||
| 422 | const projection = yield* getProjection(input.threadId); | ||
| 423 | const activeRun = projection.runs.findLast( | ||
| 424 | (run) => run.status === "starting" || run.status === "running" || run.status === "waiting", | ||
| 425 | ); | ||
| 426 | const requestedMode = input.dispatchMode ?? "auto"; | ||
| 427 | const activeProviderThread = | ||
| 428 | activeRun === undefined | ||
| 429 | ? undefined | ||
| 430 | : projection.providerThreads.find((thread) => thread.id === activeRun.providerThreadId); | ||
| 431 | const activeProviderSession = | ||
| 432 | activeProviderThread?.providerSessionId === null || | ||
| 433 | activeProviderThread?.providerSessionId === undefined | ||
| 434 | ? undefined | ||
| 435 | : projection.providerSessions.find( | ||
| 436 | (session) => session.id === activeProviderThread.providerSessionId, | ||
| 437 | ); | ||
| 438 | const turnCapabilities = activeProviderSession?.capabilities.turns; | ||
| 439 | const dispatchMode = | ||
| 440 | activeRun === undefined | ||
| 441 | ? ({ type: "start_immediately" } as const) | ||
| 442 | : requestedMode === "steer" | ||
| 443 | ? ({ type: "steer_active", targetRunId: activeRun.id } as const) | ||
| 444 | : requestedMode === "restart" | ||
| 445 | ? ({ type: "restart_active", targetRunId: activeRun.id } as const) | ||
| 446 | : requestedMode === "queue" | ||
| 447 | ? ({ type: "queue_after_active" } as const) | ||
| 448 | : turnCapabilities?.supportsActiveSteering === true | ||
| 449 | ? ({ type: "steer_active", targetRunId: activeRun.id } as const) | ||
| 450 | : turnCapabilities?.supportsQueuedMessages === true | ||
| 451 | ? ({ type: "queue_after_active" } as const) | ||
| 452 | : turnCapabilities?.supportsSteeringByInterruptRestart === true | ||
| 453 | ? ({ type: "restart_active", targetRunId: activeRun.id } as const) | ||
| 454 | : ({ type: "queue_after_active" } as const); | ||
| 237 | return yield* dispatch({ | 455 | return yield* dispatch({ |
| 238 | ...input, | 456 | type: "message.dispatch", |
| 239 | type: "thread.turn.start", | 457 | commandId, |
| 240 | commandId: metadata.commandId, | 458 | createdBy: "user", |
| 241 | createdAt: metadata.createdAt, | 459 | creationSource: input.creationSource ?? "web", |
| 460 | threadId: input.threadId, | ||
| 461 | messageId: input.message.messageId, | ||
| 462 | text: input.message.text, | ||
| 463 | attachments, | ||
| 464 | ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), | ||
| 465 | ...(input.sourceProposedPlan === undefined ? {} : { sourcePlanRef: input.sourceProposedPlan }), | ||
| 466 | dispatchMode, | ||
| 242 | }); | 467 | }); |
| 243 | }); | 468 | }); |
| 244 | 469 | ||
| 245 | export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn( | 470 | export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( |
| 246 | "EnvironmentCommands.interruptThreadTurn", | 471 | input: InterruptThreadTurnInput, |
| 247 | )(function* (input) { | 472 | ) { |
| 248 | const metadata = yield* timestampedCommandMetadata(input); | 473 | const projection = yield* getProjection(input.threadId); |
| 474 | const runId = | ||
| 475 | input.runId ?? | ||
| 476 | (input.turnId as RunId | undefined) ?? | ||
| 477 | projection.runs.findLast( | ||
| 478 | (run) => run.status === "starting" || run.status === "running" || run.status === "waiting", | ||
| 479 | )?.id; | ||
| 480 | if (runId === undefined) return { sequence: 0 }; | ||
| 249 | return yield* dispatch({ | 481 | return yield* dispatch({ |
| 250 | ...input, | 482 | type: "run.interrupt", |
| 251 | type: "thread.turn.interrupt", | 483 | commandId: yield* allocateCommandId(input), |
| 252 | commandId: metadata.commandId, | 484 | threadId: input.threadId, |
| 253 | createdAt: metadata.createdAt, | 485 | runId, |
| 254 | }); | 486 | }); |
| 255 | }); | 487 | }); |
| 256 | 488 | ||
| 257 | export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = | 489 | export const respondToThreadApproval = Effect.fn("EnvironmentCommands.respondToThreadApproval")( |
| 258 | Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { | 490 | function* (input: RespondToThreadApprovalInput) { |
| 259 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 260 | return yield* dispatch({ | 491 | return yield* dispatch({ |
| 261 | ...input, | 492 | type: "runtime-request.respond", |
| 262 | type: "thread.approval.respond", | 493 | commandId: yield* allocateCommandId(input), |
| 263 | commandId: metadata.commandId, | 494 | threadId: input.threadId, |
| 264 | createdAt: metadata.createdAt, | 495 | requestId: input.requestId, |
| 496 | decision: input.decision, | ||
| 265 | }); | 497 | }); |
| 266 | }); | 498 | }, |
| 499 | ); | ||
| 267 | 500 | ||
| 268 | export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect = | 501 | export const respondToThreadUserInput = Effect.fn("EnvironmentCommands.respondToThreadUserInput")( |
| 269 | Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) { | 502 | function* (input: RespondToThreadUserInputInput) { |
| 270 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 271 | return yield* dispatch({ | 503 | return yield* dispatch({ |
| 272 | ...input, | 504 | type: "runtime-request.respond", |
| 273 | type: "thread.user-input.respond", | 505 | commandId: yield* allocateCommandId(input), |
| 274 | commandId: metadata.commandId, | 506 | threadId: input.threadId, |
| 275 | createdAt: metadata.createdAt, | 507 | requestId: input.requestId, |
| 508 | answers: input.answers, | ||
| 276 | }); | 509 | }); |
| 277 | }); | 510 | }, |
| 511 | ); | ||
| 278 | 512 | ||
| 279 | export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = | 513 | export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThreadCheckpoint")( |
| 280 | Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { | 514 | function* (input: RevertThreadCheckpointInput) { |
| 281 | const metadata = yield* timestampedCommandMetadata(input); | 515 | const projection = yield* getProjection(input.threadId); |
| 516 | const checkpoint = | ||
| 517 | projection.checkpoints.find( | ||
| 518 | (candidate) => candidate.id === input.checkpointId && candidate.scopeId === input.scopeId, | ||
| 519 | ) ?? | ||
| 520 | projection.checkpoints.findLast((candidate) => candidate.appRunOrdinal === input.turnCount); | ||
| 521 | if (checkpoint === undefined) return { sequence: 0 }; | ||
| 282 | return yield* dispatch({ | 522 | return yield* dispatch({ |
| 283 | ...input, | 523 | type: "checkpoint.rollback", |
| 284 | type: "thread.checkpoint.revert", | 524 | commandId: yield* allocateCommandId(input), |
| 285 | commandId: metadata.commandId, | 525 | threadId: input.threadId, |
| 286 | createdAt: metadata.createdAt, | 526 | scopeId: checkpoint.scopeId, |
| 527 | checkpointId: checkpoint.id, | ||
| 287 | }); | 528 | }); |
| 288 | }); | 529 | }, |
| 530 | ); | ||
| 289 | 531 | ||
| 290 | export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn( | 532 | export const stopThreadSession = Effect.fn("EnvironmentCommands.stopThreadSession")(function* ( |
| 291 | "EnvironmentCommands.stopThreadSession", | 533 | input: StopThreadSessionInput, |
| 292 | )(function* (input) { | 534 | ) { |
| 293 | const metadata = yield* timestampedCommandMetadata(input); | 535 | const projection = yield* getProjection(input.threadId); |
| 294 | return yield* dispatch({ | 536 | const commandId = yield* allocateCommandId(input); |
| 295 | ...input, | 537 | let result = { sequence: 0 }; |
| 296 | type: "thread.session.stop", | 538 | for (const session of projection.providerSessions) { |
| 297 | commandId: metadata.commandId, | 539 | result = yield* dispatch({ |
| 298 | createdAt: metadata.createdAt, | 540 | type: "provider-session.detach", |
| 299 | }); | 541 | commandId: CommandId.make(`${commandId}:detach:${session.id}`), |
| 542 | threadId: input.threadId, | ||
| 543 | providerSessionId: session.id, | ||
| 544 | reason: "client-requested", | ||
| 545 | }); | ||
| 546 | } | ||
| 547 | return result; | ||
| 300 | }); | 548 | }); |
| @@ -1,4 +1,5 @@ | |||
| 1 | export * from "./capabilities.ts"; | 1 | export * from "./capabilities.ts"; |
| 2 | export * from "./persistence.ts"; | 2 | export * from "./persistence.ts"; |
| 3 | export * from "./orchestrationCache.ts"; | ||
| 3 | export * from "./source.ts"; | 4 | export * from "./source.ts"; |
| 4 | export * from "./storageDocument.ts"; | 5 | export * from "./storageDocument.ts"; |
| @@ -0,0 +1,59 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | ORCHESTRATION_CACHE_SCHEMA_VERSION, | ||
| 9 | StoredOrchestrationShellSnapshot, | ||
| 10 | StoredOrchestrationThreadSnapshot, | ||
| 11 | decodeOrDiscardOrchestrationCache, | ||
| 12 | } from "./orchestrationCache.ts"; | ||
| 13 | import { v2Projection, v2ShellSnapshot, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts"; | ||
| 14 | |||
| 15 | const environmentId = EnvironmentId.make("environment-cache-test"); | ||
| 16 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot); | ||
| 17 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationThreadSnapshot); | ||
| 18 | |||
| 19 | describe("orchestration cache envelopes", () => { | ||
| 20 | it.effect("accepts V2 shell and thread cache envelopes", () => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const shell = yield* decodeStoredShellSnapshot({ | ||
| 23 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | ||
| 24 | environmentId, | ||
| 25 | snapshot: v2ShellSnapshot, | ||
| 26 | }); | ||
| 27 | const thread = yield* decodeStoredThreadSnapshot({ | ||
| 28 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | ||
| 29 | environmentId, | ||
| 30 | threadId: v2ThreadId, | ||
| 31 | thread: v2Projection, | ||
| 32 | }); | ||
| 33 | |||
| 34 | expect(shell.snapshot).toEqual(v2ShellSnapshot); | ||
| 35 | expect(thread.thread).toEqual(v2Projection); | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | |||
| 39 | it.effect("discards V1-versioned cache envelopes after a decode failure", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | let discardCount = 0; | ||
| 42 | const decoded = decodeStoredShellSnapshot({ | ||
| 43 | schemaVersion: 1, | ||
| 44 | environmentId, | ||
| 45 | snapshot: v2ShellSnapshot, | ||
| 46 | }).pipe(Effect.map(Option.some)); | ||
| 47 | |||
| 48 | const result = yield* decodeOrDiscardOrchestrationCache( | ||
| 49 | decoded, | ||
| 50 | Effect.sync(() => { | ||
| 51 | discardCount += 1; | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | |||
| 55 | expect(Option.isNone(result)).toBe(true); | ||
| 56 | expect(discardCount).toBe(1); | ||
| 57 | }), | ||
| 58 | ); | ||
| 59 | }); | ||
| @@ -0,0 +1,32 @@ | |||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationV2ShellSnapshot, | ||
| 4 | OrchestrationV2ThreadProjection, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | |||
| 11 | export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 2 as const; | ||
| 12 | |||
| 13 | export const StoredOrchestrationShellSnapshot = Schema.Struct({ | ||
| 14 | schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), | ||
| 15 | environmentId: EnvironmentId, | ||
| 16 | snapshot: OrchestrationV2ShellSnapshot, | ||
| 17 | }); | ||
| 18 | |||
| 19 | export const StoredOrchestrationThreadSnapshot = Schema.Struct({ | ||
| 20 | schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), | ||
| 21 | environmentId: EnvironmentId, | ||
| 22 | threadId: ThreadId, | ||
| 23 | thread: OrchestrationV2ThreadProjection, | ||
| 24 | }); | ||
| 25 | |||
| 26 | /** Invalid orchestration caches are disposable and must never block live synchronization. */ | ||
| 27 | export function decodeOrDiscardOrchestrationCache<A, E, R, E2, R2>( | ||
| 28 | decode: Effect.Effect<Option.Option<A>, E, R>, | ||
| 29 | discard: Effect.Effect<void, E2, R2>, | ||
| 30 | ) { | ||
| 31 | return decode.pipe(Effect.catch(() => discard.pipe(Effect.as(Option.none<A>())))); | ||
| 32 | } | ||
| @@ -1,8 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | type OrchestrationShellSnapshot, | 3 | type OrchestrationV2ShellSnapshot, |
| 4 | type OrchestrationThreadDetailSnapshot, | 4 | type OrchestrationV2ThreadProjection, |
| 5 | type ServerConfig, | ||
| 6 | type ThreadId, | 5 | type ThreadId, |
| 7 | type VcsListRefsResult, | 6 | type VcsListRefsResult, |
| 8 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| @@ -60,21 +59,18 @@ export class EnvironmentCacheStore extends Context.Service< · 51 unmodified lines | |||
| 60 | { | 59 | { |
| 61 | readonly loadShell: ( | 60 | readonly loadShell: ( |
| 62 | environmentId: EnvironmentId, | 61 | environmentId: EnvironmentId, |
| 63 | ) => Effect.Effect<Option.Option<OrchestrationShellSnapshot>, ConnectionPersistenceError>; | 62 | ) => Effect.Effect<Option.Option<OrchestrationV2ShellSnapshot>, ConnectionPersistenceError>; |
| 64 | readonly saveShell: ( | 63 | readonly saveShell: ( |
| 65 | environmentId: EnvironmentId, | 64 | environmentId: EnvironmentId, |
| 66 | snapshot: OrchestrationShellSnapshot, | 65 | snapshot: OrchestrationV2ShellSnapshot, |
| 67 | ) => Effect.Effect<void, ConnectionPersistenceError>; | 66 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
| 68 | readonly loadThread: ( | 67 | readonly loadThread: ( |
| 69 | environmentId: EnvironmentId, | 68 | environmentId: EnvironmentId, |
| 70 | threadId: ThreadId, | 69 | threadId: ThreadId, |
| 71 | ) => Effect.Effect< | 70 | ) => Effect.Effect<Option.Option<OrchestrationV2ThreadProjection>, ConnectionPersistenceError>; |
| 72 | Option.Option<OrchestrationThreadDetailSnapshot>, | ||
| 73 | ConnectionPersistenceError | ||
| 74 | >; | ||
| 75 | readonly saveThread: ( | 71 | readonly saveThread: ( |
| 76 | environmentId: EnvironmentId, | 72 | environmentId: EnvironmentId, |
| 77 | snapshot: OrchestrationThreadDetailSnapshot, | 73 | thread: OrchestrationV2ThreadProjection, |
| 78 | ) => Effect.Effect<void, ConnectionPersistenceError>; | 74 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
| 79 | readonly removeThread: ( | 75 | readonly removeThread: ( |
| 80 | environmentId: EnvironmentId, | 76 | environmentId: EnvironmentId, |
| @@ -1,8 +1,4 @@ | |||
| 1 | import { | 1 | import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; |
| 2 | ORCHESTRATION_V2_WS_METHODS, | ||
| 3 | ORCHESTRATION_WS_METHODS, | ||
| 4 | WS_METHODS, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 7 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 8 | import type * as Duration from "effect/Duration"; | 4 | import type * as Duration from "effect/Duration"; |
| @@ -44,8 +40,8 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; · 35 unmodified lines | |||
| 44 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; | 40 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; |
| 45 | 41 | ||
| 46 | export type EnvironmentSubscriptionRpcTag = | 42 | export type EnvironmentSubscriptionRpcTag = |
| 47 | | typeof ORCHESTRATION_WS_METHODS.subscribeShell | 43 | | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell |
| 48 | | typeof ORCHESTRATION_WS_METHODS.subscribeThread | 44 | | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread |
| 49 | | typeof WS_METHODS.subscribeAuthAccess | 45 | | typeof WS_METHODS.subscribeAuthAccess |
| 50 | | typeof WS_METHODS.subscribeServerConfig | 46 | | typeof WS_METHODS.subscribeServerConfig |
| 51 | | typeof WS_METHODS.subscribeServerLifecycle | 47 | | typeof WS_METHODS.subscribeServerLifecycle |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 3 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | import { expect, it } from "vite-plus/test"; | 4 | import { expect, it } from "vite-plus/test"; |
| @@ -22,7 +22,7 @@ it("does not expose an archived snapshot failure message", () => { · 17 unmodified lines | |||
| 22 | const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({ | 22 | const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({ |
| 23 | getSnapshotAtom: () => | 23 | getSnapshotAtom: () => |
| 24 | Atom.make( | 24 | Atom.make( |
| 25 | AsyncResult.failure<OrchestrationShellSnapshot, Error>( | 25 | AsyncResult.failure<OrchestrationV2ArchivedShellSnapshot, Error>( |
| 26 | Cause.fail(new Error("credential=secret-value")), | 26 | Cause.fail(new Error("credential=secret-value")), |
| 27 | ), | 27 | ), |
| 28 | ), | 28 | ), |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import { pipe } from "effect/Function"; | 3 | import { pipe } from "effect/Function"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| @@ -7,7 +7,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; · 2 unmodified lines | |||
| 7 | 7 | ||
| 8 | export interface ArchivedSnapshotEntry { | 8 | export interface ArchivedSnapshotEntry { |
| 9 | readonly environmentId: EnvironmentId; | 9 | readonly environmentId: EnvironmentId; |
| 10 | readonly snapshot: OrchestrationShellSnapshot; | 10 | readonly snapshot: OrchestrationV2ArchivedShellSnapshot; |
| 11 | } | 11 | } |
| 12 | 12 | ||
| 13 | export interface ArchivedThreadSnapshotsState { | 13 | export interface ArchivedThreadSnapshotsState { |
| @@ -40,7 +40,7 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E · 26 unmodified lines | |||
| 40 | export function createArchivedThreadSnapshotsAtomFamily<E>(options: { | 40 | export function createArchivedThreadSnapshotsAtomFamily<E>(options: { |
| 41 | readonly getSnapshotAtom: ( | 41 | readonly getSnapshotAtom: ( |
| 42 | environmentId: EnvironmentId, | 42 | environmentId: EnvironmentId, |
| 43 | ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationShellSnapshot, E>>; | 43 | ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationV2ArchivedShellSnapshot, E>>; |
| 44 | readonly labelPrefix: string; | 44 | readonly labelPrefix: string; |
| 45 | }) { | 45 | }) { |
| 46 | return Atom.family((environmentKey: string) => | 46 | return Atom.family((environmentKey: string) => |
| @@ -1,370 +1,115 @@ | |||
| 1 | import { | 1 | import { EnvironmentId, MessageId, RunId, TurnItemId } from "@t3tools/contracts"; |
| 2 | EnvironmentId, | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | ProjectId, | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | ProviderInstanceId, | 4 | |
| 5 | ThreadId, | 5 | import { presentThread, presentThreadShell } from "./models.ts"; |
| 6 | type OrchestrationShellSnapshot, | 6 | import { v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; |
| 7 | type OrchestrationThread, | 7 | |
| 8 | } from "@t3tools/contracts"; | 8 | const environmentId = EnvironmentId.make("environment-v2"); |
| 9 | import { describe, expect, it } from "@effect/vitest"; | 9 | |
| 10 | import * as Option from "effect/Option"; | 10 | describe("V2 client presentation", () => { |
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 11 | it("presents shell timestamps and status without constructing V1 state", () => { |
| 12 | 12 | const shell = presentThreadShell(environmentId, v2ThreadShell); | |
| 13 | import { PrimaryConnectionTarget } from "../connection/model.ts"; | 13 | expect(shell.environmentId).toBe(environmentId); |
| 14 | import { | 14 | expect(shell.createdAt).toBe("2026-06-20T00:00:00.000Z"); |
| 15 | InvalidScopedProjectKeyError, | 15 | expect(shell.runtime).toBeNull(); |
| 16 | InvalidScopedProjectRefCollectionKeyError, | 16 | expect(shell.source).toBe(v2ThreadShell); |
| 17 | InvalidScopedThreadKeyError, | ||
| 18 | parseProjectKey, | ||
| 19 | parseProjectRefCollectionKey, | ||
| 20 | parseThreadKey, | ||
| 21 | } from "./entities.ts"; | ||
| 22 | import type { EnvironmentShellState } from "./shell.ts"; | ||
| 23 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; | ||
| 24 | import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; | ||
| 25 | import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; | ||
| 26 | import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; | ||
| 27 | import { mergeEnvironmentThread } from "./threadDetail.ts"; | ||
| 28 | import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; | ||
| 29 | |||
| 30 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | ||
| 31 | const PROJECT_ID = ProjectId.make("project-1"); | ||
| 32 | const OTHER_PROJECT_ID = ProjectId.make("project-2"); | ||
| 33 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 34 | const OTHER_THREAD_ID = ThreadId.make("thread-2"); | ||
| 35 | |||
| 36 | describe("scoped entity keys", () => { | ||
| 37 | it("preserves an invalid project key as structured error data", () => { | ||
| 38 | const key = "missing-project-key-separator"; | ||
| 39 | let error: unknown; | ||
| 40 | |||
| 41 | try { | ||
| 42 | parseProjectKey(key); | ||
| 43 | } catch (cause) { | ||
| 44 | error = cause; | ||
| 45 | } | ||
| 46 | |||
| 47 | expect(error).toEqual(new InvalidScopedProjectKeyError({ key })); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("preserves an invalid thread key as structured error data", () => { | ||
| 51 | const key = "missing-thread-key-separator"; | ||
| 52 | let error: unknown; | ||
| 53 | |||
| 54 | try { | ||
| 55 | parseThreadKey(key); | ||
| 56 | } catch (cause) { | ||
| 57 | error = cause; | ||
| 58 | } | ||
| 59 | |||
| 60 | expect(error).toEqual(new InvalidScopedThreadKeyError({ key })); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("preserves malformed project reference collection input and its cause", () => { | ||
| 64 | const key = "not-json"; | ||
| 65 | let error: unknown; | ||
| 66 | |||
| 67 | try { | ||
| 68 | parseProjectRefCollectionKey(key); | ||
| 69 | } catch (cause) { | ||
| 70 | error = cause; | ||
| 71 | } | ||
| 72 | |||
| 73 | expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError); | ||
| 74 | expect(error).toMatchObject({ key, cause: expect.anything() }); | ||
| 75 | }); | 17 | }); |
| 76 | 18 | ||
| 77 | it("rejects invalid project reference collection shapes", () => { | 19 | it("retains the complete projection while deriving conversation parity", () => { |
| 78 | const key = JSON.stringify([["environment-1"]]); | 20 | const runId = RunId.make("run-1"); |
| 79 | 21 | const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"); | |
| 80 | expect(() => parseProjectRefCollectionKey(key)).toThrowError( | 22 | const projection = { |
| 81 | InvalidScopedProjectRefCollectionKeyError, | 23 | ...v2Projection, |
| 82 | ); | 24 | runs: [ |
| 83 | }); | ||
| 84 | }); | ||
| 85 | |||
| 86 | const THREAD_SHELL = { | ||
| 87 | id: THREAD_ID, | ||
| 88 | projectId: PROJECT_ID, | ||
| 89 | title: "Thread", | ||
| 90 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 91 | runtimeMode: "full-access", | ||
| 92 | interactionMode: "default", | ||
| 93 | branch: null, | ||
| 94 | worktreePath: null, | ||
| 95 | latestTurn: null, | ||
| 96 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 97 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 98 | archivedAt: null, | ||
| 99 | settledOverride: null, | ||
| 100 | settledAt: null, | ||
| 101 | session: null, | ||
| 102 | latestUserMessageAt: null, | ||
| 103 | hasPendingApprovals: false, | ||
| 104 | hasPendingUserInput: false, | ||
| 105 | hasActionableProposedPlan: false, | ||
| 106 | } as const; | ||
| 107 | |||
| 108 | const SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 109 | snapshotSequence: 1, | ||
| 110 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 111 | projects: [ | ||
| 112 | { | ||
| 113 | id: PROJECT_ID, | ||
| 114 | title: "Project", | ||
| 115 | workspaceRoot: "/repo", | ||
| 116 | repositoryIdentity: null, | ||
| 117 | defaultModelSelection: null, | ||
| 118 | scripts: [], | ||
| 119 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 120 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | id: OTHER_PROJECT_ID, | ||
| 124 | title: "Other project", | ||
| 125 | workspaceRoot: "/other-repo", | ||
| 126 | repositoryIdentity: null, | ||
| 127 | defaultModelSelection: null, | ||
| 128 | scripts: [], | ||
| 129 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 130 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 131 | }, | ||
| 132 | ], | ||
| 133 | threads: [ | ||
| 134 | THREAD_SHELL, | ||
| 135 | { | ||
| 136 | ...THREAD_SHELL, | ||
| 137 | id: OTHER_THREAD_ID, | ||
| 138 | projectId: OTHER_PROJECT_ID, | ||
| 139 | title: "Other thread", | ||
| 140 | }, | ||
| 141 | ], | ||
| 142 | }; | ||
| 143 | |||
| 144 | function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState { | ||
| 145 | return { | ||
| 146 | snapshot: Option.some(snapshot), | ||
| 147 | status: "live", | ||
| 148 | error: Option.none(), | ||
| 149 | }; | ||
| 150 | } | ||
| 151 | |||
| 152 | function makeHarness() { | ||
| 153 | const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => | ||
| 154 | Atom.make(AsyncResult.success(shellState(SNAPSHOT))), | ||
| 155 | ); | ||
| 156 | const threadStateAtoms = Atom.family((_key: string) => | ||
| 157 | Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)), | ||
| 158 | ); | ||
| 159 | const catalogValueAtom = Atom.make({ | ||
| 160 | isReady: true, | ||
| 161 | entries: new Map([ | ||
| 162 | [ | ||
| 163 | ENVIRONMENT_ID, | ||
| 164 | { | 25 | { |
| 165 | target: new PrimaryConnectionTarget({ | 26 | id: runId, |
| 166 | environmentId: ENVIRONMENT_ID, | 27 | threadId: v2Projection.thread.id, |
| 167 | label: "Environment", | 28 | ordinal: 1, |
| 168 | httpBaseUrl: "https://example.test", | 29 | providerInstanceId: v2Projection.thread.providerInstanceId, |
| 169 | wsBaseUrl: "wss://example.test", | 30 | modelSelection: v2Projection.thread.modelSelection, |
| 170 | }), | 31 | providerThreadId: null, |
| 171 | profile: Option.none(), | 32 | userMessageId: MessageId.make("message-user"), |
| 33 | rootNodeId: null, | ||
| 34 | activeAttemptId: null, | ||
| 35 | status: "running" as const, | ||
| 36 | requestedAt: now, | ||
| 37 | startedAt: now, | ||
| 38 | completedAt: null, | ||
| 39 | checkpointId: null, | ||
| 40 | contextHandoffId: null, | ||
| 172 | }, | 41 | }, |
| 173 | ], | 42 | ], |
| 174 | ]), | 43 | messages: [ |
| 175 | }); | 44 | { |
| 176 | const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); | 45 | id: MessageId.make("message-user"), |
| 177 | const projects = createEnvironmentProjectAtoms({ | 46 | threadId: v2Projection.thread.id, |
| 178 | catalogValueAtom, | 47 | runId, |
| 179 | snapshotAtom, | 48 | nodeId: null, |
| 180 | }); | 49 | role: "user" as const, |
| 181 | const threadShells = createEnvironmentThreadShellAtoms({ | 50 | text: "Hello", |
| 182 | catalogValueAtom, | 51 | attachments: [], |
| 183 | snapshotAtom, | 52 | streaming: false, |
| 184 | }); | 53 | createdBy: "user" as const, |
| 185 | const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) => | 54 | creationSource: "web" as const, |
| 186 | threadStateAtoms(`${environmentId}\u0000${threadId}`), | 55 | createdAt: now, |
| 187 | ); | 56 | updatedAt: now, |
| 188 | 57 | }, | |
| 189 | return { | 58 | ], |
| 190 | registry: AtomRegistry.make(), | 59 | updatedAt: now, |
| 191 | shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), | ||
| 192 | threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), | ||
| 193 | projects, | ||
| 194 | threadShells, | ||
| 195 | threadDetails, | ||
| 196 | }; | ||
| 197 | } | ||
| 198 | |||
| 199 | describe("environment entity projections", () => { | ||
| 200 | it("composes detail collections with authoritative shell workspace metadata", () => { | ||
| 201 | const messages: OrchestrationThread["messages"] = []; | ||
| 202 | const detail = { | ||
| 203 | ...THREAD_SHELL, | ||
| 204 | environmentId: ENVIRONMENT_ID, | ||
| 205 | title: "Cached thread", | ||
| 206 | branch: "stale-branch", | ||
| 207 | worktreePath: "/repo/stale-worktree", | ||
| 208 | deletedAt: null, | ||
| 209 | messages, | ||
| 210 | proposedPlans: [], | ||
| 211 | activities: [], | ||
| 212 | checkpoints: [], | ||
| 213 | } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; | ||
| 214 | const shell = { | ||
| 215 | ...THREAD_SHELL, | ||
| 216 | environmentId: ENVIRONMENT_ID, | ||
| 217 | title: "Current thread", | ||
| 218 | branch: "current-branch", | ||
| 219 | worktreePath: "/repo/current-worktree", | ||
| 220 | }; | ||
| 221 | |||
| 222 | const merged = mergeEnvironmentThread(detail, shell); | ||
| 223 | |||
| 224 | expect(merged).toMatchObject({ | ||
| 225 | title: "Current thread", | ||
| 226 | branch: "current-branch", | ||
| 227 | worktreePath: "/repo/current-worktree", | ||
| 228 | }); | ||
| 229 | expect(merged?.messages).toBe(messages); | ||
| 230 | }); | ||
| 231 | |||
| 232 | it("preserves untouched project and thread identities across unrelated shell updates", () => { | ||
| 233 | const harness = makeHarness(); | ||
| 234 | const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID); | ||
| 235 | const threadRefsAtom = harness.threadShells.environmentThreadRefsAtom(ENVIRONMENT_ID); | ||
| 236 | const projectsAtom = harness.projects.projectsAtom; | ||
| 237 | const projectAtom = harness.projects.projectAtom({ | ||
| 238 | environmentId: ENVIRONMENT_ID, | ||
| 239 | projectId: PROJECT_ID, | ||
| 240 | }); | ||
| 241 | const threadAtom = harness.threadShells.threadShellAtom({ | ||
| 242 | environmentId: ENVIRONMENT_ID, | ||
| 243 | threadId: THREAD_ID, | ||
| 244 | }); | ||
| 245 | const projectRefs = harness.registry.get(projectRefsAtom); | ||
| 246 | const threadRefs = harness.registry.get(threadRefsAtom); | ||
| 247 | const projects = harness.registry.get(projectsAtom); | ||
| 248 | const project = harness.registry.get(projectAtom); | ||
| 249 | const thread = harness.registry.get(threadAtom); | ||
| 250 | |||
| 251 | harness.registry.set( | ||
| 252 | harness.shellStateAtom, | ||
| 253 | AsyncResult.success( | ||
| 254 | shellState({ | ||
| 255 | ...SNAPSHOT, | ||
| 256 | snapshotSequence: 2, | ||
| 257 | threads: SNAPSHOT.threads.map((candidate) => | ||
| 258 | candidate.id === OTHER_THREAD_ID | ||
| 259 | ? { ...candidate, title: "Renamed other thread" } | ||
| 260 | : candidate, | ||
| 261 | ), | ||
| 262 | }), | ||
| 263 | ), | ||
| 264 | ); | ||
| 265 | |||
| 266 | expect(harness.registry.get(projectRefsAtom)).toBe(projectRefs); | ||
| 267 | expect(harness.registry.get(threadRefsAtom)).toBe(threadRefs); | ||
| 268 | expect(harness.registry.get(projectsAtom)).toBe(projects); | ||
| 269 | expect(harness.registry.get(projectAtom)).toBe(project); | ||
| 270 | expect(harness.registry.get(threadAtom)).toBe(thread); | ||
| 271 | }); | ||
| 272 | |||
| 273 | it("preserves project-scoped thread collections across unrelated project updates", () => { | ||
| 274 | const harness = makeHarness(); | ||
| 275 | const projectRef = { | ||
| 276 | environmentId: ENVIRONMENT_ID, | ||
| 277 | projectId: PROJECT_ID, | ||
| 278 | }; | 60 | }; |
| 279 | const refsByProjectAtom = | ||
| 280 | harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); | ||
| 281 | const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]); | ||
| 282 | const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID); | ||
| 283 | const threads = harness.registry.get(threadsAtom); | ||
| 284 | |||
| 285 | expect(threads).toHaveLength(1); | ||
| 286 | expect(threads[0]?.id).toBe(THREAD_ID); | ||
| 287 | 61 | ||
| 288 | harness.registry.set( | 62 | const thread = presentThread(environmentId, projection); |
| 289 | harness.shellStateAtom, | 63 | expect(thread.projection).toBe(projection); |
| 290 | AsyncResult.success( | 64 | expect(thread.latestRun?.runId).toBe(runId); |
| 291 | shellState({ | 65 | expect(thread.runtime?.status).toBe("running"); |
| 292 | ...SNAPSHOT, | 66 | expect(thread.runs).toHaveLength(1); |
| 293 | snapshotSequence: 2, | 67 | expect(thread.availableActions.canInterrupt).toBe(true); |
| 294 | threads: SNAPSHOT.threads.map((thread) => | 68 | expect(thread.messages[0]).toMatchObject({ text: "Hello", runId }); |
| 295 | thread.id === OTHER_THREAD_ID ? { ...thread, title: "Updated elsewhere" } : thread, | ||
| 296 | ), | ||
| 297 | }), | ||
| 298 | ), | ||
| 299 | ); | ||
| 300 | |||
| 301 | expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); | ||
| 302 | expect(harness.registry.get(threadsAtom)).toBe(threads); | ||
| 303 | }); | 69 | }); |
| 304 | 70 | ||
| 305 | it("updates only the requested thread detail and preserves untouched field identities", () => { | 71 | it("keeps the complete payload for generic V2 work-item rendering", () => { |
| 306 | const harness = makeHarness(); | 72 | const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"); |
| 307 | const threadRef = { | 73 | const item = { |
| 308 | environmentId: ENVIRONMENT_ID, | 74 | id: TurnItemId.make("item-dynamic"), |
| 309 | threadId: THREAD_ID, | 75 | threadId: v2Projection.thread.id, |
| 76 | runId: null, | ||
| 77 | nodeId: null, | ||
| 78 | providerThreadId: null, | ||
| 79 | providerTurnId: null, | ||
| 80 | nativeItemRef: null, | ||
| 81 | parentItemId: null, | ||
| 82 | ordinal: 0, | ||
| 83 | status: "completed" as const, | ||
| 84 | title: null, | ||
| 85 | startedAt: now, | ||
| 86 | completedAt: now, | ||
| 87 | updatedAt: now, | ||
| 88 | type: "dynamic_tool" as const, | ||
| 89 | toolName: "custom_tool", | ||
| 90 | input: { nested: { value: 1 } }, | ||
| 91 | output: { ok: true }, | ||
| 310 | }; | 92 | }; |
| 311 | const otherThreadRef = { | 93 | const projection = { |
| 312 | environmentId: ENVIRONMENT_ID, | 94 | ...v2Projection, |
| 313 | threadId: OTHER_THREAD_ID, | 95 | turnItems: [item], |
| 96 | visibleTurnItems: [ | ||
| 97 | { | ||
| 98 | position: 0, | ||
| 99 | visibility: "local" as const, | ||
| 100 | sourceThreadId: v2Projection.thread.id, | ||
| 101 | sourceItemId: item.id, | ||
| 102 | item, | ||
| 103 | }, | ||
| 104 | ], | ||
| 105 | updatedAt: now, | ||
| 314 | }; | 106 | }; |
| 315 | const threadDetailAtom = harness.threadDetails.detailAtom(threadRef); | ||
| 316 | const messagesAtom = harness.threadDetails.messagesAtom(threadRef); | ||
| 317 | const activitiesAtom = harness.threadDetails.activitiesAtom(threadRef); | ||
| 318 | const statusAtom = harness.threadDetails.statusAtom(threadRef); | ||
| 319 | const otherThreadDetailAtom = harness.threadDetails.detailAtom(otherThreadRef); | ||
| 320 | const otherValue = harness.registry.get(otherThreadDetailAtom); | ||
| 321 | const detail = { | ||
| 322 | ...THREAD_SHELL, | ||
| 323 | deletedAt: null, | ||
| 324 | messages: [], | ||
| 325 | proposedPlans: [], | ||
| 326 | activities: [], | ||
| 327 | checkpoints: [], | ||
| 328 | } satisfies OrchestrationThread; | ||
| 329 | |||
| 330 | harness.registry.set( | ||
| 331 | harness.threadStateAtom(THREAD_ID), | ||
| 332 | AsyncResult.success<EnvironmentThreadState>({ | ||
| 333 | data: Option.some(detail), | ||
| 334 | status: "live", | ||
| 335 | error: Option.none(), | ||
| 336 | }), | ||
| 337 | ); | ||
| 338 | |||
| 339 | const scopedDetail = harness.registry.get(threadDetailAtom); | ||
| 340 | const messages = harness.registry.get(messagesAtom); | ||
| 341 | const activities = harness.registry.get(activitiesAtom); | ||
| 342 | 107 | ||
| 343 | expect(scopedDetail).toEqual({ ...detail, environmentId: ENVIRONMENT_ID }); | 108 | const thread = presentThread(environmentId, projection); |
| 344 | expect(harness.registry.get(statusAtom)).toBe("live"); | 109 | expect(thread.workEntries[0]).toMatchObject({ |
| 345 | expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue); | 110 | itemType: "dynamic_tool", |
| 346 | 111 | label: "custom_tool", | |
| 347 | harness.registry.set( | 112 | structuredPayload: item, |
| 348 | harness.threadStateAtom(THREAD_ID), | 113 | }); |
| 349 | AsyncResult.success<EnvironmentThreadState>({ | ||
| 350 | data: Option.some({ | ||
| 351 | ...detail, | ||
| 352 | session: { | ||
| 353 | threadId: THREAD_ID, | ||
| 354 | status: "ready", | ||
| 355 | providerName: "codex", | ||
| 356 | runtimeMode: "full-access", | ||
| 357 | activeTurnId: null, | ||
| 358 | lastError: null, | ||
| 359 | updatedAt: "2026-06-01T00:01:00.000Z", | ||
| 360 | }, | ||
| 361 | }), | ||
| 362 | status: "live", | ||
| 363 | error: Option.none(), | ||
| 364 | }), | ||
| 365 | ); | ||
| 366 | |||
| 367 | expect(harness.registry.get(messagesAtom)).toBe(messages); | ||
| 368 | expect(harness.registry.get(activitiesAtom)).toBe(activities); | ||
| 369 | }); | 114 | }); |
| 370 | }); | 115 | }); |
| @@ -1,25 +1,459 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ChatAttachment, | ||
| 3 | CheckpointId, | ||
| 4 | CheckpointRef, | ||
| 5 | CheckpointScopeId, | ||
| 2 | EnvironmentId, | 6 | EnvironmentId, |
| 3 | OrchestrationMessage, | 7 | MessageId, |
| 4 | OrchestrationProjectShell, | 8 | OrchestrationProjectShell, |
| 5 | OrchestrationShellSnapshot, | 9 | OrchestrationV2PlanArtifact, |
| 6 | OrchestrationThread, | 10 | OrchestrationV2ProviderCapabilities, |
| 7 | OrchestrationThreadShell, | 11 | OrchestrationV2RunStatus, |
| 12 | OrchestrationV2ShellSnapshot, | ||
| 13 | OrchestrationV2ThreadProjection, | ||
| 14 | OrchestrationV2ThreadShell, | ||
| 15 | OrchestrationV2TurnItem, | ||
| 16 | PlanId, | ||
| 17 | ProjectId, | ||
| 18 | ProviderApprovalDecision, | ||
| 19 | ProviderInstanceId, | ||
| 20 | ProviderRequestKind, | ||
| 21 | RunId, | ||
| 22 | RuntimeRequestId, | ||
| 8 | ThreadId, | 23 | ThreadId, |
| 9 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 25 | import * as DateTime from "effect/DateTime"; | ||
| 10 | 26 | ||
| 11 | export interface EnvironmentProject extends OrchestrationProjectShell { | 27 | export interface EnvironmentProject extends OrchestrationProjectShell { |
| 12 | readonly environmentId: EnvironmentId; | 28 | readonly environmentId: EnvironmentId; |
| 13 | } | 29 | } |
| 14 | 30 | ||
| 15 | export interface EnvironmentThreadShell extends OrchestrationThreadShell { | 31 | /** |
| 32 | * A pristine V2 thread projection paired with the environment that produced it. | ||
| 33 | * | ||
| 34 | * Keep the projection nested so its identity and every structurally shared | ||
| 35 | * collection remain intact. This is the scoped detail boundary for rich V2 | ||
| 36 | * consumers; it is intentionally not another presentation-shaped thread. | ||
| 37 | */ | ||
| 38 | export interface ScopedThreadProjection { | ||
| 16 | readonly environmentId: EnvironmentId; | 39 | readonly environmentId: EnvironmentId; |
| 40 | readonly projection: OrchestrationV2ThreadProjection; | ||
| 17 | } | 41 | } |
| 18 | 42 | ||
| 19 | export type EnvironmentMessage = OrchestrationMessage; | 43 | export interface ThreadRunSummary { |
| 44 | readonly runId: RunId; | ||
| 45 | readonly status: OrchestrationV2RunStatus; | ||
| 46 | readonly requestedAt: string | null; | ||
| 47 | readonly startedAt: string | null; | ||
| 48 | readonly completedAt: string | null; | ||
| 49 | readonly assistantMessageId: MessageId | null; | ||
| 50 | readonly sourcePlanRef?: { | ||
| 51 | readonly threadId: ThreadId; | ||
| 52 | readonly planId: PlanId; | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | export interface ThreadRuntimeSummary { | ||
| 57 | readonly status: OrchestrationV2RunStatus | "idle"; | ||
| 58 | readonly activeRunId: RunId | null; | ||
| 59 | readonly providerInstanceId: ProviderInstanceId; | ||
| 60 | readonly providerName: string | null; | ||
| 61 | readonly lastError: string | null; | ||
| 62 | readonly updatedAt: string; | ||
| 63 | } | ||
| 64 | |||
| 65 | export function threadRuntimeIsActive(runtime: ThreadRuntimeSummary | null | undefined): boolean { | ||
| 66 | return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status); | ||
| 67 | } | ||
| 68 | |||
| 69 | export function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolean { | ||
| 70 | return ( | ||
| 71 | status === "queued" || status === "starting" || status === "running" || status === "waiting" | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | |||
| 75 | export interface ThreadConversationMessage { | ||
| 76 | readonly id: MessageId; | ||
| 77 | readonly role: "user" | "assistant" | "system"; | ||
| 78 | readonly text: string; | ||
| 79 | readonly attachments?: ReadonlyArray<ChatAttachment>; | ||
| 80 | readonly runId: RunId | null; | ||
| 81 | readonly streaming: boolean; | ||
| 82 | readonly createdAt: string; | ||
| 83 | readonly updatedAt: string; | ||
| 84 | } | ||
| 85 | |||
| 86 | export interface ThreadProposedPlan { | ||
| 87 | readonly id: PlanId; | ||
| 88 | readonly runId: RunId | null; | ||
| 89 | readonly planMarkdown: string; | ||
| 90 | readonly status: OrchestrationV2PlanArtifact["status"]; | ||
| 91 | readonly implementedAt: string | null; | ||
| 92 | readonly implementationThreadId: ThreadId | null; | ||
| 93 | readonly createdAt: string; | ||
| 94 | readonly updatedAt: string; | ||
| 95 | } | ||
| 96 | |||
| 97 | export interface ThreadTodoPlan { | ||
| 98 | readonly id: PlanId; | ||
| 99 | readonly runId: RunId | null; | ||
| 100 | readonly status: OrchestrationV2PlanArtifact["status"]; | ||
| 101 | readonly explanation: string | null; | ||
| 102 | readonly steps: ReadonlyArray<{ | ||
| 103 | readonly id: string; | ||
| 104 | readonly step: string; | ||
| 105 | readonly status: "pending" | "inProgress" | "completed"; | ||
| 106 | }>; | ||
| 107 | readonly updatedAt: string; | ||
| 108 | } | ||
| 109 | |||
| 110 | export interface ThreadCheckpointSummary { | ||
| 111 | readonly checkpointId?: CheckpointId; | ||
| 112 | readonly scopeId?: CheckpointScopeId; | ||
| 113 | readonly runId: RunId; | ||
| 114 | readonly checkpointTurnCount: number; | ||
| 115 | readonly checkpointRef: CheckpointRef; | ||
| 116 | readonly status: "ready" | "missing" | "error" | "stale"; | ||
| 117 | readonly files: ReadonlyArray<{ | ||
| 118 | readonly path: string; | ||
| 119 | readonly kind: string; | ||
| 120 | readonly additions: number; | ||
| 121 | readonly deletions: number; | ||
| 122 | }>; | ||
| 123 | readonly assistantMessageId: MessageId | null; | ||
| 124 | readonly completedAt: string; | ||
| 125 | } | ||
| 20 | 126 | ||
| 21 | export interface EnvironmentThread extends OrchestrationThread { | 127 | export interface ThreadPendingApproval { |
| 128 | readonly requestId: RuntimeRequestId; | ||
| 129 | readonly requestKind: ProviderRequestKind; | ||
| 130 | readonly createdAt: string; | ||
| 131 | readonly detail?: string; | ||
| 132 | readonly responseCapability: "live" | "not_resumable"; | ||
| 133 | } | ||
| 134 | |||
| 135 | export interface ThreadUserInputQuestion { | ||
| 136 | readonly id: string; | ||
| 137 | readonly header: string; | ||
| 138 | readonly question: string; | ||
| 139 | readonly options: ReadonlyArray<{ | ||
| 140 | readonly label: string; | ||
| 141 | readonly description: string; | ||
| 142 | }>; | ||
| 143 | readonly multiSelect: boolean; | ||
| 144 | } | ||
| 145 | |||
| 146 | export interface ThreadPendingUserInput { | ||
| 147 | readonly requestId: RuntimeRequestId; | ||
| 148 | readonly createdAt: string; | ||
| 149 | readonly questions: ReadonlyArray<ThreadUserInputQuestion>; | ||
| 150 | readonly responseCapability: "live" | "not_resumable"; | ||
| 151 | } | ||
| 152 | |||
| 153 | export type ThreadWorkEntryTone = "thinking" | "tool" | "info" | "error"; | ||
| 154 | export type ThreadWorkEntryStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; | ||
| 155 | |||
| 156 | /** | ||
| 157 | * Stable parity presentation for a V2 turn item. `structuredPayload` always | ||
| 158 | * retains the complete source item so generic rendering never loses provider | ||
| 159 | * or tool data. | ||
| 160 | */ | ||
| 161 | export interface ThreadWorkEntry { | ||
| 162 | readonly id: string; | ||
| 163 | readonly createdAt: string; | ||
| 164 | readonly runId: RunId | null; | ||
| 165 | readonly label: string; | ||
| 166 | readonly detail?: string; | ||
| 167 | readonly command?: string; | ||
| 168 | readonly rawCommand?: string; | ||
| 169 | readonly changedFiles?: ReadonlyArray<string>; | ||
| 170 | readonly tone: ThreadWorkEntryTone; | ||
| 171 | readonly toolTitle?: string; | ||
| 172 | readonly toolData?: unknown; | ||
| 173 | readonly itemType: OrchestrationV2TurnItem["type"]; | ||
| 174 | readonly requestKind?: ProviderRequestKind; | ||
| 175 | readonly toolLifecycleStatus: ThreadWorkEntryStatus; | ||
| 176 | readonly structuredPayload: OrchestrationV2TurnItem; | ||
| 177 | } | ||
| 178 | |||
| 179 | export interface EnvironmentThreadShell { | ||
| 22 | readonly environmentId: EnvironmentId; | 180 | readonly environmentId: EnvironmentId; |
| 181 | readonly id: ThreadId; | ||
| 182 | readonly projectId: ProjectId; | ||
| 183 | readonly title: string; | ||
| 184 | readonly providerInstanceId: ProviderInstanceId; | ||
| 185 | readonly modelSelection: OrchestrationV2ThreadShell["modelSelection"]; | ||
| 186 | readonly runtimeMode: OrchestrationV2ThreadShell["runtimeMode"]; | ||
| 187 | readonly interactionMode: OrchestrationV2ThreadShell["interactionMode"]; | ||
| 188 | readonly branch: string | null; | ||
| 189 | readonly worktreePath: string | null; | ||
| 190 | readonly lineage: OrchestrationV2ThreadShell["lineage"]; | ||
| 191 | readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"]; | ||
| 192 | readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; | ||
| 193 | readonly latestRun: ThreadRunSummary | null; | ||
| 194 | readonly runtime: ThreadRuntimeSummary | null; | ||
| 195 | readonly latestUserMessageAt: string | null; | ||
| 196 | readonly hasPendingApprovals: boolean; | ||
| 197 | readonly hasPendingUserInput: boolean; | ||
| 198 | readonly hasActionableProposedPlan: boolean; | ||
| 199 | readonly itemCount: number; | ||
| 200 | readonly visibleItemCount: number; | ||
| 201 | readonly createdAt: string; | ||
| 202 | readonly updatedAt: string; | ||
| 203 | readonly archivedAt: string | null; | ||
| 204 | readonly deletedAt: string | null; | ||
| 205 | readonly source: OrchestrationV2ThreadShell; | ||
| 206 | } | ||
| 207 | |||
| 208 | export interface EnvironmentThread extends EnvironmentThreadShell { | ||
| 209 | readonly projection: OrchestrationV2ThreadProjection; | ||
| 210 | readonly runs: ReadonlyArray<ThreadRunSummary>; | ||
| 211 | readonly capabilities: OrchestrationV2ProviderCapabilities | null; | ||
| 212 | readonly availableActions: { | ||
| 213 | readonly canSendMessage: boolean; | ||
| 214 | readonly canInterrupt: boolean; | ||
| 215 | readonly canRespondToApproval: boolean; | ||
| 216 | readonly canRespondToUserInput: boolean; | ||
| 217 | readonly canRollback: boolean; | ||
| 218 | readonly canArchive: boolean; | ||
| 219 | readonly canUnarchive: boolean; | ||
| 220 | readonly canDelete: boolean; | ||
| 221 | readonly canUpdateSettings: boolean; | ||
| 222 | }; | ||
| 223 | readonly messages: ReadonlyArray<ThreadConversationMessage>; | ||
| 224 | readonly workEntries: ReadonlyArray<ThreadWorkEntry>; | ||
| 225 | readonly proposedPlans: ReadonlyArray<ThreadProposedPlan>; | ||
| 226 | readonly todoPlans: ReadonlyArray<ThreadTodoPlan>; | ||
| 227 | readonly pendingApprovals: ReadonlyArray<ThreadPendingApproval>; | ||
| 228 | readonly pendingUserInputs: ReadonlyArray<ThreadPendingUserInput>; | ||
| 229 | readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>; | ||
| 230 | } | ||
| 231 | |||
| 232 | function iso(value: DateTime.Utc): string { | ||
| 233 | return DateTime.formatIso(value); | ||
| 234 | } | ||
| 235 | |||
| 236 | function nullableIso(value: DateTime.Utc | null): string | null { | ||
| 237 | return value === null ? null : iso(value); | ||
| 238 | } | ||
| 239 | |||
| 240 | function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { | ||
| 241 | return ( | ||
| 242 | status === "completed" || | ||
| 243 | status === "interrupted" || | ||
| 244 | status === "failed" || | ||
| 245 | status === "cancelled" || | ||
| 246 | status === "rolled_back" | ||
| 247 | ); | ||
| 248 | } | ||
| 249 | |||
| 250 | function workEntryStatus(item: OrchestrationV2TurnItem): ThreadWorkEntryStatus { | ||
| 251 | switch (item.status) { | ||
| 252 | case "pending": | ||
| 253 | case "running": | ||
| 254 | case "waiting": | ||
| 255 | return "inProgress"; | ||
| 256 | case "completed": | ||
| 257 | return "completed"; | ||
| 258 | case "failed": | ||
| 259 | return "failed"; | ||
| 260 | case "cancelled": | ||
| 261 | case "interrupted": | ||
| 262 | return "stopped"; | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | function workEntryTone(item: OrchestrationV2TurnItem): ThreadWorkEntryTone { | ||
| 267 | if (item.status === "failed") return "error"; | ||
| 268 | if (item.type === "reasoning") return "thinking"; | ||
| 269 | switch (item.type) { | ||
| 270 | case "command_execution": | ||
| 271 | case "file_change": | ||
| 272 | case "file_search": | ||
| 273 | case "web_search": | ||
| 274 | case "dynamic_tool": | ||
| 275 | case "subagent": | ||
| 276 | return "tool"; | ||
| 277 | default: | ||
| 278 | return "info"; | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | function summarizeWorkItem(item: OrchestrationV2TurnItem): { | ||
| 283 | readonly label: string; | ||
| 284 | readonly detail?: string; | ||
| 285 | readonly command?: string; | ||
| 286 | readonly rawCommand?: string; | ||
| 287 | readonly changedFiles?: ReadonlyArray<string>; | ||
| 288 | readonly toolTitle?: string; | ||
| 289 | readonly toolData?: unknown; | ||
| 290 | readonly requestKind?: ProviderRequestKind; | ||
| 291 | } { | ||
| 292 | const title = item.title?.trim() || null; | ||
| 293 | switch (item.type) { | ||
| 294 | case "reasoning": | ||
| 295 | return { label: title ?? "Thinking", ...(item.text ? { detail: item.text } : {}) }; | ||
| 296 | case "command_execution": | ||
| 297 | return { | ||
| 298 | label: title ?? "Ran command", | ||
| 299 | command: item.input, | ||
| 300 | rawCommand: item.input, | ||
| 301 | ...(item.output ? { detail: item.output } : {}), | ||
| 302 | toolTitle: title ?? "Command", | ||
| 303 | toolData: item, | ||
| 304 | }; | ||
| 305 | case "file_change": | ||
| 306 | return { | ||
| 307 | label: title ?? `Changed ${item.fileName}`, | ||
| 308 | changedFiles: [item.fileName], | ||
| 309 | ...(item.diffStr ? { detail: item.diffStr } : {}), | ||
| 310 | toolTitle: title ?? "File change", | ||
| 311 | toolData: item, | ||
| 312 | }; | ||
| 313 | case "file_search": | ||
| 314 | return { | ||
| 315 | label: title ?? "Searched files", | ||
| 316 | ...(item.pattern ? { detail: item.pattern } : {}), | ||
| 317 | toolTitle: title ?? "File search", | ||
| 318 | toolData: item, | ||
| 319 | }; | ||
| 320 | case "web_search": | ||
| 321 | return { | ||
| 322 | label: title ?? "Searched the web", | ||
| 323 | ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}), | ||
| 324 | toolTitle: title ?? "Web search", | ||
| 325 | toolData: item, | ||
| 326 | }; | ||
| 327 | case "approval_request": | ||
| 328 | return { | ||
| 329 | label: title ?? "Approval requested", | ||
| 330 | ...(item.prompt ? { detail: item.prompt } : {}), | ||
| 331 | requestKind: item.requestKind, | ||
| 332 | toolData: item, | ||
| 333 | }; | ||
| 334 | case "user_input_request": | ||
| 335 | return { | ||
| 336 | label: title ?? "Input requested", | ||
| 337 | detail: item.questions.map((question) => question.question).join("\n"), | ||
| 338 | toolData: item, | ||
| 339 | }; | ||
| 340 | case "checkpoint": | ||
| 341 | return { | ||
| 342 | label: title ?? "Checkpoint captured", | ||
| 343 | changedFiles: item.files.map((file) => file.path), | ||
| 344 | toolData: item, | ||
| 345 | }; | ||
| 346 | case "run_interrupt_request": | ||
| 347 | return { label: title ?? "Interrupt requested", detail: item.message, toolData: item }; | ||
| 348 | case "run_interrupt_result": | ||
| 349 | return { label: title ?? "Run interrupted", detail: item.message, toolData: item }; | ||
| 350 | case "compaction": | ||
| 351 | return { | ||
| 352 | label: title ?? "Context compacted", | ||
| 353 | ...(item.summary ? { detail: item.summary } : {}), | ||
| 354 | toolData: item, | ||
| 355 | }; | ||
| 356 | case "handoff": | ||
| 357 | return { | ||
| 358 | label: title ?? "Context handed off", | ||
| 359 | ...(item.summary ? { detail: item.summary } : {}), | ||
| 360 | toolData: item, | ||
| 361 | }; | ||
| 362 | case "fork": | ||
| 363 | return { label: title ?? "Thread forked", detail: item.targetThreadId, toolData: item }; | ||
| 364 | case "subagent": | ||
| 365 | return { | ||
| 366 | label: title ?? "Subagent", | ||
| 367 | detail: item.result ?? item.prompt, | ||
| 368 | toolTitle: title ?? "Subagent", | ||
| 369 | toolData: item, | ||
| 370 | }; | ||
| 371 | case "dynamic_tool": | ||
| 372 | return { | ||
| 373 | label: title ?? item.toolName ?? "Tool call", | ||
| 374 | toolTitle: title ?? item.toolName ?? "Tool", | ||
| 375 | toolData: { input: item.input, output: item.output }, | ||
| 376 | }; | ||
| 377 | case "user_message": | ||
| 378 | return { label: title ?? "User message", detail: item.text, toolData: item }; | ||
| 379 | case "assistant_message": | ||
| 380 | return { label: title ?? "Assistant message", detail: item.text, toolData: item }; | ||
| 381 | case "proposed_plan": | ||
| 382 | return { label: title ?? "Proposed plan", detail: item.markdown, toolData: item }; | ||
| 383 | case "todo_list": | ||
| 384 | return { | ||
| 385 | label: title ?? "Plan updated", | ||
| 386 | detail: item.steps.map((step) => step.text).join("\n"), | ||
| 387 | toolData: item, | ||
| 388 | }; | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | function presentWorkEntries( | ||
| 393 | projection: OrchestrationV2ThreadProjection, | ||
| 394 | ): ReadonlyArray<ThreadWorkEntry> { | ||
| 395 | return projection.visibleTurnItems | ||
| 396 | .filter( | ||
| 397 | ({ item }) => | ||
| 398 | item.type !== "user_message" && | ||
| 399 | item.type !== "assistant_message" && | ||
| 400 | item.type !== "proposed_plan" && | ||
| 401 | item.type !== "todo_list", | ||
| 402 | ) | ||
| 403 | .map(({ item }) => { | ||
| 404 | const summary = summarizeWorkItem(item); | ||
| 405 | return { | ||
| 406 | id: item.id, | ||
| 407 | createdAt: iso(item.startedAt ?? item.updatedAt), | ||
| 408 | runId: item.runId, | ||
| 409 | ...summary, | ||
| 410 | tone: workEntryTone(item), | ||
| 411 | itemType: item.type, | ||
| 412 | toolLifecycleStatus: workEntryStatus(item), | ||
| 413 | structuredPayload: item, | ||
| 414 | } satisfies ThreadWorkEntry; | ||
| 415 | }); | ||
| 416 | } | ||
| 417 | |||
| 418 | function presentRuns(projection: OrchestrationV2ThreadProjection): ReadonlyArray<ThreadRunSummary> { | ||
| 419 | return projection.runs | ||
| 420 | .toSorted((left, right) => left.ordinal - right.ordinal) | ||
| 421 | .map((run) => ({ | ||
| 422 | runId: run.id, | ||
| 423 | status: run.status, | ||
| 424 | requestedAt: iso(run.requestedAt), | ||
| 425 | startedAt: nullableIso(run.startedAt), | ||
| 426 | completedAt: nullableIso(run.completedAt), | ||
| 427 | assistantMessageId: | ||
| 428 | projection.messages.findLast( | ||
| 429 | (message) => message.runId === run.id && message.role === "assistant", | ||
| 430 | )?.id ?? null, | ||
| 431 | ...(run.sourcePlanRef === undefined ? {} : { sourcePlanRef: run.sourcePlanRef }), | ||
| 432 | })); | ||
| 433 | } | ||
| 434 | |||
| 435 | function presentLatestRun(projection: OrchestrationV2ThreadProjection): ThreadRunSummary | null { | ||
| 436 | return presentRuns(projection).at(-1) ?? null; | ||
| 437 | } | ||
| 438 | |||
| 439 | function presentRuntime( | ||
| 440 | shell: OrchestrationV2ThreadShell, | ||
| 441 | projection?: OrchestrationV2ThreadProjection, | ||
| 442 | ): ThreadRuntimeSummary | null { | ||
| 443 | const latestRun = projection === undefined ? null : presentLatestRun(projection); | ||
| 444 | const hasRuntime = shell.latestRunId !== null || shell.activeProviderThreadId !== null; | ||
| 445 | if (!hasRuntime && latestRun === null) return null; | ||
| 446 | const providerSession = projection?.providerSessions.findLast( | ||
| 447 | (session) => session.providerInstanceId === shell.providerInstanceId, | ||
| 448 | ); | ||
| 449 | return { | ||
| 450 | status: latestRun?.status ?? shell.status, | ||
| 451 | activeRunId: shell.activeRunId, | ||
| 452 | providerInstanceId: shell.providerInstanceId, | ||
| 453 | providerName: providerSession?.driver ?? null, | ||
| 454 | lastError: providerSession?.lastError ?? null, | ||
| 455 | updatedAt: iso(projection?.updatedAt ?? shell.updatedAt), | ||
| 456 | }; | ||
| 23 | } | 457 | } |
| 24 | 458 | ||
| 25 | export function scopeProject( | 459 | export function scopeProject( |
| @@ -29,25 +463,281 @@ export function scopeProject( · 3 unmodified lines | |||
| 29 | return { ...project, environmentId }; | 463 | return { ...project, environmentId }; |
| 30 | } | 464 | } |
| 31 | 465 | ||
| 32 | export function scopeThreadShell( | 466 | export function presentThreadShell( |
| 33 | environmentId: EnvironmentId, | 467 | environmentId: EnvironmentId, |
| 34 | thread: OrchestrationThreadShell, | 468 | thread: OrchestrationV2ThreadShell, |
| 35 | ): EnvironmentThreadShell { | 469 | ): EnvironmentThreadShell { |
| 36 | return { ...thread, environmentId }; | 470 | const updatedAt = iso(thread.updatedAt); |
| 471 | const latestRun = | ||
| 472 | thread.latestRunId === null | ||
| 473 | ? null | ||
| 474 | : ({ | ||
| 475 | runId: thread.latestRunId, | ||
| 476 | status: thread.status === "idle" ? "completed" : thread.status, | ||
| 477 | requestedAt: null, | ||
| 478 | startedAt: null, | ||
| 479 | completedAt: | ||
| 480 | thread.status === "idle" || terminalRunStatus(thread.status) ? updatedAt : null, | ||
| 481 | assistantMessageId: null, | ||
| 482 | } satisfies ThreadRunSummary); | ||
| 483 | return { | ||
| 484 | environmentId, | ||
| 485 | id: thread.id, | ||
| 486 | projectId: thread.projectId, | ||
| 487 | title: thread.title, | ||
| 488 | providerInstanceId: thread.providerInstanceId, | ||
| 489 | modelSelection: thread.modelSelection, | ||
| 490 | runtimeMode: thread.runtimeMode, | ||
| 491 | interactionMode: thread.interactionMode, | ||
| 492 | branch: thread.branch, | ||
| 493 | worktreePath: thread.worktreePath, | ||
| 494 | lineage: thread.lineage, | ||
| 495 | forkedFrom: thread.forkedFrom, | ||
| 496 | activeProviderThreadId: thread.activeProviderThreadId, | ||
| 497 | latestRun, | ||
| 498 | runtime: presentRuntime(thread), | ||
| 499 | latestUserMessageAt: nullableIso(thread.latestUserMessageAt), | ||
| 500 | hasPendingApprovals: | ||
| 501 | thread.pendingRuntimeRequest !== null && | ||
| 502 | thread.pendingRuntimeRequest.kind !== "user_input" && | ||
| 503 | thread.pendingRuntimeRequest.kind !== "auth_refresh", | ||
| 504 | hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input", | ||
| 505 | hasActionableProposedPlan: thread.hasActionableProposedPlan, | ||
| 506 | itemCount: thread.itemCount, | ||
| 507 | visibleItemCount: thread.visibleItemCount, | ||
| 508 | createdAt: iso(thread.createdAt), | ||
| 509 | updatedAt, | ||
| 510 | archivedAt: nullableIso(thread.archivedAt), | ||
| 511 | deletedAt: nullableIso(thread.deletedAt), | ||
| 512 | source: thread, | ||
| 513 | }; | ||
| 37 | } | 514 | } |
| 38 | 515 | ||
| 39 | export function scopeThread( | 516 | export const scopeThreadShell = presentThreadShell; |
| 517 | |||
| 518 | function presentMessages( | ||
| 519 | projection: OrchestrationV2ThreadProjection, | ||
| 520 | ): ReadonlyArray<ThreadConversationMessage> { | ||
| 521 | return projection.messages.map((message) => ({ | ||
| 522 | id: message.id, | ||
| 523 | role: message.role, | ||
| 524 | text: message.text, | ||
| 525 | attachments: message.attachments, | ||
| 526 | runId: message.runId, | ||
| 527 | streaming: message.streaming, | ||
| 528 | createdAt: iso(message.createdAt), | ||
| 529 | updatedAt: iso(message.updatedAt), | ||
| 530 | })); | ||
| 531 | } | ||
| 532 | |||
| 533 | function planItemTime(projection: OrchestrationV2ThreadProjection, planId: PlanId): string { | ||
| 534 | const item = projection.turnItems.findLast( | ||
| 535 | (candidate) => | ||
| 536 | (candidate.type === "proposed_plan" || candidate.type === "todo_list") && | ||
| 537 | candidate.planId === planId, | ||
| 538 | ); | ||
| 539 | return iso(item?.updatedAt ?? projection.updatedAt); | ||
| 540 | } | ||
| 541 | |||
| 542 | function presentProposedPlans( | ||
| 543 | projection: OrchestrationV2ThreadProjection, | ||
| 544 | ): ReadonlyArray<ThreadProposedPlan> { | ||
| 545 | return projection.plans.flatMap((plan) => { | ||
| 546 | if (plan.kind !== "proposed_plan") return []; | ||
| 547 | const updatedAt = planItemTime(projection, plan.id); | ||
| 548 | return [ | ||
| 549 | { | ||
| 550 | id: plan.id, | ||
| 551 | runId: plan.runId, | ||
| 552 | planMarkdown: plan.markdown, | ||
| 553 | status: plan.status, | ||
| 554 | implementedAt: plan.status === "completed" ? updatedAt : null, | ||
| 555 | implementationThreadId: null, | ||
| 556 | createdAt: updatedAt, | ||
| 557 | updatedAt, | ||
| 558 | } satisfies ThreadProposedPlan, | ||
| 559 | ]; | ||
| 560 | }); | ||
| 561 | } | ||
| 562 | |||
| 563 | function presentTodoPlans( | ||
| 564 | projection: OrchestrationV2ThreadProjection, | ||
| 565 | ): ReadonlyArray<ThreadTodoPlan> { | ||
| 566 | return projection.plans.flatMap((plan) => { | ||
| 567 | if (plan.kind !== "todo_list") return []; | ||
| 568 | return [ | ||
| 569 | { | ||
| 570 | id: plan.id, | ||
| 571 | runId: plan.runId, | ||
| 572 | status: plan.status, | ||
| 573 | explanation: plan.explanation ?? null, | ||
| 574 | steps: plan.steps.map((step) => ({ | ||
| 575 | id: step.id, | ||
| 576 | step: step.text, | ||
| 577 | status: step.status === "running" ? "inProgress" : step.status, | ||
| 578 | })), | ||
| 579 | updatedAt: planItemTime(projection, plan.id), | ||
| 580 | } satisfies ThreadTodoPlan, | ||
| 581 | ]; | ||
| 582 | }); | ||
| 583 | } | ||
| 584 | |||
| 585 | function presentPendingRequests(projection: OrchestrationV2ThreadProjection): { | ||
| 586 | readonly approvals: ReadonlyArray<ThreadPendingApproval>; | ||
| 587 | readonly userInputs: ReadonlyArray<ThreadPendingUserInput>; | ||
| 588 | } { | ||
| 589 | const approvals: ThreadPendingApproval[] = []; | ||
| 590 | const userInputs: ThreadPendingUserInput[] = []; | ||
| 591 | for (const request of projection.runtimeRequests) { | ||
| 592 | if (request.status !== "pending") continue; | ||
| 593 | const responseCapability = request.responseCapability.type; | ||
| 594 | if (request.kind === "user_input") { | ||
| 595 | const item = projection.turnItems.findLast( | ||
| 596 | (candidate) => | ||
| 597 | candidate.type === "user_input_request" && candidate.requestId === request.id, | ||
| 598 | ); | ||
| 599 | if (item === undefined || item.type !== "user_input_request") continue; | ||
| 600 | userInputs.push({ | ||
| 601 | requestId: request.id, | ||
| 602 | createdAt: iso(request.createdAt), | ||
| 603 | questions: item.questions.map((question) => ({ ...question, multiSelect: false })), | ||
| 604 | responseCapability, | ||
| 605 | }); | ||
| 606 | continue; | ||
| 607 | } | ||
| 608 | if (request.kind === "auth_refresh" || request.kind === "dynamic_tool_call") continue; | ||
| 609 | const item = projection.turnItems.findLast( | ||
| 610 | (candidate) => candidate.type === "approval_request" && candidate.requestId === request.id, | ||
| 611 | ); | ||
| 612 | approvals.push({ | ||
| 613 | requestId: request.id, | ||
| 614 | requestKind: request.kind, | ||
| 615 | createdAt: iso(request.createdAt), | ||
| 616 | ...(item?.type === "approval_request" && item.prompt ? { detail: item.prompt } : {}), | ||
| 617 | responseCapability, | ||
| 618 | }); | ||
| 619 | } | ||
| 620 | return { approvals, userInputs }; | ||
| 621 | } | ||
| 622 | |||
| 623 | function presentCheckpoints( | ||
| 624 | projection: OrchestrationV2ThreadProjection, | ||
| 625 | ): ReadonlyArray<ThreadCheckpointSummary> { | ||
| 626 | return projection.checkpoints.flatMap((checkpoint) => { | ||
| 627 | if (checkpoint.appRunOrdinal === null || checkpoint.runId === null) return []; | ||
| 628 | const assistantMessageId = | ||
| 629 | projection.messages.findLast( | ||
| 630 | (message) => message.runId === checkpoint.runId && message.role === "assistant", | ||
| 631 | )?.id ?? null; | ||
| 632 | return [ | ||
| 633 | { | ||
| 634 | checkpointId: checkpoint.id, | ||
| 635 | scopeId: checkpoint.scopeId, | ||
| 636 | runId: checkpoint.runId, | ||
| 637 | checkpointTurnCount: checkpoint.appRunOrdinal, | ||
| 638 | checkpointRef: checkpoint.ref, | ||
| 639 | status: checkpoint.status, | ||
| 640 | files: checkpoint.files, | ||
| 641 | assistantMessageId, | ||
| 642 | completedAt: iso(checkpoint.capturedAt), | ||
| 643 | } satisfies ThreadCheckpointSummary, | ||
| 644 | ]; | ||
| 645 | }); | ||
| 646 | } | ||
| 647 | |||
| 648 | export function presentThread( | ||
| 40 | environmentId: EnvironmentId, | 649 | environmentId: EnvironmentId, |
| 41 | thread: OrchestrationThread, | 650 | projection: OrchestrationV2ThreadProjection, |
| 42 | ): EnvironmentThread { | 651 | ): EnvironmentThread { |
| 43 | return { ...thread, environmentId }; | 652 | const shell = presentThreadShell(environmentId, { |
| 653 | ...projection.thread, | ||
| 654 | latestRunId: | ||
| 655 | projection.runs.reduce<(typeof projection.runs)[number] | null>( | ||
| 656 | (latest, candidate) => | ||
| 657 | latest === null || candidate.ordinal > latest.ordinal ? candidate : latest, | ||
| 658 | null, | ||
| 659 | )?.id ?? null, | ||
| 660 | activeRunId: | ||
| 661 | projection.runs.findLast( | ||
| 662 | (run) => run.status === "starting" || run.status === "running" || run.status === "waiting", | ||
| 663 | )?.id ?? null, | ||
| 664 | status: presentLatestRun(projection)?.status ?? "idle", | ||
| 665 | pendingRuntimeRequest: | ||
| 666 | projection.runtimeRequests.findLast((request) => request.status === "pending") ?? null, | ||
| 667 | latestVisibleMessage: | ||
| 668 | projection.messages.length === 0 | ||
| 669 | ? null | ||
| 670 | : (() => { | ||
| 671 | const message = projection.messages.at(-1)!; | ||
| 672 | return { | ||
| 673 | id: message.id, | ||
| 674 | role: message.role, | ||
| 675 | text: message.text, | ||
| 676 | updatedAt: message.updatedAt, | ||
| 677 | }; | ||
| 678 | })(), | ||
| 679 | latestUserMessageAt: | ||
| 680 | projection.messages.findLast((message) => message.role === "user")?.updatedAt ?? null, | ||
| 681 | hasActionableProposedPlan: projection.plans.some( | ||
| 682 | (plan) => plan.kind === "proposed_plan" && plan.status === "active", | ||
| 683 | ), | ||
| 684 | itemCount: projection.turnItems.length, | ||
| 685 | visibleItemCount: projection.visibleTurnItems.length, | ||
| 686 | }); | ||
| 687 | const requests = presentPendingRequests(projection); | ||
| 688 | const proposedPlans = presentProposedPlans(projection); | ||
| 689 | const checkpoints = presentCheckpoints(projection); | ||
| 690 | const runs = presentRuns(projection); | ||
| 691 | const capabilities = | ||
| 692 | projection.providerSessions.findLast( | ||
| 693 | (session) => session.providerInstanceId === projection.thread.providerInstanceId, | ||
| 694 | )?.capabilities ?? null; | ||
| 695 | const active = runs.some((run) => threadRunStatusIsActive(run.status)); | ||
| 696 | const mutable = projection.thread.archivedAt === null && projection.thread.deletedAt === null; | ||
| 697 | return { | ||
| 698 | ...shell, | ||
| 699 | latestRun: presentLatestRun(projection), | ||
| 700 | runtime: presentRuntime(shell.source, projection), | ||
| 701 | hasPendingApprovals: requests.approvals.length > 0, | ||
| 702 | hasPendingUserInput: requests.userInputs.length > 0, | ||
| 703 | hasActionableProposedPlan: proposedPlans.some((plan) => plan.status === "active"), | ||
| 704 | projection, | ||
| 705 | runs, | ||
| 706 | capabilities, | ||
| 707 | availableActions: { | ||
| 708 | canSendMessage: mutable, | ||
| 709 | canInterrupt: mutable && active && (capabilities?.turns.supportsInterrupt ?? true), | ||
| 710 | canRespondToApproval: requests.approvals.some( | ||
| 711 | (request) => request.responseCapability === "live", | ||
| 712 | ), | ||
| 713 | canRespondToUserInput: requests.userInputs.some( | ||
| 714 | (request) => request.responseCapability === "live", | ||
| 715 | ), | ||
| 716 | canRollback: mutable && checkpoints.some((checkpoint) => checkpoint.status === "ready"), | ||
| 717 | canArchive: projection.thread.archivedAt === null && projection.thread.deletedAt === null, | ||
| 718 | canUnarchive: projection.thread.archivedAt !== null && projection.thread.deletedAt === null, | ||
| 719 | canDelete: projection.thread.deletedAt === null, | ||
| 720 | canUpdateSettings: mutable, | ||
| 721 | }, | ||
| 722 | messages: presentMessages(projection), | ||
| 723 | workEntries: presentWorkEntries(projection), | ||
| 724 | proposedPlans, | ||
| 725 | todoPlans: presentTodoPlans(projection), | ||
| 726 | pendingApprovals: requests.approvals, | ||
| 727 | pendingUserInputs: requests.userInputs, | ||
| 728 | checkpoints, | ||
| 729 | }; | ||
| 44 | } | 730 | } |
| 45 | 731 | ||
| 46 | export function selectEnvironmentThreadShell( | 732 | export function selectEnvironmentThreadShell( |
| 47 | snapshot: OrchestrationShellSnapshot | null, | 733 | snapshot: OrchestrationV2ShellSnapshot | null, |
| 48 | environmentId: EnvironmentId, | 734 | environmentId: EnvironmentId, |
| 49 | threadId: ThreadId, | 735 | threadId: ThreadId, |
| 50 | ): EnvironmentThreadShell | null { | 736 | ): EnvironmentThreadShell | null { |
| 51 | const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; | 737 | const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; |
| 52 | return thread ? scopeThreadShell(environmentId, thread) : null; | 738 | return thread ? presentThreadShell(environmentId, thread) : null; |
| 739 | } | ||
| 740 | |||
| 741 | export function providerApprovalDecisionIsFinal(_decision: ProviderApprovalDecision): boolean { | ||
| 742 | return true; | ||
| 53 | } | 743 | } |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts"; | 1 | import { ORCHESTRATION_V2_WS_METHODS } from "@t3tools/contracts"; |
| 2 | import { Atom } from "effect/unstable/reactivity"; | 2 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 3 | ||
| 4 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | 4 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| @@ -10,11 +10,11 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 5 unmodified lines | |||
| 10 | return { | 10 | return { |
| 11 | turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { | 11 | turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 12 | label: "environment-data:orchestration:turn-diff", | 12 | label: "environment-data:orchestration:turn-diff", |
| 13 | tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | 13 | tag: ORCHESTRATION_V2_WS_METHODS.getTurnDiff, |
| 14 | }), | 14 | }), |
| 15 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { | 15 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 16 | label: "environment-data:orchestration:full-thread-diff", | 16 | label: "environment-data:orchestration:full-thread-diff", |
| 17 | tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, | 17 | tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, |
| 18 | }), | 18 | }), |
| 19 | threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, { | 19 | threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 20 | label: "environment-data:orchestration:thread-search", | 20 | label: "environment-data:orchestration:thread-search", |
| @@ -24,7 +24,7 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 3 unmodified lines | |||
| 24 | }), | 24 | }), |
| 25 | archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { | 25 | archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 26 | label: "environment-data:orchestration:archived-shell-snapshot", | 26 | label: "environment-data:orchestration:archived-shell-snapshot", |
| 27 | tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, | 27 | tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, |
| 28 | }), | 28 | }), |
| 29 | }; | 29 | }; |
| 30 | } | 30 | } |
| @@ -1,10 +1,15 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { | 2 | import { |
| 3 | type OrchestrationV2DomainEvent, | 3 | type OrchestrationV2DomainEvent, |
| 4 | type OrchestrationV2Run, | ||
| 4 | type OrchestrationV2ThreadProjection, | 5 | type OrchestrationV2ThreadProjection, |
| 6 | type OrchestrationV2TurnItem, | ||
| 7 | MessageId, | ||
| 5 | ProjectId, | 8 | ProjectId, |
| 6 | ProviderInstanceId, | 9 | ProviderInstanceId, |
| 10 | RunId, | ||
| 7 | ThreadId, | 11 | ThreadId, |
| 12 | TurnItemId, | ||
| 8 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 9 | import * as DateTime from "effect/DateTime"; | 14 | import * as DateTime from "effect/DateTime"; |
| 10 | 15 | ||
| @@ -12,6 +17,47 @@ import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection · 1 unmodified line | |||
| 12 | 17 | ||
| 13 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); | 18 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 14 | const threadId = ThreadId.make("thread-reducer"); | 19 | const threadId = ThreadId.make("thread-reducer"); |
| 20 | const runId = RunId.make("run-reducer"); | ||
| 21 | const run = { | ||
| 22 | id: runId, | ||
| 23 | threadId, | ||
| 24 | ordinal: 1, | ||
| 25 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 26 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 27 | providerThreadId: null, | ||
| 28 | userMessageId: MessageId.make("message-reducer"), | ||
| 29 | rootNodeId: null, | ||
| 30 | activeAttemptId: null, | ||
| 31 | status: "completed", | ||
| 32 | requestedAt: now, | ||
| 33 | startedAt: now, | ||
| 34 | completedAt: now, | ||
| 35 | checkpointId: null, | ||
| 36 | contextHandoffId: null, | ||
| 37 | } satisfies OrchestrationV2Run; | ||
| 38 | |||
| 39 | function commandItem(id: string, output = "done"): OrchestrationV2TurnItem { | ||
| 40 | return { | ||
| 41 | id: TurnItemId.make(id), | ||
| 42 | threadId, | ||
| 43 | runId, | ||
| 44 | nodeId: null, | ||
| 45 | providerThreadId: null, | ||
| 46 | providerTurnId: null, | ||
| 47 | nativeItemRef: null, | ||
| 48 | parentItemId: null, | ||
| 49 | ordinal: 1, | ||
| 50 | status: "completed", | ||
| 51 | title: null, | ||
| 52 | startedAt: now, | ||
| 53 | completedAt: now, | ||
| 54 | updatedAt: now, | ||
| 55 | type: "command_execution", | ||
| 56 | input: "pwd", | ||
| 57 | output, | ||
| 58 | exitCode: 0, | ||
| 59 | }; | ||
| 60 | } | ||
| 15 | const emptyProjection = { | 61 | const emptyProjection = { |
| 16 | thread: { | 62 | thread: { |
| 17 | id: threadId, | 63 | id: threadId, |
| @@ -79,4 +125,112 @@ describe("applyOrchestrationV2ProjectionEvent", () => { · 61 unmodified lines | |||
| 79 | 125 | ||
| 80 | expect(applyOrchestrationV2ProjectionEvent(emptyProjection, event)).toBe(emptyProjection); | 126 | expect(applyOrchestrationV2ProjectionEvent(emptyProjection, event)).toBe(emptyProjection); |
| 81 | }); | 127 | }); |
| 128 | |||
| 129 | it("preserves visible row identity when run updates do not change membership", () => { | ||
| 130 | const item = commandItem("item-stable"); | ||
| 131 | const visibleTurnItems = [ | ||
| 132 | { | ||
| 133 | position: 0, | ||
| 134 | visibility: "local" as const, | ||
| 135 | sourceThreadId: threadId, | ||
| 136 | sourceItemId: item.id, | ||
| 137 | item, | ||
| 138 | }, | ||
| 139 | ]; | ||
| 140 | const projection = { | ||
| 141 | ...emptyProjection, | ||
| 142 | runs: [run], | ||
| 143 | turnItems: [item], | ||
| 144 | visibleTurnItems, | ||
| 145 | }; | ||
| 146 | const event = { | ||
| 147 | id: "event-run-update", | ||
| 148 | type: "run.updated", | ||
| 149 | threadId, | ||
| 150 | runId, | ||
| 151 | occurredAt: now, | ||
| 152 | payload: { ...run, status: "completed" }, | ||
| 153 | } as OrchestrationV2DomainEvent; | ||
| 154 | |||
| 155 | const next = applyOrchestrationV2ProjectionEvent(projection, event); | ||
| 156 | expect(next?.visibleTurnItems).toBe(visibleTurnItems); | ||
| 157 | expect(next?.visibleTurnItems[0]).toBe(visibleTurnItems[0]); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("replaces only the updated visible item when membership is unchanged", () => { | ||
| 161 | const first = commandItem("item-first", "first"); | ||
| 162 | const second = commandItem("item-second", "second"); | ||
| 163 | const firstRow = { | ||
| 164 | position: 0, | ||
| 165 | visibility: "local" as const, | ||
| 166 | sourceThreadId: threadId, | ||
| 167 | sourceItemId: first.id, | ||
| 168 | item: first, | ||
| 169 | }; | ||
| 170 | const secondRow = { | ||
| 171 | position: 1, | ||
| 172 | visibility: "local" as const, | ||
| 173 | sourceThreadId: threadId, | ||
| 174 | sourceItemId: second.id, | ||
| 175 | item: second, | ||
| 176 | }; | ||
| 177 | const updated = commandItem("item-first", "streamed output"); | ||
| 178 | const projection = { | ||
| 179 | ...emptyProjection, | ||
| 180 | runs: [run], | ||
| 181 | turnItems: [first, second], | ||
| 182 | visibleTurnItems: [firstRow, secondRow], | ||
| 183 | }; | ||
| 184 | const event = { | ||
| 185 | id: "event-item-update", | ||
| 186 | type: "turn-item.updated", | ||
| 187 | threadId, | ||
| 188 | runId, | ||
| 189 | occurredAt: now, | ||
| 190 | payload: updated, | ||
| 191 | } as OrchestrationV2DomainEvent; | ||
| 192 | |||
| 193 | const next = applyOrchestrationV2ProjectionEvent(projection, event); | ||
| 194 | expect(next?.visibleTurnItems).not.toBe(projection.visibleTurnItems); | ||
| 195 | expect(next?.visibleTurnItems[0]).not.toBe(firstRow); | ||
| 196 | expect(next?.visibleTurnItems[0]?.item).toBe(updated); | ||
| 197 | expect(next?.visibleTurnItems[1]).toBe(secondRow); | ||
| 198 | }); | ||
| 199 | |||
| 200 | it("removes only hidden local items while preserving inherited rows", () => { | ||
| 201 | const inherited = commandItem("item-inherited"); | ||
| 202 | const local = commandItem("item-local"); | ||
| 203 | const inheritedRow = { | ||
| 204 | position: 0, | ||
| 205 | visibility: "inherited" as const, | ||
| 206 | sourceThreadId: ThreadId.make("thread-source"), | ||
| 207 | sourceItemId: inherited.id, | ||
| 208 | item: inherited, | ||
| 209 | }; | ||
| 210 | const localRow = { | ||
| 211 | position: 1, | ||
| 212 | visibility: "local" as const, | ||
| 213 | sourceThreadId: threadId, | ||
| 214 | sourceItemId: local.id, | ||
| 215 | item: local, | ||
| 216 | }; | ||
| 217 | const projection = { | ||
| 218 | ...emptyProjection, | ||
| 219 | runs: [run], | ||
| 220 | turnItems: [local], | ||
| 221 | visibleTurnItems: [inheritedRow, localRow], | ||
| 222 | }; | ||
| 223 | const event = { | ||
| 224 | id: "event-run-rollback", | ||
| 225 | type: "run.updated", | ||
| 226 | threadId, | ||
| 227 | runId, | ||
| 228 | occurredAt: now, | ||
| 229 | payload: { ...run, status: "rolled_back" }, | ||
| 230 | } as OrchestrationV2DomainEvent; | ||
| 231 | |||
| 232 | const next = applyOrchestrationV2ProjectionEvent(projection, event); | ||
| 233 | expect(next?.visibleTurnItems).toEqual([inheritedRow]); | ||
| 234 | expect(next?.visibleTurnItems[0]).toBe(inheritedRow); | ||
| 235 | }); | ||
| 82 | }); | 236 | }); |
| @@ -20,9 +20,15 @@ function removeVisibleItem( · 19 unmodified lines | |||
| 20 | rows: OrchestrationV2ThreadProjection["visibleTurnItems"], | 20 | rows: OrchestrationV2ThreadProjection["visibleTurnItems"], |
| 21 | sourceItemId: OrchestrationV2TurnItem["id"], | 21 | sourceItemId: OrchestrationV2TurnItem["id"], |
| 22 | ): OrchestrationV2ThreadProjection["visibleTurnItems"] { | 22 | ): OrchestrationV2ThreadProjection["visibleTurnItems"] { |
| 23 | return rows | 23 | const index = rows.findIndex((row) => row.sourceItemId === sourceItemId); |
| 24 | .filter((row) => row.sourceItemId !== sourceItemId) | 24 | if (index === -1) return rows; |
| 25 | .map((row, position) => ({ ...row, position })); | 25 | return renumberVisibleItems([...rows.slice(0, index), ...rows.slice(index + 1)]); |
| 26 | } | ||
| 27 | |||
| 28 | function renumberVisibleItems( | ||
| 29 | rows: OrchestrationV2ThreadProjection["visibleTurnItems"], | ||
| 30 | ): OrchestrationV2ThreadProjection["visibleTurnItems"] { | ||
| 31 | return rows.map((row, position) => (row.position === position ? row : { ...row, position })); | ||
| 26 | } | 32 | } |
| 27 | 33 | ||
| 28 | function shouldShowLocalTurnItem( | 34 | function shouldShowLocalTurnItem( |
| @@ -39,9 +45,18 @@ function shouldShowLocalTurnItem( · 10 unmodified lines | |||
| 39 | function activeVisibleTurnItems( | 45 | function activeVisibleTurnItems( |
| 40 | projection: OrchestrationV2ThreadProjection, | 46 | projection: OrchestrationV2ThreadProjection, |
| 41 | ): OrchestrationV2ThreadProjection["visibleTurnItems"] { | 47 | ): OrchestrationV2ThreadProjection["visibleTurnItems"] { |
| 42 | return projection.visibleTurnItems | 48 | const rows = projection.visibleTurnItems; |
| 43 | .filter((row) => row.visibility !== "local" || shouldShowLocalTurnItem(projection, row.item)) | 49 | let next: Array<(typeof rows)[number]> | null = null; |
| 44 | .map((row, position) => ({ ...row, position })); | 50 | for (let index = 0; index < rows.length; index += 1) { |
| 51 | const row = rows[index]!; | ||
| 52 | const keep = row.visibility !== "local" || shouldShowLocalTurnItem(projection, row.item); | ||
| 53 | if (!keep) { | ||
| 54 | next ??= rows.slice(0, index); | ||
| 55 | continue; | ||
| 56 | } | ||
| 57 | next?.push(row); | ||
| 58 | } | ||
| 59 | return next === null ? rows : renumberVisibleItems(next); | ||
| 45 | } | 60 | } |
| 46 | 61 | ||
| 47 | function upsertVisibleTurnItem( | 62 | function upsertVisibleTurnItem( |
| @@ -57,9 +72,19 @@ function upsertVisibleTurnItem( · 9 unmodified lines | |||
| 57 | sourceItemId: item.id, | 72 | sourceItemId: item.id, |
| 58 | item, | 73 | item, |
| 59 | }; | 74 | }; |
| 60 | return index === -1 | 75 | if (index === -1) return [...rows, next]; |
| 61 | ? [...rows, next] | 76 | const previous = rows[index]!; |
| 62 | : rows.map((row, rowIndex) => (rowIndex === index ? next : row)); | 77 | if ( |
| 78 | previous.visibility === next.visibility && | ||
| 79 | previous.sourceThreadId === next.sourceThreadId && | ||
| 80 | previous.sourceItemId === next.sourceItemId && | ||
| 81 | previous.item === item | ||
| 82 | ) { | ||
| 83 | return rows; | ||
| 84 | } | ||
| 85 | const updated = [...rows]; | ||
| 86 | updated[index] = next; | ||
| 87 | return updated; | ||
| 63 | } | 88 | } |
| 64 | 89 | ||
| 65 | /** Applies one committed event to a matching thread projection. */ | 90 | /** Applies one committed event to a matching thread projection. */ |
| @@ -0,0 +1,104 @@ | |||
| 1 | import { | ||
| 2 | ProjectId, | ||
| 3 | ProviderInstanceId, | ||
| 4 | ThreadId, | ||
| 5 | type OrchestrationProjectShell, | ||
| 6 | type OrchestrationV2ShellSnapshot, | ||
| 7 | type OrchestrationV2ThreadProjection, | ||
| 8 | type OrchestrationV2ThreadShell, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | |||
| 12 | export const v2Now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); | ||
| 13 | export const v2ProjectId = ProjectId.make("project-v2"); | ||
| 14 | export const v2ThreadId = ThreadId.make("thread-v2"); | ||
| 15 | export const v2ProviderInstanceId = ProviderInstanceId.make("codex"); | ||
| 16 | |||
| 17 | export const v2Project: OrchestrationProjectShell = { | ||
| 18 | id: v2ProjectId, | ||
| 19 | title: "Project", | ||
| 20 | workspaceRoot: "/workspace/project", | ||
| 21 | repositoryIdentity: null, | ||
| 22 | defaultModelSelection: null, | ||
| 23 | scripts: [], | ||
| 24 | createdAt: "2026-06-20T00:00:00.000Z", | ||
| 25 | updatedAt: "2026-06-20T00:00:00.000Z", | ||
| 26 | }; | ||
| 27 | |||
| 28 | export const v2ThreadShell: OrchestrationV2ThreadShell = { | ||
| 29 | id: v2ThreadId, | ||
| 30 | projectId: v2ProjectId, | ||
| 31 | title: "Thread", | ||
| 32 | providerInstanceId: v2ProviderInstanceId, | ||
| 33 | modelSelection: { instanceId: v2ProviderInstanceId, model: "gpt-5.4" }, | ||
| 34 | runtimeMode: "full-access", | ||
| 35 | interactionMode: "default", | ||
| 36 | branch: null, | ||
| 37 | worktreePath: null, | ||
| 38 | activeProviderThreadId: null, | ||
| 39 | lineage: { rootThreadId: v2ThreadId, parentThreadId: null, relationshipToParent: null }, | ||
| 40 | forkedFrom: null, | ||
| 41 | createdBy: "user", | ||
| 42 | creationSource: "web", | ||
| 43 | latestRunId: null, | ||
| 44 | activeRunId: null, | ||
| 45 | status: "idle", | ||
| 46 | pendingRuntimeRequest: null, | ||
| 47 | latestVisibleMessage: null, | ||
| 48 | latestUserMessageAt: null, | ||
| 49 | hasActionableProposedPlan: false, | ||
| 50 | itemCount: 0, | ||
| 51 | visibleItemCount: 0, | ||
| 52 | createdAt: v2Now, | ||
| 53 | updatedAt: v2Now, | ||
| 54 | archivedAt: null, | ||
| 55 | deletedAt: null, | ||
| 56 | }; | ||
| 57 | |||
| 58 | export const v2Projection: OrchestrationV2ThreadProjection = { | ||
| 59 | thread: { | ||
| 60 | id: v2ThreadShell.id, | ||
| 61 | projectId: v2ThreadShell.projectId, | ||
| 62 | title: v2ThreadShell.title, | ||
| 63 | providerInstanceId: v2ThreadShell.providerInstanceId, | ||
| 64 | modelSelection: v2ThreadShell.modelSelection, | ||
| 65 | runtimeMode: v2ThreadShell.runtimeMode, | ||
| 66 | interactionMode: v2ThreadShell.interactionMode, | ||
| 67 | branch: v2ThreadShell.branch, | ||
| 68 | worktreePath: v2ThreadShell.worktreePath, | ||
| 69 | activeProviderThreadId: v2ThreadShell.activeProviderThreadId, | ||
| 70 | lineage: v2ThreadShell.lineage, | ||
| 71 | forkedFrom: v2ThreadShell.forkedFrom, | ||
| 72 | createdBy: v2ThreadShell.createdBy, | ||
| 73 | creationSource: v2ThreadShell.creationSource, | ||
| 74 | createdAt: v2Now, | ||
| 75 | updatedAt: v2Now, | ||
| 76 | archivedAt: null, | ||
| 77 | deletedAt: null, | ||
| 78 | }, | ||
| 79 | runs: [], | ||
| 80 | attempts: [], | ||
| 81 | nodes: [], | ||
| 82 | subagents: [], | ||
| 83 | providerSessions: [], | ||
| 84 | providerThreads: [], | ||
| 85 | providerTurns: [], | ||
| 86 | runtimeRequests: [], | ||
| 87 | messages: [], | ||
| 88 | plans: [], | ||
| 89 | turnItems: [], | ||
| 90 | checkpointScopes: [], | ||
| 91 | checkpoints: [], | ||
| 92 | contextHandoffs: [], | ||
| 93 | contextTransfers: [], | ||
| 94 | visibleTurnItems: [], | ||
| 95 | updatedAt: v2Now, | ||
| 96 | }; | ||
| 97 | |||
| 98 | export const v2ShellSnapshot: OrchestrationV2ShellSnapshot = { | ||
| 99 | schemaVersion: 1, | ||
| 100 | snapshotSequence: 0, | ||
| 101 | projects: [v2Project], | ||
| 102 | threads: [v2ThreadShell], | ||
| 103 | archivedThreads: [], | ||
| 104 | }; | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | OrchestrationProjectShell, | 3 | OrchestrationProjectShell, |
| 4 | OrchestrationShellSnapshot, | 4 | OrchestrationV2ShellSnapshot, |
| 5 | ProjectId, | 5 | ProjectId, |
| 6 | ScopedProjectRef, | 6 | ScopedProjectRef, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| @@ -19,7 +19,7 @@ export function createEnvironmentProjectAtoms(input: { · 11 unmodified lines | |||
| 19 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | 19 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; |
| 20 | readonly snapshotAtom: ( | 20 | readonly snapshotAtom: ( |
| 21 | environmentId: EnvironmentId, | 21 | environmentId: EnvironmentId, |
| 22 | ) => Atom.Atom<OrchestrationShellSnapshot | null>; | 22 | ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>; |
| 23 | }) { | 23 | }) { |
| 24 | const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => | 24 | const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => |
| 25 | Atom.make( | 25 | Atom.make( |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { | 1 | import { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | ORCHESTRATION_WS_METHODS, | 3 | ORCHESTRATION_V2_WS_METHODS, |
| 4 | type OrchestrationShellSnapshot, | 4 | type OrchestrationV2ShellSnapshot, |
| 5 | type OrchestrationShellStreamItem, | 5 | type OrchestrationV2ShellStreamItem, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| @@ -22,7 +22,8 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; · 13 unmodified lines | |||
| 22 | import * as Persistence from "../platform/persistence.ts"; | 22 | import * as Persistence from "../platform/persistence.ts"; |
| 23 | import * as RpcSession from "../rpc/session.ts"; | 23 | import * as RpcSession from "../rpc/session.ts"; |
| 24 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 24 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 25 | import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts"; | 25 | import { makeEnvironmentShellState } from "./shell.ts"; |
| 26 | import { v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; | ||
| 26 | 27 | ||
| 27 | const TARGET = new PrimaryConnectionTarget({ | 28 | const TARGET = new PrimaryConnectionTarget({ |
| 28 | environmentId: EnvironmentId.make("environment-1"), | 29 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -31,20 +32,9 @@ const TARGET = new PrimaryConnectionTarget({ · 2 unmodified lines | |||
| 31 | wsBaseUrl: "wss://environment.example.test", | 32 | wsBaseUrl: "wss://environment.example.test", |
| 32 | }); | 33 | }); |
| 33 | 34 | ||
| 34 | const PREPARED: PreparedConnection = { | 35 | const LIVE_SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = { |
| 35 | environmentId: TARGET.environmentId, | 36 | ...v2ShellSnapshot, |
| 36 | label: TARGET.label, | ||
| 37 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 38 | socketUrl: TARGET.wsBaseUrl, | ||
| 39 | httpAuthorization: null, | ||
| 40 | target: TARGET, | ||
| 41 | }; | ||
| 42 | |||
| 43 | const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 44 | snapshotSequence: 1, | 37 | snapshotSequence: 1, |
| 45 | projects: [], | ||
| 46 | threads: [], | ||
| 47 | updatedAt: "2026-06-06T00:00:00.000Z", | ||
| 48 | }; | 38 | }; |
| 49 | 39 | ||
| 50 | function session(client: WsRpcProtocolClient): RpcSession.RpcSession { | 40 | function session(client: WsRpcProtocolClient): RpcSession.RpcSession { |
| @@ -60,9 +50,9 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { · 9 unmodified lines | |||
| 60 | describe("environment shell synchronization", () => { | 50 | describe("environment shell synchronization", () => { |
| 61 | it.effect("publishes live state before persistence and preserves it when ready", () => | 51 | it.effect("publishes live state before persistence and preserves it when ready", () => |
| 62 | Effect.gen(function* () { | 52 | Effect.gen(function* () { |
| 63 | const events = yield* Queue.unbounded<OrchestrationShellStreamItem>(); | 53 | const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>(); |
| 64 | const client = { | 54 | const client = { |
| 65 | [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | 55 | [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), |
| 66 | } as unknown as WsRpcProtocolClient; | 56 | } as unknown as WsRpcProtocolClient; |
| 67 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | 57 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); |
| 68 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | 58 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( |
| @@ -2,11 +2,13 @@ import type { ServerConfig } from "@t3tools/contracts"; · 1 unmodified line | |||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { describe, expect, it } from "@effect/vitest"; | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | 6 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 6 | 7 | ||
| 7 | import { PrimaryConnectionTarget } from "../connection/model.ts"; | 8 | import { PrimaryConnectionTarget } from "../connection/model.ts"; |
| 8 | import type { EnvironmentShellState } from "./shell.ts"; | 9 | import type { EnvironmentShellState } from "./shell.ts"; |
| 9 | import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; | 10 | import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; |
| 11 | import { v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; | ||
| 10 | 12 | ||
| 11 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | 13 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 12 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); | 14 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); |
| @@ -34,10 +36,16 @@ function shellState(input: { · 21 unmodified lines | |||
| 34 | input.updatedAt === undefined | 36 | input.updatedAt === undefined |
| 35 | ? Option.none() | 37 | ? Option.none() |
| 36 | : Option.some({ | 38 | : Option.some({ |
| 39 | schemaVersion: 1, | ||
| 37 | snapshotSequence: input.snapshotSequence ?? 1, | 40 | snapshotSequence: input.snapshotSequence ?? 1, |
| 38 | updatedAt: input.updatedAt, | ||
| 39 | projects: [], | 41 | projects: [], |
| 40 | threads: [], | 42 | threads: [ |
| 43 | { | ||
| 44 | ...v2ThreadShell, | ||
| 45 | updatedAt: DateTime.makeUnsafe(input.updatedAt), | ||
| 46 | }, | ||
| 47 | ], | ||
| 48 | archivedThreads: [], | ||
| 41 | }), | 49 | }), |
| 42 | status: input.status, | 50 | status: input.status, |
| 43 | error: input.error === undefined ? Option.none() : Option.some(input.error), | 51 | error: input.error === undefined ? Option.none() : Option.some(input.error), |
| @@ -1,11 +1,12 @@ | |||
| 1 | import { | 1 | import { |
| 2 | ORCHESTRATION_WS_METHODS, | 2 | ORCHESTRATION_V2_WS_METHODS, |
| 3 | type EnvironmentId, | 3 | type EnvironmentId, |
| 4 | type OrchestrationShellSnapshot, | 4 | type OrchestrationV2ShellSnapshot, |
| 5 | type OrchestrationShellStreamItem, | 5 | type OrchestrationV2ShellStreamItem, |
| 6 | type ServerConfig, | 6 | type ServerConfig, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; | 8 | import * as Cause from "effect/Cause"; |
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 11 | import * as Queue from "effect/Queue"; | 12 | import * as Queue from "effect/Queue"; |
| @@ -29,7 +30,7 @@ import { followStreamInEnvironment } from "./runtime.ts"; · 17 unmodified lines | |||
| 29 | export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; | 30 | export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; |
| 30 | 31 | ||
| 31 | export interface EnvironmentShellState { | 32 | export interface EnvironmentShellState { |
| 32 | readonly snapshot: Option.Option<OrchestrationShellSnapshot>; | 33 | readonly snapshot: Option.Option<OrchestrationV2ShellSnapshot>; |
| 33 | readonly status: EnvironmentShellStatus; | 34 | readonly status: EnvironmentShellStatus; |
| 34 | readonly error: Option.Option<string>; | 35 | readonly error: Option.Option<string>; |
| 35 | } | 36 | } |
| @@ -41,7 +42,7 @@ const EMPTY_SHELL_STATE: EnvironmentShellState = { · 5 unmodified lines | |||
| 41 | }; | 42 | }; |
| 42 | 43 | ||
| 43 | function shellStatusForSnapshot( | 44 | function shellStatusForSnapshot( |
| 44 | snapshot: Option.Option<OrchestrationShellSnapshot>, | 45 | snapshot: Option.Option<OrchestrationV2ShellSnapshot>, |
| 45 | ): EnvironmentShellStatus { | 46 | ): EnvironmentShellStatus { |
| 46 | return Option.isSome(snapshot) ? "cached" : "empty"; | 47 | return Option.isSome(snapshot) ? "cached" : "empty"; |
| 47 | } | 48 | } |
| @@ -61,7 +62,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 13 unmodified lines | |||
| 61 | environmentId, | 62 | environmentId, |
| 62 | ...safeErrorLogAttributes(error), | 63 | ...safeErrorLogAttributes(error), |
| 63 | }), | 64 | }), |
| 64 | Effect.as(Option.none<OrchestrationShellSnapshot>()), | 65 | Effect.as(Option.none<OrchestrationV2ShellSnapshot>()), |
| 65 | ), | 66 | ), |
| 66 | ), | 67 | ), |
| 67 | ); | 68 | ); |
| @@ -70,11 +71,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 2 unmodified lines | |||
| 70 | status: shellStatusForSnapshot(cachedSnapshot), | 71 | status: shellStatusForSnapshot(cachedSnapshot), |
| 71 | error: Option.none(), | 72 | error: Option.none(), |
| 72 | }); | 73 | }); |
| 73 | const awaitingCompletion = yield* Ref.make(false); | 74 | const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1); |
| 74 | const persistence = yield* Queue.sliding<OrchestrationShellSnapshot>(1); | ||
| 75 | 75 | ||
| 76 | const persist = Effect.fn("EnvironmentShellState.persist")(function* ( | 76 | const persist = Effect.fn("EnvironmentShellState.persist")(function* ( |
| 77 | snapshot: OrchestrationShellSnapshot, | 77 | snapshot: OrchestrationV2ShellSnapshot, |
| 78 | ) { | 78 | ) { |
| 79 | yield* cache.saveShell(environmentId, snapshot).pipe( | 79 | yield* cache.saveShell(environmentId, snapshot).pipe( |
| 80 | Effect.catch((error) => | 80 | Effect.catch((error) => |
| @@ -133,7 +133,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 52 unmodified lines | |||
| 133 | ); | 133 | ); |
| 134 | 134 | ||
| 135 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( | 135 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( |
| 136 | item: OrchestrationShellStreamItem, | 136 | item: OrchestrationV2ShellStreamItem, |
| 137 | ) { | 137 | ) { |
| 138 | if (item.kind === "synchronized") { | 138 | if (item.kind === "synchronized") { |
| 139 | yield* Ref.set(awaitingCompletion, false); | 139 | yield* Ref.set(awaitingCompletion, false); |
| @@ -169,56 +169,13 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 29 unmodified lines | |||
| 169 | yield* Queue.offer(persistence, nextSnapshot); | 169 | yield* Queue.offer(persistence, nextSnapshot); |
| 170 | }); | 170 | }); |
| 171 | 171 | ||
| 172 | const foregroundResubscriptions = Option.match(wakeups, { | 172 | yield* subscribe( |
| 173 | onNone: () => Stream.never, | 173 | ORCHESTRATION_V2_WS_METHODS.subscribeShell, |
| 174 | onSome: (service) => | 174 | {}, |
| 175 | service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), | 175 | { |
| 176 | }); | 176 | onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), |
| 177 | 177 | }, | |
| 178 | yield* setSynchronizing; | 178 | ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); |
| 179 | yield* Effect.forkScoped( | ||
| 180 | subscribeDynamic( | ||
| 181 | ORCHESTRATION_WS_METHODS.subscribeShell, | ||
| 182 | Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { | ||
| 183 | const supportsCompletionMarker = yield* session.initialConfig.pipe( | ||
| 184 | Effect.map((config) => config.shellResumeCompletionMarker === true), | ||
| 185 | Effect.orElseSucceed(() => false), | ||
| 186 | ); | ||
| 187 | yield* Ref.set(awaitingCompletion, supportsCompletionMarker); | ||
| 188 | yield* setSynchronizing; | ||
| 189 | |||
| 190 | const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( | ||
| 191 | Effect.flatMap( | ||
| 192 | Option.match({ | ||
| 193 | onSome: Effect.succeed, | ||
| 194 | onNone: () => | ||
| 195 | SubscriptionRef.changes(supervisor.prepared).pipe( | ||
| 196 | Stream.filter(Option.isSome), | ||
| 197 | Stream.map((value) => value.value), | ||
| 198 | Stream.runHead, | ||
| 199 | Effect.map(Option.getOrThrow), | ||
| 200 | ), | ||
| 201 | }), | ||
| 202 | ), | ||
| 203 | ); | ||
| 204 | const httpSnapshot = yield* snapshotLoader.load(prepared); | ||
| 205 | if (Option.isSome(httpSnapshot)) { | ||
| 206 | yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); | ||
| 207 | return { | ||
| 208 | afterSequence: httpSnapshot.value.snapshotSequence, | ||
| 209 | ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), | ||
| 210 | }; | ||
| 211 | } | ||
| 212 | |||
| 213 | return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; | ||
| 214 | }), | ||
| 215 | { | ||
| 216 | onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), | ||
| 217 | retryExpectedFailureAfter: "250 millis", | ||
| 218 | resubscribe: foregroundResubscriptions, | ||
| 219 | }, | ||
| 220 | ).pipe(Stream.runForEach(applyItem)), | ||
| 221 | ); | ||
| 222 | yield* SubscriptionRef.changes(supervisor.state).pipe( | 179 | yield* SubscriptionRef.changes(supervisor.state).pipe( |
| 223 | Stream.runForEach((connectionState) => { | 180 | Stream.runForEach((connectionState) => { |
| 224 | switch (connectionProjectionPhase(connectionState)) { | 181 | switch (connectionProjectionPhase(connectionState)) { |
| @@ -314,8 +271,20 @@ export function createEnvironmentShellSummaryAtom(input: { · 89 unmodified lines | |||
| 314 | continue; | 271 | continue; |
| 315 | } | 272 | } |
| 316 | hasSnapshot = true; | 273 | hasSnapshot = true; |
| 317 | const updatedAt = state.snapshot.value.updatedAt; | 274 | const snapshot = state.snapshot.value; |
| 318 | if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) { | 275 | const updatedAt = snapshot.threads.concat(snapshot.archivedThreads).reduce<string | null>( |
| 276 | (latest, thread) => { | ||
| 277 | const value = DateTime.formatIso(thread.updatedAt); | ||
| 278 | return latest === null || value > latest ? value : latest; | ||
| 279 | }, | ||
| 280 | snapshot.projects.reduce<string | null>((latest, project) => { | ||
| 281 | return latest === null || project.updatedAt > latest ? project.updatedAt : latest; | ||
| 282 | }, null), | ||
| 283 | ); | ||
| 284 | if ( | ||
| 285 | updatedAt !== null && | ||
| 286 | (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) | ||
| 287 | ) { | ||
| 319 | latestSnapshotUpdatedAt = updatedAt; | 288 | latestSnapshotUpdatedAt = updatedAt; |
| 320 | } | 289 | } |
| 321 | } | 290 | } |
| @@ -1,185 +1,84 @@ | |||
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 4 | import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; |
| 4 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { applyShellStreamEvent } from "./shellReducer.ts"; | 5 | import { applyShellStreamEvent } from "./shellReducer.ts"; |
| 7 | 6 | ||
| 8 | const baseSnapshot: OrchestrationShellSnapshot = { | ||
| 9 | snapshotSequence: 0, | ||
| 10 | projects: [], | ||
| 11 | threads: [], | ||
| 12 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 13 | }; | ||
| 14 | |||
| 15 | const stubProject = { | ||
| 16 | id: ProjectId.make("project-1"), | ||
| 17 | title: "Test Project", | ||
| 18 | workspaceRoot: "/workspace/test", | ||
| 19 | repositoryIdentity: null, | ||
| 20 | defaultModelSelection: null, | ||
| 21 | scripts: [], | ||
| 22 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 23 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | const stubThread = { | ||
| 27 | id: ThreadId.make("thread-1"), | ||
| 28 | projectId: ProjectId.make("project-1"), | ||
| 29 | title: "Test Thread", | ||
| 30 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 31 | runtimeMode: "full-access" as const, | ||
| 32 | interactionMode: "default" as const, | ||
| 33 | branch: null, | ||
| 34 | worktreePath: null, | ||
| 35 | latestTurn: null, | ||
| 36 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 37 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 38 | archivedAt: null, | ||
| 39 | settledOverride: null, | ||
| 40 | settledAt: null, | ||
| 41 | latestUserMessageAt: null, | ||
| 42 | hasPendingApprovals: false, | ||
| 43 | hasPendingUserInput: false, | ||
| 44 | hasActionableProposedPlan: false, | ||
| 45 | session: null, | ||
| 46 | } as const; | ||
| 47 | |||
| 48 | describe("applyShellStreamEvent", () => { | 7 | describe("applyShellStreamEvent", () => { |
| 49 | it("ignores stale project upserts without mutating the snapshot", () => { | 8 | it("ignores stale project updates without mutating the snapshot", () => { |
| 50 | const snapshotWithProject: OrchestrationShellSnapshot = { | 9 | const snapshotWithProject = { |
| 51 | ...baseSnapshot, | 10 | ...v2ShellSnapshot, |
| 52 | snapshotSequence: 4, | 11 | snapshotSequence: 4, |
| 53 | projects: [stubProject], | 12 | projects: [v2Project], |
| 54 | }; | 13 | }; |
| 55 | 14 | ||
| 56 | for (const sequence of [3, 4]) { | 15 | for (const sequence of [3, 4]) { |
| 57 | const next = applyShellStreamEvent(snapshotWithProject, { | 16 | const next = applyShellStreamEvent(snapshotWithProject, { |
| 58 | kind: "project-upserted", | 17 | kind: "project.updated", |
| 59 | sequence, | 18 | sequence, |
| 60 | project: { ...stubProject, title: "Stale Title" }, | 19 | project: { ...v2Project, title: "Stale Title" }, |
| 61 | }); | 20 | }); |
| 62 | 21 | ||
| 63 | expect(next).toBe(snapshotWithProject); | 22 | expect(next).toBe(snapshotWithProject); |
| 64 | expect(next.snapshotSequence).toBe(4); | 23 | expect(next.snapshotSequence).toBe(4); |
| 65 | expect(next.projects[0]?.title).toBe("Test Project"); | 24 | expect(next.projects[0]?.title).toBe(v2Project.title); |
| 66 | } | 25 | } |
| 67 | }); | 26 | }); |
| 68 | 27 | ||
| 69 | describe("project-upserted", () => { | 28 | it("applies project updates and removals", () => { |
| 70 | it("adds a new project", () => { | 29 | const updated = applyShellStreamEvent(v2ShellSnapshot, { |
| 71 | const event: OrchestrationShellStreamEvent = { | 30 | kind: "project.updated", |
| 72 | kind: "project-upserted", | 31 | sequence: 1, |
| 73 | sequence: 1, | 32 | project: { ...v2Project, title: "Updated" }, |
| 74 | project: stubProject, | ||
| 75 | }; | ||
| 76 | |||
| 77 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 78 | |||
| 79 | expect(next.projects).toHaveLength(1); | ||
| 80 | expect(next.projects[0]?.id).toBe("project-1"); | ||
| 81 | expect(next.snapshotSequence).toBe(1); | ||
| 82 | }); | 33 | }); |
| 34 | expect(updated.projects[0]?.title).toBe("Updated"); | ||
| 35 | expect(updated.snapshotSequence).toBe(1); | ||
| 83 | 36 | ||
| 84 | it("updates an existing project", () => { | 37 | const removed = applyShellStreamEvent(updated, { |
| 85 | const snapshotWithProject: OrchestrationShellSnapshot = { | 38 | kind: "project.removed", |
| 86 | ...baseSnapshot, | 39 | sequence: 2, |
| 87 | projects: [stubProject], | 40 | projectId: ProjectId.make(v2Project.id), |
| 88 | }; | ||
| 89 | |||
| 90 | const updatedProject = { ...stubProject, title: "Updated Title" }; | ||
| 91 | const event: OrchestrationShellStreamEvent = { | ||
| 92 | kind: "project-upserted", | ||
| 93 | sequence: 2, | ||
| 94 | project: updatedProject, | ||
| 95 | }; | ||
| 96 | |||
| 97 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 98 | |||
| 99 | expect(next.projects).toHaveLength(1); | ||
| 100 | expect(next.projects[0]?.title).toBe("Updated Title"); | ||
| 101 | expect(next.snapshotSequence).toBe(2); | ||
| 102 | }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | describe("project-removed", () => { | ||
| 106 | it("removes a project by id", () => { | ||
| 107 | const snapshotWithProject: OrchestrationShellSnapshot = { | ||
| 108 | ...baseSnapshot, | ||
| 109 | projects: [stubProject], | ||
| 110 | }; | ||
| 111 | |||
| 112 | const event: OrchestrationShellStreamEvent = { | ||
| 113 | kind: "project-removed", | ||
| 114 | sequence: 3, | ||
| 115 | projectId: ProjectId.make("project-1"), | ||
| 116 | }; | ||
| 117 | |||
| 118 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 119 | |||
| 120 | expect(next.projects).toHaveLength(0); | ||
| 121 | expect(next.snapshotSequence).toBe(3); | ||
| 122 | }); | 41 | }); |
| 42 | expect(removed.projects).toEqual([]); | ||
| 123 | }); | 43 | }); |
| 124 | 44 | ||
| 125 | describe("thread-upserted", () => { | 45 | it("moves a thread between active and archive without duplicating it", () => { |
| 126 | it("adds a new thread", () => { | 46 | const archived = applyShellStreamEvent(v2ShellSnapshot, { |
| 127 | const event: OrchestrationShellStreamEvent = { | 47 | kind: "thread.updated", |
| 128 | kind: "thread-upserted", | 48 | sequence: 3, |
| 129 | sequence: 4, | 49 | location: "archive", |
| 130 | thread: stubThread, | 50 | thread: { ...v2ThreadShell, archivedAt: v2ThreadShell.updatedAt }, |
| 131 | }; | ||
| 132 | |||
| 133 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 134 | |||
| 135 | expect(next.threads).toHaveLength(1); | ||
| 136 | expect(next.threads[0]?.id).toBe("thread-1"); | ||
| 137 | expect(next.snapshotSequence).toBe(4); | ||
| 138 | }); | 51 | }); |
| 139 | 52 | expect(archived.threads).toEqual([]); | |
| 140 | it("updates an existing thread", () => { | 53 | expect(archived.archivedThreads).toHaveLength(1); |
| 141 | const snapshotWithThread: OrchestrationShellSnapshot = { | 54 | |
| 142 | ...baseSnapshot, | 55 | const active = applyShellStreamEvent(archived, { |
| 143 | threads: [stubThread], | 56 | kind: "thread.updated", |
| 144 | }; | 57 | sequence: 4, |
| 145 | 58 | location: "active", | |
| 146 | const updatedThread = { ...stubThread, title: "Updated Thread" }; | 59 | thread: v2ThreadShell, |
| 147 | const event: OrchestrationShellStreamEvent = { | ||
| 148 | kind: "thread-upserted", | ||
| 149 | sequence: 5, | ||
| 150 | thread: updatedThread, | ||
| 151 | }; | ||
| 152 | |||
| 153 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 154 | |||
| 155 | expect(next.threads).toHaveLength(1); | ||
| 156 | expect(next.threads[0]?.title).toBe("Updated Thread"); | ||
| 157 | }); | 60 | }); |
| 61 | expect(active.threads).toHaveLength(1); | ||
| 62 | expect(active.archivedThreads).toEqual([]); | ||
| 158 | }); | 63 | }); |
| 159 | 64 | ||
| 160 | describe("thread-removed", () => { | 65 | it("removes a thread from either collection", () => { |
| 161 | it("removes a thread by id", () => { | 66 | const next = applyShellStreamEvent(v2ShellSnapshot, { |
| 162 | const snapshotWithThread: OrchestrationShellSnapshot = { | 67 | kind: "thread.removed", |
| 163 | ...baseSnapshot, | 68 | sequence: 5, |
| 164 | threads: [stubThread], | 69 | location: "active", |
| 165 | }; | 70 | threadId: ThreadId.make(v2ThreadShell.id), |
| 166 | |||
| 167 | const event: OrchestrationShellStreamEvent = { | ||
| 168 | kind: "thread-removed", | ||
| 169 | sequence: 6, | ||
| 170 | threadId: ThreadId.make("thread-1"), | ||
| 171 | }; | ||
| 172 | |||
| 173 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 174 | |||
| 175 | expect(next.threads).toHaveLength(0); | ||
| 176 | expect(next.snapshotSequence).toBe(6); | ||
| 177 | }); | 71 | }); |
| 72 | expect(next.threads).toEqual([]); | ||
| 73 | expect(next.snapshotSequence).toBe(5); | ||
| 178 | }); | 74 | }); |
| 179 | 75 | ||
| 180 | it("returns original snapshot for unrecognized event kinds", () => { | 76 | it("leaves unknown future events unchanged", () => { |
| 181 | const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; | 77 | const next = applyShellStreamEvent( |
| 182 | const next = applyShellStreamEvent(baseSnapshot, unknownEvent); | 78 | v2ShellSnapshot, |
| 183 | expect(next).toBe(baseSnapshot); | 79 | { kind: "future.event", sequence: 99 } as never, |
| 80 | ); | ||
| 81 | |||
| 82 | expect(next).toBe(v2ShellSnapshot); | ||
| 184 | }); | 83 | }); |
| 185 | }); | 84 | }); |
| @@ -1,43 +1,58 @@ | |||
| 1 | import * as Arr from "effect/Array"; | 1 | import type { |
| 2 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | 2 | OrchestrationV2ShellSnapshot, |
| 3 | OrchestrationV2ShellStreamItem, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 3 | 5 | ||
| 4 | /** | 6 | function upsertById<T extends { readonly id: unknown }>( |
| 5 | * Reduce a single shell stream event into an existing snapshot, returning a new | 7 | items: ReadonlyArray<T>, |
| 6 | * snapshot with the event's changes applied. This is a pure reducer that both | 8 | item: T, |
| 7 | * web and mobile can use to keep their local shell snapshot in sync. | 9 | ): ReadonlyArray<T> { |
| 8 | * | 10 | const index = items.findIndex((candidate) => candidate.id === item.id); |
| 9 | * Returns the original snapshot reference unchanged if the event is not | 11 | if (index === -1) return [...items, item]; |
| 10 | * recognized (forward-compatible). | 12 | return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate)); |
| 11 | */ | 13 | } |
| 14 | |||
| 15 | /** Applies one committed V2 shell delta while preserving active/archive exclusivity. */ | ||
| 12 | export function applyShellStreamEvent( | 16 | export function applyShellStreamEvent( |
| 13 | snapshot: OrchestrationShellSnapshot, | 17 | snapshot: OrchestrationV2ShellSnapshot, |
| 14 | event: OrchestrationShellStreamEvent, | 18 | event: Exclude<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }>, |
| 15 | ): OrchestrationShellSnapshot { | 19 | ): OrchestrationV2ShellSnapshot { |
| 16 | if (event.sequence <= snapshot.snapshotSequence) return snapshot; | 20 | if (event.sequence <= snapshot.snapshotSequence) return snapshot; |
| 17 | 21 | ||
| 18 | switch (event.kind) { | 22 | switch (event.kind) { |
| 19 | case "project-upserted": { | 23 | case "project.updated": |
| 20 | const projects = snapshot.projects.some((p) => p.id === event.project.id) | 24 | return { |
| 21 | ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p)) | 25 | ...snapshot, |
| 22 | : Arr.append(snapshot.projects, event.project); | 26 | projects: upsertById(snapshot.projects, event.project), |
| 23 | return { ...snapshot, projects, snapshotSequence: event.sequence }; | 27 | snapshotSequence: event.sequence, |
| 24 | } | 28 | }; |
| 25 | case "project-removed": | 29 | case "project.removed": |
| 30 | return { | ||
| 31 | ...snapshot, | ||
| 32 | projects: snapshot.projects.filter((project) => project.id !== event.projectId), | ||
| 33 | snapshotSequence: event.sequence, | ||
| 34 | }; | ||
| 35 | case "thread.updated": { | ||
| 36 | const withoutThread = (threads: OrchestrationV2ShellSnapshot["threads"]) => | ||
| 37 | threads.filter((thread) => thread.id !== event.thread.id); | ||
| 26 | return { | 38 | return { |
| 27 | ...snapshot, | 39 | ...snapshot, |
| 28 | projects: Arr.filter(snapshot.projects, (p) => p.id !== event.projectId), | 40 | threads: |
| 41 | event.location === "active" | ||
| 42 | ? upsertById(withoutThread(snapshot.threads), event.thread) | ||
| 43 | : withoutThread(snapshot.threads), | ||
| 44 | archivedThreads: | ||
| 45 | event.location === "archive" | ||
| 46 | ? upsertById(withoutThread(snapshot.archivedThreads), event.thread) | ||
| 47 | : withoutThread(snapshot.archivedThreads), | ||
| 29 | snapshotSequence: event.sequence, | 48 | snapshotSequence: event.sequence, |
| 30 | }; | 49 | }; |
| 31 | case "thread-upserted": { | ||
| 32 | const threads = snapshot.threads.some((t) => t.id === event.thread.id) | ||
| 33 | ? Arr.map(snapshot.threads, (t) => (t.id === event.thread.id ? event.thread : t)) | ||
| 34 | : Arr.append(snapshot.threads, event.thread); | ||
| 35 | return { ...snapshot, threads, snapshotSequence: event.sequence }; | ||
| 36 | } | 50 | } |
| 37 | case "thread-removed": | 51 | case "thread.removed": |
| 38 | return { | 52 | return { |
| 39 | ...snapshot, | 53 | ...snapshot, |
| 40 | threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId), | 54 | threads: snapshot.threads.filter((thread) => thread.id !== event.threadId), |
| 55 | archivedThreads: snapshot.archivedThreads.filter((thread) => thread.id !== event.threadId), | ||
| 41 | snapshotSequence: event.sequence, | 56 | snapshotSequence: event.sequence, |
| 42 | }; | 57 | }; |
| 43 | default: | 58 | default: |
| @@ -1,4 +1,4 @@ | |||
| 1 | import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | ||
| @@ -10,7 +10,7 @@ export function createEnvironmentSnapshotAtom<E>( · 5 unmodified lines | |||
| 10 | ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>, | 10 | ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>, |
| 11 | ) { | 11 | ) { |
| 12 | return Atom.family((environmentId: EnvironmentId) => | 12 | return Atom.family((environmentId: EnvironmentId) => |
| 13 | Atom.make((get): OrchestrationShellSnapshot | null => | 13 | Atom.make((get): OrchestrationV2ShellSnapshot | null => |
| 14 | Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), { | 14 | Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), { |
| 15 | onNone: () => null, | 15 | onNone: () => null, |
| 16 | onSome: (state) => Option.getOrNull(state.snapshot), | 16 | onSome: (state) => Option.getOrNull(state.snapshot), |
| @@ -54,6 +54,7 @@ export type { · 53 unmodified lines | |||
| 54 | SnoozeThreadInput, | 54 | SnoozeThreadInput, |
| 55 | StartThreadTurnInput, | 55 | StartThreadTurnInput, |
| 56 | StopThreadSessionInput, | 56 | StopThreadSessionInput, |
| 57 | ThreadCommandInput, | ||
| 57 | UnarchiveThreadInput, | 58 | UnarchiveThreadInput, |
| 58 | UnsettleThreadInput, | 59 | UnsettleThreadInput, |
| 59 | UnsnoozeThreadInput, | 60 | UnsnoozeThreadInput, |
| @@ -0,0 +1,45 @@ | |||
| 1 | import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { v2Projection } from "./orchestrationV2TestFixtures.ts"; | ||
| 7 | import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; | ||
| 8 | import type { EnvironmentThreadState } from "./threads.ts"; | ||
| 9 | |||
| 10 | const ref: ScopedThreadRef = { | ||
| 11 | environmentId: EnvironmentId.make("environment-detail"), | ||
| 12 | threadId: ThreadId.make(v2Projection.thread.id), | ||
| 13 | }; | ||
| 14 | |||
| 15 | describe("createEnvironmentThreadDetailAtoms", () => { | ||
| 16 | it("adds environment scope while preserving the pristine projection", () => { | ||
| 17 | const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success({ | ||
| 18 | data: Option.some(v2Projection), | ||
| 19 | status: "cached", | ||
| 20 | error: Option.none(), | ||
| 21 | }); | ||
| 22 | const sourceAtom = Atom.make(initial); | ||
| 23 | const details = createEnvironmentThreadDetailAtoms(() => sourceAtom); | ||
| 24 | const registry = AtomRegistry.make(); | ||
| 25 | |||
| 26 | const thread = registry.get(details.threadAtom(ref)); | ||
| 27 | expect(thread).toEqual({ environmentId: ref.environmentId, projection: v2Projection }); | ||
| 28 | expect(thread?.projection).toBe(v2Projection); | ||
| 29 | expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems); | ||
| 30 | expect(registry.get(details.statusAtom(ref))).toBe("cached"); | ||
| 31 | |||
| 32 | registry.set( | ||
| 33 | sourceAtom, | ||
| 34 | AsyncResult.success({ | ||
| 35 | data: Option.some(v2Projection), | ||
| 36 | status: "synchronizing", | ||
| 37 | error: Option.none(), | ||
| 38 | }), | ||
| 39 | ); | ||
| 40 | |||
| 41 | expect(registry.get(details.threadAtom(ref))).toBe(thread); | ||
| 42 | expect(registry.get(details.statusAtom(ref))).toBe("synchronizing"); | ||
| 43 | registry.dispose(); | ||
| 44 | }); | ||
| 45 | }); | ||
| @@ -1,67 +1,48 @@ | |||
| 1 | import type { | 1 | import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | OrchestrationCheckpointSummary, | ||
| 3 | OrchestrationLatestTurn, | ||
| 4 | OrchestrationMessage, | ||
| 5 | OrchestrationProposedPlan, | ||
| 6 | OrchestrationSession, | ||
| 7 | OrchestrationThread, | ||
| 8 | OrchestrationThreadActivity, | ||
| 9 | ScopedThreadRef, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 13 | 4 | ||
| 14 | import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; | 5 | import type { |
| 15 | import { scopeThread } from "./models.ts"; | 6 | EnvironmentThread, |
| 16 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts"; | 7 | EnvironmentThreadShell, |
| 8 | ThreadCheckpointSummary, | ||
| 9 | ThreadConversationMessage, | ||
| 10 | ThreadPendingApproval, | ||
| 11 | ThreadPendingUserInput, | ||
| 12 | ThreadProposedPlan, | ||
| 13 | ThreadRunSummary, | ||
| 14 | ThreadRuntimeSummary, | ||
| 15 | ScopedThreadProjection, | ||
| 16 | ThreadWorkEntry, | ||
| 17 | } from "./models.ts"; | ||
| 18 | import { presentThread } from "./models.ts"; | ||
| 19 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; | ||
| 17 | import { parseThreadKey, threadKey } from "./entities.ts"; | 20 | import { parseThreadKey, threadKey } from "./entities.ts"; |
| 18 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; | 21 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 19 | 22 | ||
| 20 | const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]); | 23 | const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]); |
| 21 | const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]); | 24 | const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]); |
| 22 | const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]); | 25 | const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = Object.freeze([]); |
| 23 | const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]); | 26 | const EMPTY_CHECKPOINTS: ReadonlyArray<ThreadCheckpointSummary> = Object.freeze([]); |
| 27 | const EMPTY_APPROVALS: ReadonlyArray<ThreadPendingApproval> = Object.freeze([]); | ||
| 28 | const EMPTY_USER_INPUTS: ReadonlyArray<ThreadPendingUserInput> = Object.freeze([]); | ||
| 29 | const EMPTY_VISIBLE_TURN_ITEMS: OrchestrationV2ThreadProjection["visibleTurnItems"] = Object.freeze( | ||
| 30 | [], | ||
| 31 | ); | ||
| 24 | 32 | ||
| 25 | /** | 33 | /** Shell metadata wins over an independently cached detail projection. */ |
| 26 | * Combine detail-only collections with the shell's authoritative thread metadata. | ||
| 27 | * | ||
| 28 | * Shell and detail subscriptions are intentionally independent. A cached detail can | ||
| 29 | * therefore briefly outlive a newer shell snapshot after reconnecting. Workspace | ||
| 30 | * consumers must use the shell branch/worktree/project fields so they do not target | ||
| 31 | * a stale checkout while retaining messages, activities, plans, and checkpoints | ||
| 32 | * from the detail subscription. | ||
| 33 | */ | ||
| 34 | export function mergeEnvironmentThread( | 34 | export function mergeEnvironmentThread( |
| 35 | detail: EnvironmentThread | null, | 35 | detail: EnvironmentThread | null, |
| 36 | shell: EnvironmentThreadShell | null, | 36 | shell: EnvironmentThreadShell | null, |
| 37 | ): EnvironmentThread | null { | 37 | ): EnvironmentThread | null { |
| 38 | if (detail === null || shell === null) { | 38 | if (detail === null || shell === null) return detail; |
| 39 | return detail; | 39 | if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) return detail; |
| 40 | } | ||
| 41 | if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) { | ||
| 42 | return detail; | ||
| 43 | } | ||
| 44 | |||
| 45 | return { | 40 | return { |
| 46 | ...detail, | 41 | ...detail, |
| 47 | environmentId: shell.environmentId, | 42 | ...shell, |
| 48 | id: shell.id, | 43 | latestRun: detail.latestRun, |
| 49 | projectId: shell.projectId, | 44 | runtime: detail.runtime, |
| 50 | title: shell.title, | 45 | source: shell.source, |
| 51 | modelSelection: shell.modelSelection, | ||
| 52 | runtimeMode: shell.runtimeMode, | ||
| 53 | interactionMode: shell.interactionMode, | ||
| 54 | branch: shell.branch, | ||
| 55 | worktreePath: shell.worktreePath, | ||
| 56 | latestTurn: shell.latestTurn, | ||
| 57 | createdAt: shell.createdAt, | ||
| 58 | updatedAt: shell.updatedAt, | ||
| 59 | archivedAt: shell.archivedAt, | ||
| 60 | settledOverride: shell.settledOverride, | ||
| 61 | settledAt: shell.settledAt, | ||
| 62 | snoozedUntil: shell.snoozedUntil, | ||
| 63 | snoozedAt: shell.snoozedAt, | ||
| 64 | session: shell.session, | ||
| 65 | }; | 46 | }; |
| 66 | } | 47 | } |
| 67 | 48 | ||
| @@ -86,15 +67,13 @@ export function createEnvironmentThreadDetailAtoms<E>( · 18 unmodified lines | |||
| 86 | 67 | ||
| 87 | const threadDetailAtomFamily = Atom.family((key: string) => { | 68 | const threadDetailAtomFamily = Atom.family((key: string) => { |
| 88 | const ref = parseThreadKey(key); | 69 | const ref = parseThreadKey(key); |
| 89 | let previousSource: OrchestrationThread | null = null; | 70 | let previousSource: OrchestrationV2ThreadProjection | null = null; |
| 90 | let previousValue: EnvironmentThread | null = null; | 71 | let previousValue: EnvironmentThread | null = null; |
| 91 | return Atom.make((get) => { | 72 | return Atom.make((get) => { |
| 92 | const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); | 73 | const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); |
| 93 | if (source === previousSource) { | 74 | if (source === previousSource) return previousValue; |
| 94 | return previousValue; | ||
| 95 | } | ||
| 96 | previousSource = source; | 75 | previousSource = source; |
| 97 | previousValue = source === null ? null : scopeThread(ref.environmentId, source); | 76 | previousValue = source === null ? null : presentThread(ref.environmentId, source); |
| 98 | return previousValue; | 77 | return previousValue; |
| 99 | }).pipe( | 78 | }).pipe( |
| 100 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 79 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), |
| @@ -102,88 +81,96 @@ export function createEnvironmentThreadDetailAtoms<E>( · 1 unmodified line | |||
| 102 | ); | 81 | ); |
| 103 | }); | 82 | }); |
| 104 | 83 | ||
| 105 | const threadStatusAtomFamily = Atom.family((key: string) => | 84 | const scopedThreadProjectionAtomFamily = Atom.family((key: string) => { |
| 106 | Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( | 85 | const ref = parseThreadKey(key); |
| 107 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 86 | let previousProjection: OrchestrationV2ThreadProjection | null = null; |
| 108 | Atom.withLabel(`environment-thread-status:${key}`), | 87 | let previousValue: ScopedThreadProjection | null = null; |
| 109 | ), | 88 | return Atom.make((get) => { |
| 110 | ); | 89 | const projection = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); |
| 111 | 90 | if (projection === previousProjection) return previousValue; | |
| 112 | const threadErrorAtomFamily = Atom.family((key: string) => | 91 | previousProjection = projection; |
| 113 | Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( | 92 | previousValue = projection === null ? null : { environmentId: ref.environmentId, projection }; |
| 93 | return previousValue; | ||
| 94 | }).pipe( | ||
| 114 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 95 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), |
| 115 | Atom.withLabel(`environment-thread-error:${key}`), | 96 | Atom.withLabel(`scoped-thread-projection:${key}`), |
| 116 | ), | 97 | ); |
| 117 | ); | 98 | }); |
| 118 | 99 | ||
| 119 | const threadMessagesAtomFamily = Atom.family((key: string) => | 100 | const visibleTurnItemsAtomFamily = Atom.family((key: string) => |
| 120 | Atom.make( | 101 | Atom.make( |
| 121 | (get): ReadonlyArray<OrchestrationMessage> => | 102 | (get): OrchestrationV2ThreadProjection["visibleTurnItems"] => |
| 122 | get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES, | 103 | Option.getOrNull(get(threadStateValueAtomFamily(key)).data)?.visibleTurnItems ?? |
| 104 | EMPTY_VISIBLE_TURN_ITEMS, | ||
| 123 | ).pipe( | 105 | ).pipe( |
| 124 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 106 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), |
| 125 | Atom.withLabel(`environment-thread-messages:${key}`), | 107 | Atom.withLabel(`environment-thread-visible-turn-items:${key}`), |
| 126 | ), | 108 | ), |
| 127 | ); | 109 | ); |
| 128 | 110 | ||
| 129 | const threadActivitiesAtomFamily = Atom.family((key: string) => | 111 | const family = <A>(label: string, select: (thread: EnvironmentThread) => A, empty: A) => |
| 130 | Atom.make( | 112 | Atom.family((key: string) => |
| 131 | (get): ReadonlyArray<OrchestrationThreadActivity> => | 113 | Atom.make((get): A => { |
| 132 | get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES, | 114 | const thread = get(threadDetailAtomFamily(key)); |
| 133 | ).pipe( | 115 | return thread === null ? empty : select(thread); |
| 134 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 116 | }).pipe( |
| 135 | Atom.withLabel(`environment-thread-activities:${key}`), | 117 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), |
| 136 | ), | 118 | Atom.withLabel(`environment-thread-${label}:${key}`), |
| 137 | ); | 119 | ), |
| 120 | ); | ||
| 138 | 121 | ||
| 139 | const threadProposedPlansAtomFamily = Atom.family((key: string) => | 122 | const messages = family("messages", (thread) => thread.messages, EMPTY_MESSAGES); |
| 140 | Atom.make( | 123 | const workEntries = family("work-entries", (thread) => thread.workEntries, EMPTY_WORK_ENTRIES); |
| 141 | (get): ReadonlyArray<OrchestrationProposedPlan> => | 124 | const proposedPlans = family( |
| 142 | get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS, | 125 | "proposed-plans", |
| 143 | ).pipe( | 126 | (thread) => thread.proposedPlans, |
| 144 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 127 | EMPTY_PROPOSED_PLANS, |
| 145 | Atom.withLabel(`environment-thread-proposed-plans:${key}`), | ||
| 146 | ), | ||
| 147 | ); | 128 | ); |
| 148 | 129 | const checkpoints = family("checkpoints", (thread) => thread.checkpoints, EMPTY_CHECKPOINTS); | |
| 149 | const threadCheckpointsAtomFamily = Atom.family((key: string) => | 130 | const pendingApprovals = family( |
| 150 | Atom.make( | 131 | "pending-approvals", |
| 151 | (get): ReadonlyArray<OrchestrationCheckpointSummary> => | 132 | (thread) => thread.pendingApprovals, |
| 152 | get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS, | 133 | EMPTY_APPROVALS, |
| 153 | ).pipe( | 134 | ); |
| 154 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 135 | const pendingUserInputs = family( |
| 155 | Atom.withLabel(`environment-thread-checkpoints:${key}`), | 136 | "pending-user-inputs", |
| 156 | ), | 137 | (thread) => thread.pendingUserInputs, |
| 138 | EMPTY_USER_INPUTS, | ||
| 139 | ); | ||
| 140 | const runtime = family<ThreadRuntimeSummary | null>("runtime", (thread) => thread.runtime, null); | ||
| 141 | const latestRun = family<ThreadRunSummary | null>( | ||
| 142 | "latest-run", | ||
| 143 | (thread) => thread.latestRun, | ||
| 144 | null, | ||
| 157 | ); | 145 | ); |
| 158 | 146 | ||
| 159 | const threadSessionAtomFamily = Atom.family((key: string) => | 147 | const status = Atom.family((key: string) => |
| 160 | Atom.make( | 148 | Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( |
| 161 | (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null, | ||
| 162 | ).pipe( | ||
| 163 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 149 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), |
| 164 | Atom.withLabel(`environment-thread-session:${key}`), | 150 | Atom.withLabel(`environment-thread-status:${key}`), |
| 165 | ), | 151 | ), |
| 166 | ); | 152 | ); |
| 167 | 153 | const error = Atom.family((key: string) => | |
| 168 | const threadLatestTurnAtomFamily = Atom.family((key: string) => | 154 | Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( |
| 169 | Atom.make( | ||
| 170 | (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null, | ||
| 171 | ).pipe( | ||
| 172 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | 155 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), |
| 173 | Atom.withLabel(`environment-thread-latest-turn:${key}`), | 156 | Atom.withLabel(`environment-thread-error:${key}`), |
| 174 | ), | 157 | ), |
| 175 | ); | 158 | ); |
| 176 | 159 | ||
| 177 | return { | 160 | return { |
| 178 | stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)), | 161 | stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)), |
| 162 | threadAtom: (ref: ScopedThreadRef) => scopedThreadProjectionAtomFamily(threadKey(ref)), | ||
| 163 | visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)), | ||
| 179 | detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)), | 164 | detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)), |
| 180 | statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)), | 165 | statusAtom: (ref: ScopedThreadRef) => status(threadKey(ref)), |
| 181 | errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)), | 166 | errorAtom: (ref: ScopedThreadRef) => error(threadKey(ref)), |
| 182 | messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)), | 167 | messagesAtom: (ref: ScopedThreadRef) => messages(threadKey(ref)), |
| 183 | activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)), | 168 | workEntriesAtom: (ref: ScopedThreadRef) => workEntries(threadKey(ref)), |
| 184 | proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)), | 169 | proposedPlansAtom: (ref: ScopedThreadRef) => proposedPlans(threadKey(ref)), |
| 185 | checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)), | 170 | checkpointsAtom: (ref: ScopedThreadRef) => checkpoints(threadKey(ref)), |
| 186 | sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)), | 171 | pendingApprovalsAtom: (ref: ScopedThreadRef) => pendingApprovals(threadKey(ref)), |
| 187 | latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)), | 172 | pendingUserInputsAtom: (ref: ScopedThreadRef) => pendingUserInputs(threadKey(ref)), |
| 173 | runtimeAtom: (ref: ScopedThreadRef) => runtime(threadKey(ref)), | ||
| 174 | latestRunAtom: (ref: ScopedThreadRef) => latestRun(threadKey(ref)), | ||
| 188 | }; | 175 | }; |
| 189 | } | 176 | } |
| @@ -1,767 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CheckpointRef, | ||
| 5 | CommandId, | ||
| 6 | EventId, | ||
| 7 | MessageId, | ||
| 8 | ProjectId, | ||
| 9 | ProviderInstanceId, | ||
| 10 | ThreadId, | ||
| 11 | TurnId, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import type { OrchestrationThread } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | import { applyThreadDetailEvent } from "./threadReducer.ts"; | ||
| 16 | |||
| 17 | const baseEventFields = { | ||
| 18 | eventId: EventId.make("event-1"), | ||
| 19 | commandId: null, | ||
| 20 | causationEventId: null, | ||
| 21 | correlationId: null, | ||
| 22 | metadata: {}, | ||
| 23 | } as const; | ||
| 24 | |||
| 25 | const baseThread: OrchestrationThread = { | ||
| 26 | id: ThreadId.make("thread-1"), | ||
| 27 | projectId: ProjectId.make("project-1"), | ||
| 28 | title: "Test Thread", | ||
| 29 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 30 | runtimeMode: "full-access", | ||
| 31 | interactionMode: "default", | ||
| 32 | branch: null, | ||
| 33 | worktreePath: null, | ||
| 34 | latestTurn: null, | ||
| 35 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 36 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 37 | archivedAt: null, | ||
| 38 | settledOverride: null, | ||
| 39 | settledAt: null, | ||
| 40 | deletedAt: null, | ||
| 41 | messages: [], | ||
| 42 | proposedPlans: [], | ||
| 43 | activities: [], | ||
| 44 | checkpoints: [], | ||
| 45 | session: null, | ||
| 46 | }; | ||
| 47 | |||
| 48 | describe("applyThreadDetailEvent", () => { | ||
| 49 | describe("project events", () => { | ||
| 50 | it("returns unchanged for project.created", () => { | ||
| 51 | const result = applyThreadDetailEvent(baseThread, { | ||
| 52 | ...baseEventFields, | ||
| 53 | sequence: 1, | ||
| 54 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 55 | aggregateKind: "project", | ||
| 56 | aggregateId: ProjectId.make("project-1"), | ||
| 57 | type: "project.created", | ||
| 58 | payload: { | ||
| 59 | projectId: ProjectId.make("project-1"), | ||
| 60 | title: "T3 Code", | ||
| 61 | workspaceRoot: "/repo", | ||
| 62 | repositoryIdentity: null, | ||
| 63 | defaultModelSelection: null, | ||
| 64 | scripts: [], | ||
| 65 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 66 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 67 | deletedAt: null, | ||
| 68 | }, | ||
| 69 | } as any); | ||
| 70 | expect(result.kind).toBe("unchanged"); | ||
| 71 | }); | ||
| 72 | }); | ||
| 73 | |||
| 74 | describe("thread.created", () => { | ||
| 75 | it("creates a fresh thread", () => { | ||
| 76 | const result = applyThreadDetailEvent(baseThread, { | ||
| 77 | ...baseEventFields, | ||
| 78 | sequence: 1, | ||
| 79 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 80 | aggregateKind: "thread", | ||
| 81 | aggregateId: ThreadId.make("thread-2"), | ||
| 82 | type: "thread.created", | ||
| 83 | payload: { | ||
| 84 | threadId: ThreadId.make("thread-2"), | ||
| 85 | projectId: ProjectId.make("project-1"), | ||
| 86 | title: "New Thread", | ||
| 87 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 88 | runtimeMode: "full-access", | ||
| 89 | interactionMode: "default", | ||
| 90 | branch: "main", | ||
| 91 | worktreePath: null, | ||
| 92 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 93 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 94 | }, | ||
| 95 | }); | ||
| 96 | |||
| 97 | expect(result.kind).toBe("updated"); | ||
| 98 | if (result.kind === "updated") { | ||
| 99 | expect(result.thread.id).toBe("thread-2"); | ||
| 100 | expect(result.thread.title).toBe("New Thread"); | ||
| 101 | expect(result.thread.branch).toBe("main"); | ||
| 102 | expect(result.thread.messages).toEqual([]); | ||
| 103 | expect(result.thread.session).toBeNull(); | ||
| 104 | } | ||
| 105 | }); | ||
| 106 | }); | ||
| 107 | |||
| 108 | describe("thread.deleted", () => { | ||
| 109 | it("returns deleted signal", () => { | ||
| 110 | const result = applyThreadDetailEvent(baseThread, { | ||
| 111 | ...baseEventFields, | ||
| 112 | sequence: 2, | ||
| 113 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 114 | aggregateKind: "thread", | ||
| 115 | aggregateId: ThreadId.make("thread-1"), | ||
| 116 | type: "thread.deleted", | ||
| 117 | payload: { | ||
| 118 | threadId: ThreadId.make("thread-1"), | ||
| 119 | deletedAt: "2026-04-01T02:00:00.000Z", | ||
| 120 | }, | ||
| 121 | }); | ||
| 122 | expect(result.kind).toBe("deleted"); | ||
| 123 | }); | ||
| 124 | }); | ||
| 125 | |||
| 126 | describe("thread.archived / thread.unarchived", () => { | ||
| 127 | it("sets archivedAt and clears title regeneration", () => { | ||
| 128 | const regeneratingThread: OrchestrationThread = { | ||
| 129 | ...baseThread, | ||
| 130 | titleRegeneration: { | ||
| 131 | requestId: CommandId.make("regenerate-title"), | ||
| 132 | startedAt: "2026-04-01T02:00:00.000Z", | ||
| 133 | }, | ||
| 134 | }; | ||
| 135 | const result = applyThreadDetailEvent(regeneratingThread, { | ||
| 136 | ...baseEventFields, | ||
| 137 | sequence: 3, | ||
| 138 | occurredAt: "2026-04-01T03:00:00.000Z", | ||
| 139 | aggregateKind: "thread", | ||
| 140 | aggregateId: ThreadId.make("thread-1"), | ||
| 141 | type: "thread.archived", | ||
| 142 | payload: { | ||
| 143 | threadId: ThreadId.make("thread-1"), | ||
| 144 | archivedAt: "2026-04-01T03:00:00.000Z", | ||
| 145 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 146 | }, | ||
| 147 | }); | ||
| 148 | |||
| 149 | expect(result.kind).toBe("updated"); | ||
| 150 | if (result.kind === "updated") { | ||
| 151 | expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z"); | ||
| 152 | expect(result.thread.titleRegeneration).toBeNull(); | ||
| 153 | } | ||
| 154 | }); | ||
| 155 | |||
| 156 | it("clears archivedAt", () => { | ||
| 157 | const archivedThread = { ...baseThread, archivedAt: "2026-04-01T03:00:00.000Z" }; | ||
| 158 | const result = applyThreadDetailEvent(archivedThread, { | ||
| 159 | ...baseEventFields, | ||
| 160 | sequence: 4, | ||
| 161 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 162 | aggregateKind: "thread", | ||
| 163 | aggregateId: ThreadId.make("thread-1"), | ||
| 164 | type: "thread.unarchived", | ||
| 165 | payload: { | ||
| 166 | threadId: ThreadId.make("thread-1"), | ||
| 167 | updatedAt: "2026-04-01T04:00:00.000Z", | ||
| 168 | }, | ||
| 169 | }); | ||
| 170 | |||
| 171 | expect(result.kind).toBe("updated"); | ||
| 172 | if (result.kind === "updated") { | ||
| 173 | expect(result.thread.archivedAt).toBeNull(); | ||
| 174 | } | ||
| 175 | }); | ||
| 176 | }); | ||
| 177 | |||
| 178 | describe("thread.settled / thread.unsettled", () => { | ||
| 179 | it("sets the settled override and timestamp", () => { | ||
| 180 | const settledAt = "2026-04-01T05:00:00.000Z"; | ||
| 181 | const result = applyThreadDetailEvent(baseThread, { | ||
| 182 | ...baseEventFields, | ||
| 183 | sequence: 5, | ||
| 184 | occurredAt: settledAt, | ||
| 185 | aggregateKind: "thread", | ||
| 186 | aggregateId: ThreadId.make("thread-1"), | ||
| 187 | type: "thread.settled", | ||
| 188 | payload: { | ||
| 189 | threadId: ThreadId.make("thread-1"), | ||
| 190 | settledAt, | ||
| 191 | updatedAt: settledAt, | ||
| 192 | }, | ||
| 193 | }); | ||
| 194 | |||
| 195 | expect(result.kind).toBe("updated"); | ||
| 196 | if (result.kind === "updated") { | ||
| 197 | expect(result.thread.settledOverride).toBe("settled"); | ||
| 198 | expect(result.thread.settledAt).toBe(settledAt); | ||
| 199 | } | ||
| 200 | }); | ||
| 201 | |||
| 202 | it.each([ | ||
| 203 | ["user", "active"], | ||
| 204 | ["activity", null], | ||
| 205 | ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { | ||
| 206 | const settledThread: OrchestrationThread = { | ||
| 207 | ...baseThread, | ||
| 208 | settledOverride: "settled", | ||
| 209 | settledAt: "2026-04-01T05:00:00.000Z", | ||
| 210 | }; | ||
| 211 | const updatedAt = "2026-04-01T06:00:00.000Z"; | ||
| 212 | const result = applyThreadDetailEvent(settledThread, { | ||
| 213 | ...baseEventFields, | ||
| 214 | sequence: 6, | ||
| 215 | occurredAt: updatedAt, | ||
| 216 | aggregateKind: "thread", | ||
| 217 | aggregateId: ThreadId.make("thread-1"), | ||
| 218 | type: "thread.unsettled", | ||
| 219 | payload: { | ||
| 220 | threadId: ThreadId.make("thread-1"), | ||
| 221 | reason, | ||
| 222 | updatedAt, | ||
| 223 | }, | ||
| 224 | }); | ||
| 225 | |||
| 226 | expect(result.kind).toBe("updated"); | ||
| 227 | if (result.kind === "updated") { | ||
| 228 | expect(result.thread.settledOverride).toBe(settledOverride); | ||
| 229 | expect(result.thread.settledAt).toBeNull(); | ||
| 230 | } | ||
| 231 | }); | ||
| 232 | }); | ||
| 233 | |||
| 234 | describe("thread.meta-updated", () => { | ||
| 235 | it("patches title and branch", () => { | ||
| 236 | const result = applyThreadDetailEvent(baseThread, { | ||
| 237 | ...baseEventFields, | ||
| 238 | sequence: 5, | ||
| 239 | occurredAt: "2026-04-01T05:00:00.000Z", | ||
| 240 | aggregateKind: "thread", | ||
| 241 | aggregateId: ThreadId.make("thread-1"), | ||
| 242 | type: "thread.meta-updated", | ||
| 243 | payload: { | ||
| 244 | threadId: ThreadId.make("thread-1"), | ||
| 245 | title: "Updated Title", | ||
| 246 | branch: "feature/demo", | ||
| 247 | updatedAt: "2026-04-01T05:00:00.000Z", | ||
| 248 | }, | ||
| 249 | }); | ||
| 250 | |||
| 251 | expect(result.kind).toBe("updated"); | ||
| 252 | if (result.kind === "updated") { | ||
| 253 | expect(result.thread.title).toBe("Updated Title"); | ||
| 254 | expect(result.thread.branch).toBe("feature/demo"); | ||
| 255 | // Model selection should be unchanged since it wasn't in the payload | ||
| 256 | expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); | ||
| 257 | } | ||
| 258 | }); | ||
| 259 | }); | ||
| 260 | |||
| 261 | describe("thread.message-sent", () => { | ||
| 262 | it("appends a new message", () => { | ||
| 263 | const result = applyThreadDetailEvent(baseThread, { | ||
| 264 | ...baseEventFields, | ||
| 265 | sequence: 6, | ||
| 266 | occurredAt: "2026-04-01T06:00:00.000Z", | ||
| 267 | aggregateKind: "thread", | ||
| 268 | aggregateId: ThreadId.make("thread-1"), | ||
| 269 | type: "thread.message-sent", | ||
| 270 | payload: { | ||
| 271 | threadId: ThreadId.make("thread-1"), | ||
| 272 | messageId: MessageId.make("msg-1"), | ||
| 273 | role: "user", | ||
| 274 | text: "Hello, world!", | ||
| 275 | turnId: null, | ||
| 276 | streaming: false, | ||
| 277 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 278 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 279 | }, | ||
| 280 | }); | ||
| 281 | |||
| 282 | expect(result.kind).toBe("updated"); | ||
| 283 | if (result.kind === "updated") { | ||
| 284 | expect(result.thread.messages).toHaveLength(1); | ||
| 285 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 286 | } | ||
| 287 | }); | ||
| 288 | |||
| 289 | it("appends text for streaming messages", () => { | ||
| 290 | const threadWithMessage: OrchestrationThread = { | ||
| 291 | ...baseThread, | ||
| 292 | messages: [ | ||
| 293 | { | ||
| 294 | id: MessageId.make("msg-2"), | ||
| 295 | role: "assistant", | ||
| 296 | text: "Hello", | ||
| 297 | turnId: TurnId.make("turn-1"), | ||
| 298 | streaming: true, | ||
| 299 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 300 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 301 | }, | ||
| 302 | ], | ||
| 303 | }; | ||
| 304 | |||
| 305 | const result = applyThreadDetailEvent(threadWithMessage, { | ||
| 306 | ...baseEventFields, | ||
| 307 | sequence: 7, | ||
| 308 | occurredAt: "2026-04-01T06:01:00.000Z", | ||
| 309 | aggregateKind: "thread", | ||
| 310 | aggregateId: ThreadId.make("thread-1"), | ||
| 311 | type: "thread.message-sent", | ||
| 312 | payload: { | ||
| 313 | threadId: ThreadId.make("thread-1"), | ||
| 314 | messageId: MessageId.make("msg-2"), | ||
| 315 | role: "assistant", | ||
| 316 | text: ", world!", | ||
| 317 | turnId: TurnId.make("turn-1"), | ||
| 318 | streaming: true, | ||
| 319 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 320 | updatedAt: "2026-04-01T06:01:00.000Z", | ||
| 321 | }, | ||
| 322 | }); | ||
| 323 | |||
| 324 | expect(result.kind).toBe("updated"); | ||
| 325 | if (result.kind === "updated") { | ||
| 326 | expect(result.thread.messages).toHaveLength(1); | ||
| 327 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 328 | } | ||
| 329 | }); | ||
| 330 | |||
| 331 | it("updates latestTurn for assistant messages with a turn", () => { | ||
| 332 | const result = applyThreadDetailEvent(baseThread, { | ||
| 333 | ...baseEventFields, | ||
| 334 | sequence: 8, | ||
| 335 | occurredAt: "2026-04-01T07:00:00.000Z", | ||
| 336 | aggregateKind: "thread", | ||
| 337 | aggregateId: ThreadId.make("thread-1"), | ||
| 338 | type: "thread.message-sent", | ||
| 339 | payload: { | ||
| 340 | threadId: ThreadId.make("thread-1"), | ||
| 341 | messageId: MessageId.make("msg-3"), | ||
| 342 | role: "assistant", | ||
| 343 | text: "Done.", | ||
| 344 | turnId: TurnId.make("turn-1"), | ||
| 345 | streaming: false, | ||
| 346 | createdAt: "2026-04-01T07:00:00.000Z", | ||
| 347 | updatedAt: "2026-04-01T07:00:00.000Z", | ||
| 348 | }, | ||
| 349 | }); | ||
| 350 | |||
| 351 | expect(result.kind).toBe("updated"); | ||
| 352 | if (result.kind === "updated") { | ||
| 353 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 354 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 355 | expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3"); | ||
| 356 | } | ||
| 357 | }); | ||
| 358 | |||
| 359 | it("keeps latestTurn running for interim assistant messages while the session runs the turn", () => { | ||
| 360 | const threadWithRunningSession: OrchestrationThread = { | ||
| 361 | ...baseThread, | ||
| 362 | session: { | ||
| 363 | threadId: ThreadId.make("thread-1"), | ||
| 364 | status: "running", | ||
| 365 | providerName: "claude", | ||
| 366 | runtimeMode: "full-access", | ||
| 367 | activeTurnId: TurnId.make("turn-1"), | ||
| 368 | lastError: null, | ||
| 369 | updatedAt: "2026-04-01T06:59:00.000Z", | ||
| 370 | }, | ||
| 371 | latestTurn: { | ||
| 372 | turnId: TurnId.make("turn-1"), | ||
| 373 | state: "running", | ||
| 374 | requestedAt: "2026-04-01T06:59:00.000Z", | ||
| 375 | startedAt: "2026-04-01T06:59:00.000Z", | ||
| 376 | completedAt: null, | ||
| 377 | assistantMessageId: null, | ||
| 378 | }, | ||
| 379 | }; | ||
| 380 | |||
| 381 | const result = applyThreadDetailEvent(threadWithRunningSession, { | ||
| 382 | ...baseEventFields, | ||
| 383 | sequence: 8, | ||
| 384 | occurredAt: "2026-04-01T07:00:00.000Z", | ||
| 385 | aggregateKind: "thread", | ||
| 386 | aggregateId: ThreadId.make("thread-1"), | ||
| 387 | type: "thread.message-sent", | ||
| 388 | payload: { | ||
| 389 | threadId: ThreadId.make("thread-1"), | ||
| 390 | messageId: MessageId.make("msg-3"), | ||
| 391 | role: "assistant", | ||
| 392 | text: "Interim commentary between tool calls.", | ||
| 393 | turnId: TurnId.make("turn-1"), | ||
| 394 | streaming: false, | ||
| 395 | createdAt: "2026-04-01T07:00:00.000Z", | ||
| 396 | updatedAt: "2026-04-01T07:00:00.000Z", | ||
| 397 | }, | ||
| 398 | }); | ||
| 399 | |||
| 400 | expect(result.kind).toBe("updated"); | ||
| 401 | if (result.kind === "updated") { | ||
| 402 | expect(result.thread.latestTurn?.state).toBe("running"); | ||
| 403 | expect(result.thread.latestTurn?.completedAt).toBeNull(); | ||
| 404 | } | ||
| 405 | }); | ||
| 406 | }); | ||
| 407 | |||
| 408 | describe("thread.session-set", () => { | ||
| 409 | it("settles a running latestTurn when the session leaves the running status", () => { | ||
| 410 | const threadWithRunningTurn: OrchestrationThread = { | ||
| 411 | ...baseThread, | ||
| 412 | latestTurn: { | ||
| 413 | turnId: TurnId.make("turn-1"), | ||
| 414 | state: "running", | ||
| 415 | requestedAt: "2026-04-01T07:00:00.000Z", | ||
| 416 | startedAt: "2026-04-01T07:00:00.000Z", | ||
| 417 | completedAt: null, | ||
| 418 | assistantMessageId: MessageId.make("msg-3"), | ||
| 419 | }, | ||
| 420 | }; | ||
| 421 | |||
| 422 | const result = applyThreadDetailEvent(threadWithRunningTurn, { | ||
| 423 | ...baseEventFields, | ||
| 424 | sequence: 9, | ||
| 425 | occurredAt: "2026-04-01T08:00:00.000Z", | ||
| 426 | aggregateKind: "thread", | ||
| 427 | aggregateId: ThreadId.make("thread-1"), | ||
| 428 | type: "thread.session-set", | ||
| 429 | payload: { | ||
| 430 | threadId: ThreadId.make("thread-1"), | ||
| 431 | session: { | ||
| 432 | threadId: ThreadId.make("thread-1"), | ||
| 433 | status: "ready", | ||
| 434 | providerName: "claude", | ||
| 435 | runtimeMode: "full-access", | ||
| 436 | activeTurnId: null, | ||
| 437 | lastError: null, | ||
| 438 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 439 | }, | ||
| 440 | }, | ||
| 441 | }); | ||
| 442 | |||
| 443 | expect(result.kind).toBe("updated"); | ||
| 444 | if (result.kind === "updated") { | ||
| 445 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 446 | expect(result.thread.latestTurn?.completedAt).toBe("2026-04-01T08:00:00.000Z"); | ||
| 447 | } | ||
| 448 | }); | ||
| 449 | |||
| 450 | it("updates session and latestTurn for a running session", () => { | ||
| 451 | const result = applyThreadDetailEvent(baseThread, { | ||
| 452 | ...baseEventFields, | ||
| 453 | sequence: 9, | ||
| 454 | occurredAt: "2026-04-01T08:00:00.000Z", | ||
| 455 | aggregateKind: "thread", | ||
| 456 | aggregateId: ThreadId.make("thread-1"), | ||
| 457 | type: "thread.session-set", | ||
| 458 | payload: { | ||
| 459 | threadId: ThreadId.make("thread-1"), | ||
| 460 | session: { | ||
| 461 | threadId: ThreadId.make("thread-1"), | ||
| 462 | status: "running", | ||
| 463 | providerName: "codex", | ||
| 464 | runtimeMode: "full-access", | ||
| 465 | activeTurnId: TurnId.make("turn-1"), | ||
| 466 | lastError: null, | ||
| 467 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 468 | }, | ||
| 469 | }, | ||
| 470 | }); | ||
| 471 | |||
| 472 | expect(result.kind).toBe("updated"); | ||
| 473 | if (result.kind === "updated") { | ||
| 474 | expect(result.thread.session?.status).toBe("running"); | ||
| 475 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 476 | expect(result.thread.latestTurn?.state).toBe("running"); | ||
| 477 | } | ||
| 478 | }); | ||
| 479 | }); | ||
| 480 | |||
| 481 | describe("thread.session-stop-requested", () => { | ||
| 482 | it("marks session as stopped", () => { | ||
| 483 | const threadWithSession: OrchestrationThread = { | ||
| 484 | ...baseThread, | ||
| 485 | session: { | ||
| 486 | threadId: ThreadId.make("thread-1"), | ||
| 487 | status: "running", | ||
| 488 | providerName: "codex", | ||
| 489 | runtimeMode: "full-access", | ||
| 490 | activeTurnId: TurnId.make("turn-1"), | ||
| 491 | lastError: null, | ||
| 492 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 493 | }, | ||
| 494 | }; | ||
| 495 | |||
| 496 | const result = applyThreadDetailEvent(threadWithSession, { | ||
| 497 | ...baseEventFields, | ||
| 498 | sequence: 10, | ||
| 499 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 500 | aggregateKind: "thread", | ||
| 501 | aggregateId: ThreadId.make("thread-1"), | ||
| 502 | type: "thread.session-stop-requested", | ||
| 503 | payload: { | ||
| 504 | threadId: ThreadId.make("thread-1"), | ||
| 505 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 506 | }, | ||
| 507 | }); | ||
| 508 | |||
| 509 | expect(result.kind).toBe("updated"); | ||
| 510 | if (result.kind === "updated") { | ||
| 511 | expect(result.thread.session?.status).toBe("stopped"); | ||
| 512 | expect(result.thread.session?.activeTurnId).toBeNull(); | ||
| 513 | } | ||
| 514 | }); | ||
| 515 | |||
| 516 | it("returns unchanged when no session exists", () => { | ||
| 517 | const result = applyThreadDetailEvent(baseThread, { | ||
| 518 | ...baseEventFields, | ||
| 519 | sequence: 10, | ||
| 520 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 521 | aggregateKind: "thread", | ||
| 522 | aggregateId: ThreadId.make("thread-1"), | ||
| 523 | type: "thread.session-stop-requested", | ||
| 524 | payload: { | ||
| 525 | threadId: ThreadId.make("thread-1"), | ||
| 526 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 527 | }, | ||
| 528 | }); | ||
| 529 | expect(result.kind).toBe("unchanged"); | ||
| 530 | }); | ||
| 531 | }); | ||
| 532 | |||
| 533 | describe("thread.proposed-plan-upserted", () => { | ||
| 534 | it("adds a proposed plan", () => { | ||
| 535 | const result = applyThreadDetailEvent(baseThread, { | ||
| 536 | ...baseEventFields, | ||
| 537 | sequence: 11, | ||
| 538 | occurredAt: "2026-04-01T10:00:00.000Z", | ||
| 539 | aggregateKind: "thread", | ||
| 540 | aggregateId: ThreadId.make("thread-1"), | ||
| 541 | type: "thread.proposed-plan-upserted", | ||
| 542 | payload: { | ||
| 543 | threadId: ThreadId.make("thread-1"), | ||
| 544 | proposedPlan: { | ||
| 545 | id: "plan-1", | ||
| 546 | turnId: TurnId.make("turn-1"), | ||
| 547 | planMarkdown: "## Plan\n- Do stuff", | ||
| 548 | implementedAt: null, | ||
| 549 | implementationThreadId: null, | ||
| 550 | createdAt: "2026-04-01T10:00:00.000Z", | ||
| 551 | updatedAt: "2026-04-01T10:00:00.000Z", | ||
| 552 | }, | ||
| 553 | }, | ||
| 554 | }); | ||
| 555 | |||
| 556 | expect(result.kind).toBe("updated"); | ||
| 557 | if (result.kind === "updated") { | ||
| 558 | expect(result.thread.proposedPlans).toHaveLength(1); | ||
| 559 | expect(result.thread.proposedPlans[0]?.id).toBe("plan-1"); | ||
| 560 | } | ||
| 561 | }); | ||
| 562 | }); | ||
| 563 | |||
| 564 | describe("thread.activity-appended", () => { | ||
| 565 | it("adds an activity", () => { | ||
| 566 | const result = applyThreadDetailEvent(baseThread, { | ||
| 567 | ...baseEventFields, | ||
| 568 | sequence: 12, | ||
| 569 | occurredAt: "2026-04-01T11:00:00.000Z", | ||
| 570 | aggregateKind: "thread", | ||
| 571 | aggregateId: ThreadId.make("thread-1"), | ||
| 572 | type: "thread.activity-appended", | ||
| 573 | payload: { | ||
| 574 | threadId: ThreadId.make("thread-1"), | ||
| 575 | activity: { | ||
| 576 | id: EventId.make("activity-1"), | ||
| 577 | tone: "tool", | ||
| 578 | kind: "file-edit", | ||
| 579 | summary: "Edited src/index.ts", | ||
| 580 | payload: {}, | ||
| 581 | turnId: TurnId.make("turn-1"), | ||
| 582 | createdAt: "2026-04-01T11:00:00.000Z", | ||
| 583 | }, | ||
| 584 | }, | ||
| 585 | }); | ||
| 586 | |||
| 587 | expect(result.kind).toBe("updated"); | ||
| 588 | if (result.kind === "updated") { | ||
| 589 | expect(result.thread.activities).toHaveLength(1); | ||
| 590 | expect(result.thread.activities[0]?.kind).toBe("file-edit"); | ||
| 591 | } | ||
| 592 | }); | ||
| 593 | |||
| 594 | it("preserves the complete activity history when live events arrive", () => { | ||
| 595 | const existingActivities = Array.from({ length: 129 }, (_, index) => ({ | ||
| 596 | id: EventId.make(`activity-${index}`), | ||
| 597 | tone: "tool" as const, | ||
| 598 | kind: "command", | ||
| 599 | summary: `Ran command ${index}`, | ||
| 600 | payload: {}, | ||
| 601 | turnId: TurnId.make("turn-1"), | ||
| 602 | sequence: index, | ||
| 603 | createdAt: "2026-04-01T11:00:00.000Z", | ||
| 604 | })); | ||
| 605 | const result = applyThreadDetailEvent( | ||
| 606 | { ...baseThread, activities: existingActivities }, | ||
| 607 | { | ||
| 608 | ...baseEventFields, | ||
| 609 | sequence: 130, | ||
| 610 | occurredAt: "2026-04-01T11:01:00.000Z", | ||
| 611 | aggregateKind: "thread", | ||
| 612 | aggregateId: ThreadId.make("thread-1"), | ||
| 613 | type: "thread.activity-appended", | ||
| 614 | payload: { | ||
| 615 | threadId: ThreadId.make("thread-1"), | ||
| 616 | activity: { | ||
| 617 | id: EventId.make("activity-129"), | ||
| 618 | tone: "tool", | ||
| 619 | kind: "command", | ||
| 620 | summary: "Ran command 129", | ||
| 621 | payload: {}, | ||
| 622 | turnId: TurnId.make("turn-1"), | ||
| 623 | sequence: 129, | ||
| 624 | createdAt: "2026-04-01T11:01:00.000Z", | ||
| 625 | }, | ||
| 626 | }, | ||
| 627 | }, | ||
| 628 | ); | ||
| 629 | |||
| 630 | expect(result.kind).toBe("updated"); | ||
| 631 | if (result.kind === "updated") { | ||
| 632 | expect(result.thread.activities).toHaveLength(130); | ||
| 633 | expect(result.thread.activities[0]?.id).toBe("activity-0"); | ||
| 634 | } | ||
| 635 | }); | ||
| 636 | }); | ||
| 637 | |||
| 638 | describe("thread.turn-diff-completed", () => { | ||
| 639 | it("adds a checkpoint and updates latestTurn", () => { | ||
| 640 | const result = applyThreadDetailEvent(baseThread, { | ||
| 641 | ...baseEventFields, | ||
| 642 | sequence: 13, | ||
| 643 | occurredAt: "2026-04-01T12:00:00.000Z", | ||
| 644 | aggregateKind: "thread", | ||
| 645 | aggregateId: ThreadId.make("thread-1"), | ||
| 646 | type: "thread.turn-diff-completed", | ||
| 647 | payload: { | ||
| 648 | threadId: ThreadId.make("thread-1"), | ||
| 649 | turnId: TurnId.make("turn-1"), | ||
| 650 | checkpointTurnCount: 1, | ||
| 651 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 652 | status: "ready", | ||
| 653 | files: [], | ||
| 654 | assistantMessageId: MessageId.make("msg-3"), | ||
| 655 | completedAt: "2026-04-01T12:00:00.000Z", | ||
| 656 | }, | ||
| 657 | }); | ||
| 658 | |||
| 659 | expect(result.kind).toBe("updated"); | ||
| 660 | if (result.kind === "updated") { | ||
| 661 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 662 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 663 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 664 | } | ||
| 665 | }); | ||
| 666 | }); | ||
| 667 | |||
| 668 | describe("thread.reverted", () => { | ||
| 669 | it("filters entities to retained turns", () => { | ||
| 670 | const threadWithData: OrchestrationThread = { | ||
| 671 | ...baseThread, | ||
| 672 | messages: [ | ||
| 673 | { | ||
| 674 | id: MessageId.make("msg-1"), | ||
| 675 | role: "user", | ||
| 676 | text: "First", | ||
| 677 | turnId: null, | ||
| 678 | streaming: false, | ||
| 679 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 680 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 681 | }, | ||
| 682 | { | ||
| 683 | id: MessageId.make("msg-2"), | ||
| 684 | role: "assistant", | ||
| 685 | text: "Response 1", | ||
| 686 | turnId: TurnId.make("turn-1"), | ||
| 687 | streaming: false, | ||
| 688 | createdAt: "2026-04-01T02:00:00.000Z", | ||
| 689 | updatedAt: "2026-04-01T02:00:00.000Z", | ||
| 690 | }, | ||
| 691 | { | ||
| 692 | id: MessageId.make("msg-3"), | ||
| 693 | role: "assistant", | ||
| 694 | text: "Response 2", | ||
| 695 | turnId: TurnId.make("turn-2"), | ||
| 696 | streaming: false, | ||
| 697 | createdAt: "2026-04-01T03:00:00.000Z", | ||
| 698 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 699 | }, | ||
| 700 | ], | ||
| 701 | checkpoints: [ | ||
| 702 | { | ||
| 703 | turnId: TurnId.make("turn-1"), | ||
| 704 | checkpointTurnCount: 1, | ||
| 705 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 706 | status: "ready", | ||
| 707 | files: [], | ||
| 708 | assistantMessageId: MessageId.make("msg-2"), | ||
| 709 | completedAt: "2026-04-01T02:00:00.000Z", | ||
| 710 | }, | ||
| 711 | { | ||
| 712 | turnId: TurnId.make("turn-2"), | ||
| 713 | checkpointTurnCount: 2, | ||
| 714 | checkpointRef: CheckpointRef.make("ref-2"), | ||
| 715 | status: "ready", | ||
| 716 | files: [], | ||
| 717 | assistantMessageId: MessageId.make("msg-3"), | ||
| 718 | completedAt: "2026-04-01T03:00:00.000Z", | ||
| 719 | }, | ||
| 720 | ], | ||
| 721 | }; | ||
| 722 | |||
| 723 | const result = applyThreadDetailEvent(threadWithData, { | ||
| 724 | ...baseEventFields, | ||
| 725 | sequence: 14, | ||
| 726 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 727 | aggregateKind: "thread", | ||
| 728 | aggregateId: ThreadId.make("thread-1"), | ||
| 729 | type: "thread.reverted", | ||
| 730 | payload: { | ||
| 731 | threadId: ThreadId.make("thread-1"), | ||
| 732 | turnCount: 1, | ||
| 733 | }, | ||
| 734 | }); | ||
| 735 | |||
| 736 | expect(result.kind).toBe("updated"); | ||
| 737 | if (result.kind === "updated") { | ||
| 738 | // turn-2 checkpoint is filtered out (turnCount 2 > revert target 1) | ||
| 739 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 740 | expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1"); | ||
| 741 | // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain | ||
| 742 | expect(result.thread.messages).toHaveLength(2); | ||
| 743 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 744 | } | ||
| 745 | }); | ||
| 746 | }); | ||
| 747 | |||
| 748 | describe("no-op events", () => { | ||
| 749 | it("returns unchanged for approval-response-requested", () => { | ||
| 750 | const result = applyThreadDetailEvent(baseThread, { | ||
| 751 | ...baseEventFields, | ||
| 752 | sequence: 15, | ||
| 753 | occurredAt: "2026-04-01T13:00:00.000Z", | ||
| 754 | aggregateKind: "thread", | ||
| 755 | aggregateId: ThreadId.make("thread-1"), | ||
| 756 | type: "thread.approval-response-requested", | ||
| 757 | payload: { | ||
| 758 | threadId: ThreadId.make("thread-1"), | ||
| 759 | requestId: "req-1", | ||
| 760 | decision: "approve", | ||
| 761 | createdAt: "2026-04-01T13:00:00.000Z", | ||
| 762 | }, | ||
| 763 | } as any); | ||
| 764 | expect(result.kind).toBe("unchanged"); | ||
| 765 | }); | ||
| 766 | }); | ||
| 767 | }); | ||
| @@ -1,599 +0,0 @@ | |||
| 1 | import { pipe } from "effect/Function"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as O from "effect/Order"; | ||
| 4 | import type { | ||
| 5 | MessageId, | ||
| 6 | OrchestrationCheckpointSummary, | ||
| 7 | OrchestrationEvent, | ||
| 8 | OrchestrationLatestTurn, | ||
| 9 | OrchestrationMessage, | ||
| 10 | OrchestrationSession, | ||
| 11 | OrchestrationThread, | ||
| 12 | OrchestrationThreadActivity, | ||
| 13 | TurnId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | export type ThreadDetailReducerResult = | ||
| 17 | | { readonly kind: "updated"; readonly thread: OrchestrationThread } | ||
| 18 | | { readonly kind: "deleted" } | ||
| 19 | | { readonly kind: "unchanged" }; | ||
| 20 | |||
| 21 | const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>( | ||
| 22 | O.mapInput(O.String, (p) => p.createdAt), | ||
| 23 | O.mapInput(O.String, (p) => p.id), | ||
| 24 | ); | ||
| 25 | |||
| 26 | const checkpointOrder = O.mapInput( | ||
| 27 | O.Number, | ||
| 28 | (cp: OrchestrationThread["checkpoints"][number]) => | ||
| 29 | cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER, | ||
| 30 | ); | ||
| 31 | |||
| 32 | const activityOrder = O.combineAll<OrchestrationThreadActivity>([ | ||
| 33 | O.mapInput(O.Number, (a) => a.sequence ?? Number.MAX_SAFE_INTEGER), | ||
| 34 | O.mapInput(O.String, (a) => a.createdAt), | ||
| 35 | O.mapInput(O.String, (a) => a.id), | ||
| 36 | ]); | ||
| 37 | |||
| 38 | /** | ||
| 39 | * Apply a single orchestration event to an `OrchestrationThread`, returning | ||
| 40 | * the updated thread, a deletion signal, or an "unchanged" marker when the | ||
| 41 | * event doesn't affect this thread. | ||
| 42 | * | ||
| 43 | * This is a pure reducer operating on contract types. UI-specific mapping | ||
| 44 | * (e.g. resolving attachment preview URLs, normalising model slugs, adding | ||
| 45 | * scoped fields like `environmentId`) is the caller's responsibility. | ||
| 46 | */ | ||
| 47 | export function applyThreadDetailEvent( | ||
| 48 | thread: OrchestrationThread, | ||
| 49 | event: OrchestrationEvent, | ||
| 50 | ): ThreadDetailReducerResult { | ||
| 51 | switch (event.type) { | ||
| 52 | // ── Project events (irrelevant to thread detail) ──────────────── | ||
| 53 | case "project.created": | ||
| 54 | case "project.meta-updated": | ||
| 55 | case "project.deleted": | ||
| 56 | return { kind: "unchanged" }; | ||
| 57 | |||
| 58 | // ── Thread lifecycle ──────────────────────────────────────────── | ||
| 59 | case "thread.created": | ||
| 60 | return { | ||
| 61 | kind: "updated", | ||
| 62 | thread: { | ||
| 63 | id: event.payload.threadId, | ||
| 64 | projectId: event.payload.projectId, | ||
| 65 | title: event.payload.title, | ||
| 66 | modelSelection: event.payload.modelSelection, | ||
| 67 | runtimeMode: event.payload.runtimeMode, | ||
| 68 | interactionMode: event.payload.interactionMode, | ||
| 69 | branch: event.payload.branch, | ||
| 70 | worktreePath: event.payload.worktreePath, | ||
| 71 | latestTurn: null, | ||
| 72 | createdAt: event.payload.createdAt, | ||
| 73 | updatedAt: event.payload.updatedAt, | ||
| 74 | archivedAt: null, | ||
| 75 | settledOverride: null, | ||
| 76 | settledAt: null, | ||
| 77 | snoozedUntil: null, | ||
| 78 | snoozedAt: null, | ||
| 79 | deletedAt: null, | ||
| 80 | messages: [], | ||
| 81 | proposedPlans: [], | ||
| 82 | activities: [], | ||
| 83 | checkpoints: [], | ||
| 84 | session: null, | ||
| 85 | }, | ||
| 86 | }; | ||
| 87 | |||
| 88 | case "thread.deleted": | ||
| 89 | return { kind: "deleted" }; | ||
| 90 | |||
| 91 | case "thread.archived": | ||
| 92 | return { | ||
| 93 | kind: "updated", | ||
| 94 | thread: { | ||
| 95 | ...thread, | ||
| 96 | archivedAt: event.payload.archivedAt, | ||
| 97 | titleRegeneration: null, | ||
| 98 | updatedAt: event.payload.updatedAt, | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | |||
| 102 | case "thread.unarchived": | ||
| 103 | return { | ||
| 104 | kind: "updated", | ||
| 105 | thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, | ||
| 106 | }; | ||
| 107 | |||
| 108 | case "thread.settled": | ||
| 109 | return { | ||
| 110 | kind: "updated", | ||
| 111 | thread: { | ||
| 112 | ...thread, | ||
| 113 | settledOverride: "settled", | ||
| 114 | settledAt: event.payload.settledAt, | ||
| 115 | updatedAt: event.payload.updatedAt, | ||
| 116 | }, | ||
| 117 | }; | ||
| 118 | |||
| 119 | case "thread.unsettled": | ||
| 120 | return { | ||
| 121 | kind: "updated", | ||
| 122 | thread: { | ||
| 123 | ...thread, | ||
| 124 | settledOverride: event.payload.reason === "user" ? "active" : null, | ||
| 125 | settledAt: null, | ||
| 126 | updatedAt: event.payload.updatedAt, | ||
| 127 | }, | ||
| 128 | }; | ||
| 129 | |||
| 130 | case "thread.snoozed": | ||
| 131 | return { | ||
| 132 | kind: "updated", | ||
| 133 | thread: { | ||
| 134 | ...thread, | ||
| 135 | snoozedUntil: event.payload.snoozedUntil, | ||
| 136 | snoozedAt: event.payload.snoozedAt, | ||
| 137 | updatedAt: event.payload.updatedAt, | ||
| 138 | }, | ||
| 139 | }; | ||
| 140 | |||
| 141 | case "thread.unsnoozed": | ||
| 142 | return { | ||
| 143 | kind: "updated", | ||
| 144 | thread: { | ||
| 145 | ...thread, | ||
| 146 | snoozedUntil: null, | ||
| 147 | snoozedAt: null, | ||
| 148 | updatedAt: event.payload.updatedAt, | ||
| 149 | }, | ||
| 150 | }; | ||
| 151 | |||
| 152 | // ── Thread metadata ───────────────────────────────────────────── | ||
| 153 | case "thread.meta-updated": | ||
| 154 | return { | ||
| 155 | kind: "updated", | ||
| 156 | thread: { | ||
| 157 | ...thread, | ||
| 158 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | ||
| 159 | ...(event.payload.titleRegeneration !== undefined | ||
| 160 | ? { titleRegeneration: event.payload.titleRegeneration } | ||
| 161 | : {}), | ||
| 162 | ...(event.payload.modelSelection !== undefined | ||
| 163 | ? { modelSelection: event.payload.modelSelection } | ||
| 164 | : {}), | ||
| 165 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | ||
| 166 | ...(event.payload.worktreePath !== undefined | ||
| 167 | ? { worktreePath: event.payload.worktreePath } | ||
| 168 | : {}), | ||
| 169 | updatedAt: event.payload.updatedAt, | ||
| 170 | }, | ||
| 171 | }; | ||
| 172 | |||
| 173 | case "thread.runtime-mode-set": | ||
| 174 | return { | ||
| 175 | kind: "updated", | ||
| 176 | thread: { | ||
| 177 | ...thread, | ||
| 178 | runtimeMode: event.payload.runtimeMode, | ||
| 179 | updatedAt: event.payload.updatedAt, | ||
| 180 | }, | ||
| 181 | }; | ||
| 182 | |||
| 183 | case "thread.interaction-mode-set": | ||
| 184 | return { | ||
| 185 | kind: "updated", | ||
| 186 | thread: { | ||
| 187 | ...thread, | ||
| 188 | interactionMode: event.payload.interactionMode, | ||
| 189 | updatedAt: event.payload.updatedAt, | ||
| 190 | }, | ||
| 191 | }; | ||
| 192 | |||
| 193 | // ── Turn lifecycle ────────────────────────────────────────────── | ||
| 194 | case "thread.turn-start-requested": | ||
| 195 | return { | ||
| 196 | kind: "updated", | ||
| 197 | thread: { | ||
| 198 | ...thread, | ||
| 199 | ...(event.payload.modelSelection !== undefined | ||
| 200 | ? { modelSelection: event.payload.modelSelection } | ||
| 201 | : {}), | ||
| 202 | runtimeMode: event.payload.runtimeMode, | ||
| 203 | interactionMode: event.payload.interactionMode, | ||
| 204 | updatedAt: event.occurredAt, | ||
| 205 | }, | ||
| 206 | }; | ||
| 207 | |||
| 208 | case "thread.turn-interrupt-requested": { | ||
| 209 | if (event.payload.turnId === undefined) { | ||
| 210 | return { kind: "unchanged" }; | ||
| 211 | } | ||
| 212 | const latestTurn = thread.latestTurn; | ||
| 213 | if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { | ||
| 214 | return { kind: "unchanged" }; | ||
| 215 | } | ||
| 216 | return { | ||
| 217 | kind: "updated", | ||
| 218 | thread: { | ||
| 219 | ...thread, | ||
| 220 | latestTurn: { | ||
| 221 | ...latestTurn, | ||
| 222 | state: "interrupted", | ||
| 223 | startedAt: latestTurn.startedAt ?? event.payload.createdAt, | ||
| 224 | completedAt: latestTurn.completedAt ?? event.payload.createdAt, | ||
| 225 | }, | ||
| 226 | updatedAt: event.occurredAt, | ||
| 227 | }, | ||
| 228 | }; | ||
| 229 | } | ||
| 230 | |||
| 231 | // ── Messages ──────────────────────────────────────────────────── | ||
| 232 | case "thread.message-sent": { | ||
| 233 | const message: OrchestrationMessage = { | ||
| 234 | id: event.payload.messageId, | ||
| 235 | role: event.payload.role, | ||
| 236 | text: event.payload.text, | ||
| 237 | ...(event.payload.attachments !== undefined | ||
| 238 | ? { attachments: event.payload.attachments } | ||
| 239 | : {}), | ||
| 240 | turnId: event.payload.turnId, | ||
| 241 | streaming: event.payload.streaming, | ||
| 242 | createdAt: event.payload.createdAt, | ||
| 243 | updatedAt: event.payload.updatedAt, | ||
| 244 | }; | ||
| 245 | |||
| 246 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | ||
| 247 | const messages = existingMessage | ||
| 248 | ? Arr.map(thread.messages, (entry) => | ||
| 249 | entry.id !== message.id | ||
| 250 | ? entry | ||
| 251 | : { | ||
| 252 | ...entry, | ||
| 253 | text: message.streaming | ||
| 254 | ? `${entry.text}${message.text}` | ||
| 255 | : message.text.length > 0 | ||
| 256 | ? message.text | ||
| 257 | : entry.text, | ||
| 258 | streaming: message.streaming, | ||
| 259 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | ||
| 260 | ...(message.streaming ? {} : { updatedAt: message.updatedAt }), | ||
| 261 | ...(message.attachments !== undefined | ||
| 262 | ? { attachments: message.attachments } | ||
| 263 | : {}), | ||
| 264 | }, | ||
| 265 | ) | ||
| 266 | : Arr.append(thread.messages, message); | ||
| 267 | // Update latestTurn for assistant messages bound to a turn. A completed | ||
| 268 | // assistant message only settles the turn once the session is no longer | ||
| 269 | // running it — providers may emit several assistant messages per turn | ||
| 270 | // (commentary between tool calls), and the turn must stay unsettled | ||
| 271 | // until the provider reports turn end. | ||
| 272 | const turnStillRunning = | ||
| 273 | event.payload.turnId !== null && | ||
| 274 | thread.session?.status === "running" && | ||
| 275 | thread.session.activeTurnId === event.payload.turnId; | ||
| 276 | const settlesTurn = !event.payload.streaming && !turnStillRunning; | ||
| 277 | const latestTurn: OrchestrationThread["latestTurn"] = | ||
| 278 | event.payload.role === "assistant" && | ||
| 279 | event.payload.turnId !== null && | ||
| 280 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 281 | ? { | ||
| 282 | turnId: event.payload.turnId, | ||
| 283 | state: settlesTurn | ||
| 284 | ? thread.latestTurn?.state === "interrupted" | ||
| 285 | ? "interrupted" | ||
| 286 | : thread.latestTurn?.state === "error" | ||
| 287 | ? "error" | ||
| 288 | : "completed" | ||
| 289 | : "running", | ||
| 290 | requestedAt: | ||
| 291 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 292 | ? thread.latestTurn.requestedAt | ||
| 293 | : event.payload.createdAt, | ||
| 294 | startedAt: | ||
| 295 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 296 | ? (thread.latestTurn.startedAt ?? event.payload.createdAt) | ||
| 297 | : event.payload.createdAt, | ||
| 298 | completedAt: settlesTurn | ||
| 299 | ? event.payload.updatedAt | ||
| 300 | : thread.latestTurn?.turnId === event.payload.turnId | ||
| 301 | ? (thread.latestTurn.completedAt ?? null) | ||
| 302 | : null, | ||
| 303 | assistantMessageId: event.payload.messageId, | ||
| 304 | } | ||
| 305 | : thread.latestTurn; | ||
| 306 | |||
| 307 | // Rebind checkpoint assistant message IDs for assistant messages. | ||
| 308 | const checkpoints = | ||
| 309 | event.payload.role === "assistant" && event.payload.turnId !== null | ||
| 310 | ? rebindCheckpointAssistantMessage( | ||
| 311 | thread.checkpoints, | ||
| 312 | event.payload.turnId, | ||
| 313 | event.payload.messageId, | ||
| 314 | ) | ||
| 315 | : thread.checkpoints; | ||
| 316 | |||
| 317 | return { | ||
| 318 | kind: "updated", | ||
| 319 | thread: { | ||
| 320 | ...thread, | ||
| 321 | messages, | ||
| 322 | checkpoints, | ||
| 323 | latestTurn, | ||
| 324 | updatedAt: event.occurredAt, | ||
| 325 | }, | ||
| 326 | }; | ||
| 327 | } | ||
| 328 | |||
| 329 | // ── Session ───────────────────────────────────────────────────── | ||
| 330 | case "thread.session-set": { | ||
| 331 | // Leaving the "running" session status is the turn-end signal: settle a | ||
| 332 | // still-running latest turn so its duration reflects the whole turn. | ||
| 333 | const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); | ||
| 334 | const latestTurn: OrchestrationLatestTurn | null = | ||
| 335 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null | ||
| 336 | ? { | ||
| 337 | turnId: event.payload.session.activeTurnId, | ||
| 338 | state: "running", | ||
| 339 | requestedAt: | ||
| 340 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 341 | ? thread.latestTurn.requestedAt | ||
| 342 | : event.payload.session.updatedAt, | ||
| 343 | startedAt: | ||
| 344 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 345 | ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) | ||
| 346 | : event.payload.session.updatedAt, | ||
| 347 | completedAt: null, | ||
| 348 | assistantMessageId: | ||
| 349 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 350 | ? thread.latestTurn.assistantMessageId | ||
| 351 | : null, | ||
| 352 | } | ||
| 353 | : thread.latestTurn !== null && | ||
| 354 | thread.latestTurn.state === "running" && | ||
| 355 | settledTurnState !== null | ||
| 356 | ? { | ||
| 357 | ...thread.latestTurn, | ||
| 358 | state: settledTurnState, | ||
| 359 | // A running turn's completedAt can only hold a mid-turn | ||
| 360 | // placeholder checkpoint timestamp — the session leaving | ||
| 361 | // "running" is the authoritative turn end. | ||
| 362 | completedAt: event.payload.session.updatedAt, | ||
| 363 | } | ||
| 364 | : thread.latestTurn; | ||
| 365 | |||
| 366 | return { | ||
| 367 | kind: "updated", | ||
| 368 | thread: { | ||
| 369 | ...thread, | ||
| 370 | session: event.payload.session, | ||
| 371 | latestTurn, | ||
| 372 | updatedAt: event.occurredAt, | ||
| 373 | }, | ||
| 374 | }; | ||
| 375 | } | ||
| 376 | |||
| 377 | case "thread.session-stop-requested": | ||
| 378 | return thread.session === null | ||
| 379 | ? { kind: "unchanged" } | ||
| 380 | : { | ||
| 381 | kind: "updated", | ||
| 382 | thread: { | ||
| 383 | ...thread, | ||
| 384 | session: { | ||
| 385 | ...thread.session, | ||
| 386 | status: "stopped", | ||
| 387 | activeTurnId: null, | ||
| 388 | updatedAt: event.payload.createdAt, | ||
| 389 | }, | ||
| 390 | updatedAt: event.occurredAt, | ||
| 391 | }, | ||
| 392 | }; | ||
| 393 | |||
| 394 | // ── Proposed plans ────────────────────────────────────────────── | ||
| 395 | case "thread.proposed-plan-upserted": { | ||
| 396 | const proposedPlan = event.payload.proposedPlan; | ||
| 397 | |||
| 398 | const proposedPlans = pipe( | ||
| 399 | thread.proposedPlans, | ||
| 400 | Arr.filter((entry) => entry.id !== proposedPlan.id), | ||
| 401 | Arr.append(proposedPlan), | ||
| 402 | Arr.sort(proposedPlanOrder), | ||
| 403 | ); | ||
| 404 | |||
| 405 | return { | ||
| 406 | kind: "updated", | ||
| 407 | thread: { ...thread, proposedPlans, updatedAt: event.occurredAt }, | ||
| 408 | }; | ||
| 409 | } | ||
| 410 | |||
| 411 | // ── Checkpoints / turn diffs ──────────────────────────────────── | ||
| 412 | case "thread.turn-diff-completed": { | ||
| 413 | const checkpoint: OrchestrationCheckpointSummary = { | ||
| 414 | turnId: event.payload.turnId, | ||
| 415 | checkpointTurnCount: event.payload.checkpointTurnCount, | ||
| 416 | checkpointRef: event.payload.checkpointRef, | ||
| 417 | status: event.payload.status, | ||
| 418 | files: event.payload.files, | ||
| 419 | assistantMessageId: event.payload.assistantMessageId, | ||
| 420 | completedAt: event.payload.completedAt, | ||
| 421 | }; | ||
| 422 | |||
| 423 | const existing = thread.checkpoints.find((entry) => entry.turnId === checkpoint.turnId); | ||
| 424 | // Don't overwrite a non-missing checkpoint with a missing one. | ||
| 425 | if (existing && existing.status !== "missing" && checkpoint.status === "missing") { | ||
| 426 | return { kind: "unchanged" }; | ||
| 427 | } | ||
| 428 | |||
| 429 | const checkpoints = pipe( | ||
| 430 | thread.checkpoints, | ||
| 431 | Arr.filter((entry) => entry.turnId !== checkpoint.turnId), | ||
| 432 | Arr.append(checkpoint), | ||
| 433 | Arr.sort(checkpointOrder), | ||
| 434 | ); | ||
| 435 | |||
| 436 | // Mid-turn diff updates produce placeholder checkpoints; record the | ||
| 437 | // checkpoint, but don't settle a turn its session is still running. | ||
| 438 | const diffTurnStillRunning = | ||
| 439 | thread.session?.status === "running" && | ||
| 440 | thread.session.activeTurnId === event.payload.turnId; | ||
| 441 | const latestTurn = | ||
| 442 | !diffTurnStillRunning && | ||
| 443 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 444 | ? { | ||
| 445 | turnId: event.payload.turnId, | ||
| 446 | state: checkpointStatusToTurnState(event.payload.status), | ||
| 447 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, | ||
| 448 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, | ||
| 449 | completedAt: event.payload.completedAt, | ||
| 450 | assistantMessageId: event.payload.assistantMessageId, | ||
| 451 | } | ||
| 452 | : thread.latestTurn; | ||
| 453 | |||
| 454 | return { | ||
| 455 | kind: "updated", | ||
| 456 | thread: { ...thread, checkpoints, latestTurn, updatedAt: event.occurredAt }, | ||
| 457 | }; | ||
| 458 | } | ||
| 459 | |||
| 460 | // ── Revert ────────────────────────────────────────────────────── | ||
| 461 | case "thread.reverted": { | ||
| 462 | const checkpoints = pipe( | ||
| 463 | thread.checkpoints, | ||
| 464 | Arr.filter( | ||
| 465 | (entry) => | ||
| 466 | entry.checkpointTurnCount !== undefined && | ||
| 467 | entry.checkpointTurnCount <= event.payload.turnCount, | ||
| 468 | ), | ||
| 469 | Arr.sort(checkpointOrder), | ||
| 470 | ); | ||
| 471 | |||
| 472 | const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); | ||
| 473 | const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); | ||
| 474 | const proposedPlans = pipe( | ||
| 475 | thread.proposedPlans, | ||
| 476 | Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), | ||
| 477 | ); | ||
| 478 | const activities = pipe( | ||
| 479 | thread.activities, | ||
| 480 | Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)), | ||
| 481 | ); | ||
| 482 | const latestCheckpoint = checkpoints.at(-1) ?? null; | ||
| 483 | |||
| 484 | return { | ||
| 485 | kind: "updated", | ||
| 486 | thread: { | ||
| 487 | ...thread, | ||
| 488 | checkpoints, | ||
| 489 | messages, | ||
| 490 | proposedPlans, | ||
| 491 | activities, | ||
| 492 | latestTurn: | ||
| 493 | latestCheckpoint === null | ||
| 494 | ? null | ||
| 495 | : { | ||
| 496 | turnId: latestCheckpoint.turnId, | ||
| 497 | state: checkpointStatusToTurnState( | ||
| 498 | latestCheckpoint.status as "ready" | "missing" | "error", | ||
| 499 | ), | ||
| 500 | requestedAt: latestCheckpoint.completedAt, | ||
| 501 | startedAt: latestCheckpoint.completedAt, | ||
| 502 | completedAt: latestCheckpoint.completedAt, | ||
| 503 | assistantMessageId: latestCheckpoint.assistantMessageId ?? null, | ||
| 504 | }, | ||
| 505 | updatedAt: event.occurredAt, | ||
| 506 | }, | ||
| 507 | }; | ||
| 508 | } | ||
| 509 | |||
| 510 | // ── Activities ────────────────────────────────────────────────── | ||
| 511 | case "thread.activity-appended": { | ||
| 512 | const activities = pipe( | ||
| 513 | thread.activities, | ||
| 514 | Arr.filter((activity) => activity.id !== event.payload.activity.id), | ||
| 515 | Arr.append(event.payload.activity), | ||
| 516 | Arr.sort(activityOrder), | ||
| 517 | ); | ||
| 518 | |||
| 519 | return { | ||
| 520 | kind: "updated", | ||
| 521 | thread: { ...thread, activities, updatedAt: event.occurredAt }, | ||
| 522 | }; | ||
| 523 | } | ||
| 524 | |||
| 525 | // ── Events that don't mutate thread state directly ────────────── | ||
| 526 | case "thread.approval-response-requested": | ||
| 527 | case "thread.user-input-response-requested": | ||
| 528 | case "thread.checkpoint-revert-requested": | ||
| 529 | return { kind: "unchanged" }; | ||
| 530 | } | ||
| 531 | |||
| 532 | // Forward-compatible: ignore unrecognized event types. | ||
| 533 | return { kind: "unchanged" }; | ||
| 534 | } | ||
| 535 | |||
| 536 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 537 | |||
| 538 | /** | ||
| 539 | * Turn state to settle a still-running latest turn with when its session | ||
| 540 | * leaves the "running" status, or null while the session is (re)starting or | ||
| 541 | * running and the turn must stay unsettled. | ||
| 542 | */ | ||
| 543 | function settledTurnStateForSessionStatus( | ||
| 544 | status: OrchestrationSession["status"], | ||
| 545 | ): "completed" | "interrupted" | "error" | null { | ||
| 546 | switch (status) { | ||
| 547 | case "idle": | ||
| 548 | case "ready": | ||
| 549 | return "completed"; | ||
| 550 | case "error": | ||
| 551 | return "error"; | ||
| 552 | case "interrupted": | ||
| 553 | case "stopped": | ||
| 554 | return "interrupted"; | ||
| 555 | case "starting": | ||
| 556 | case "running": | ||
| 557 | return null; | ||
| 558 | } | ||
| 559 | } | ||
| 560 | |||
| 561 | function checkpointStatusToTurnState( | ||
| 562 | status: "ready" | "missing" | "error", | ||
| 563 | ): OrchestrationLatestTurn["state"] { | ||
| 564 | switch (status) { | ||
| 565 | case "ready": | ||
| 566 | return "completed"; | ||
| 567 | case "error": | ||
| 568 | return "error"; | ||
| 569 | case "missing": | ||
| 570 | return "completed"; | ||
| 571 | } | ||
| 572 | } | ||
| 573 | |||
| 574 | function rebindCheckpointAssistantMessage( | ||
| 575 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | ||
| 576 | turnId: TurnId, | ||
| 577 | messageId: MessageId, | ||
| 578 | ): OrchestrationCheckpointSummary[] { | ||
| 579 | return Arr.map(checkpoints, (entry) => | ||
| 580 | entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, | ||
| 581 | ); | ||
| 582 | } | ||
| 583 | |||
| 584 | function retainMessagesAfterRevert( | ||
| 585 | messages: ReadonlyArray<OrchestrationMessage>, | ||
| 586 | retainedTurnIds: ReadonlySet<string>, | ||
| 587 | ): OrchestrationMessage[] { | ||
| 588 | // Keep messages that belong to a retained turn, plus system messages and | ||
| 589 | // messages without a turn binding (pre-turn-0 user messages). | ||
| 590 | return Arr.filter(messages, (message) => { | ||
| 591 | if (message.role === "system") { | ||
| 592 | return true; | ||
| 593 | } | ||
| 594 | if (message.turnId === null) { | ||
| 595 | return true; | ||
| 596 | } | ||
| 597 | return retainedTurnIds.has(message.turnId); | ||
| 598 | }); | ||
| 599 | } | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | OrchestrationShellSnapshot, | 3 | OrchestrationV2ShellSnapshot, |
| 4 | OrchestrationThreadShell, | 4 | OrchestrationV2ThreadShell, |
| 5 | ProjectId, | 5 | ProjectId, |
| 6 | ScopedProjectRef, | 6 | ScopedProjectRef, |
| 7 | ScopedThreadRef, | 7 | ScopedThreadRef, |
| @@ -10,7 +10,7 @@ import type { · 2 unmodified lines | |||
| 10 | import { Atom } from "effect/unstable/reactivity"; | 10 | import { Atom } from "effect/unstable/reactivity"; |
| 11 | 11 | ||
| 12 | import type { EnvironmentThreadShell } from "./models.ts"; | 12 | import type { EnvironmentThreadShell } from "./models.ts"; |
| 13 | import { scopeThreadShell } from "./models.ts"; | 13 | import { presentThreadShell } from "./models.ts"; |
| 14 | import type { EnvironmentCatalogState } from "./connections.ts"; | 14 | import type { EnvironmentCatalogState } from "./connections.ts"; |
| 15 | import { | 15 | import { |
| 16 | arrayElementsEqual, | 16 | arrayElementsEqual, |
| @@ -21,9 +21,9 @@ import { · 4 unmodified lines | |||
| 21 | threadRefsEqual, | 21 | threadRefsEqual, |
| 22 | } from "./entities.ts"; | 22 | } from "./entities.ts"; |
| 23 | 23 | ||
| 24 | const EMPTY_THREADS: ReadonlyArray<OrchestrationThreadShell> = Object.freeze([]); | 24 | const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]); |
| 25 | const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); | 25 | const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); |
| 26 | const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationThreadShell> = new Map(); | 26 | const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map(); |
| 27 | const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< | 27 | const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< |
| 28 | ProjectId, | 28 | ProjectId, |
| 29 | ReadonlyArray<ScopedThreadRef> | 29 | ReadonlyArray<ScopedThreadRef> |
| @@ -33,17 +33,17 @@ export function createEnvironmentThreadShellAtoms(input: { · 3 unmodified lines | |||
| 33 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | 33 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; |
| 34 | readonly snapshotAtom: ( | 34 | readonly snapshotAtom: ( |
| 35 | environmentId: EnvironmentId, | 35 | environmentId: EnvironmentId, |
| 36 | ) => Atom.Atom<OrchestrationShellSnapshot | null>; | 36 | ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>; |
| 37 | }) { | 37 | }) { |
| 38 | const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) => | 38 | const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) => |
| 39 | Atom.make( | 39 | Atom.make( |
| 40 | (get): ReadonlyArray<OrchestrationThreadShell> => | 40 | (get): ReadonlyArray<OrchestrationV2ThreadShell> => |
| 41 | get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS, | 41 | get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS, |
| 42 | ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)), | 42 | ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)), |
| 43 | ); | 43 | ); |
| 44 | 44 | ||
| 45 | const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) => | 45 | const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) => |
| 46 | Atom.make((get): ReadonlyMap<ThreadId, OrchestrationThreadShell> => { | 46 | Atom.make((get): ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> => { |
| 47 | const threads = get(environmentThreadsAtom(environmentId)); | 47 | const threads = get(environmentThreadsAtom(environmentId)); |
| 48 | if (threads.length === 0) { | 48 | if (threads.length === 0) { |
| 49 | return EMPTY_THREAD_INDEX; | 49 | return EMPTY_THREAD_INDEX; |
| @@ -102,7 +102,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 52 unmodified lines | |||
| 102 | 102 | ||
| 103 | const threadShellAtomFamily = Atom.family((key: string) => { | 103 | const threadShellAtomFamily = Atom.family((key: string) => { |
| 104 | const ref = parseThreadKey(key); | 104 | const ref = parseThreadKey(key); |
| 105 | let previousSource: OrchestrationThreadShell | null = null; | 105 | let previousSource: OrchestrationV2ThreadShell | null = null; |
| 106 | let previousValue: EnvironmentThreadShell | null = null; | 106 | let previousValue: EnvironmentThreadShell | null = null; |
| 107 | return Atom.make((get) => { | 107 | return Atom.make((get) => { |
| 108 | const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; | 108 | const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; |
| @@ -110,7 +110,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 1 unmodified line | |||
| 110 | return previousValue; | 110 | return previousValue; |
| 111 | } | 111 | } |
| 112 | previousSource = source; | 112 | previousSource = source; |
| 113 | previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source); | 113 | previousValue = source === null ? null : presentThreadShell(ref.environmentId, source); |
| 114 | return previousValue; | 114 | return previousValue; |
| 115 | }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); | 115 | }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); |
| 116 | }); | 116 | }); |
| @@ -1,41 +1,29 @@ | |||
| 1 | import { | 1 | import { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | EventId, | 3 | EventId, |
| 4 | ORCHESTRATION_WS_METHODS, | 4 | ORCHESTRATION_V2_WS_METHODS, |
| 5 | ProjectId, | 5 | type OrchestrationV2ThreadStreamItem, |
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | TurnId, | ||
| 9 | type OrchestrationThread, | ||
| 10 | type OrchestrationThreadDetailSnapshot, | ||
| 11 | type OrchestrationThreadStreamItem, | ||
| 12 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 13 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 15 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 16 | import * as Queue from "effect/Queue"; | 11 | import * as Queue from "effect/Queue"; |
| 17 | import * as Ref from "effect/Ref"; | ||
| 18 | import * as Stream from "effect/Stream"; | 12 | import * as Stream from "effect/Stream"; |
| 19 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 20 | import * as TestClock from "effect/testing/TestClock"; | ||
| 21 | 14 | ||
| 22 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 23 | import { | 15 | import { |
| 24 | AVAILABLE_CONNECTION_STATE, | 16 | AVAILABLE_CONNECTION_STATE, |
| 25 | PrimaryConnectionTarget, | 17 | PrimaryConnectionTarget, |
| 26 | type PreparedConnection, | 18 | type PreparedConnection, |
| 27 | type SupervisorConnectionState, | ||
| 28 | } from "../connection/model.ts"; | 19 | } from "../connection/model.ts"; |
| 29 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | 20 | import * as ConnectionWakeups from "../connection/wakeups.ts"; |
| 30 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 21 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 31 | import * as Persistence from "../platform/persistence.ts"; | 22 | import * as Persistence from "../platform/persistence.ts"; |
| 32 | import * as RpcSession from "../rpc/session.ts"; | 23 | import * as RpcSession from "../rpc/session.ts"; |
| 33 | import { | 24 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 34 | EMPTY_ENVIRONMENT_THREAD_STATE, | 25 | import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts"; |
| 35 | makeEnvironmentThreadState, | 26 | import { makeEnvironmentThreadState } from "./threads.ts"; |
| 36 | ThreadSnapshotLoader, | ||
| 37 | type EnvironmentThreadState, | ||
| 38 | } from "./threads.ts"; | ||
| 39 | 27 | ||
| 40 | const TARGET = new PrimaryConnectionTarget({ | 28 | const TARGET = new PrimaryConnectionTarget({ |
| 41 | environmentId: EnvironmentId.make("environment-1"), | 29 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -43,559 +31,73 @@ const TARGET = new PrimaryConnectionTarget({ · 1 unmodified line | |||
| 43 | httpBaseUrl: "https://environment.example.test", | 31 | httpBaseUrl: "https://environment.example.test", |
| 44 | wsBaseUrl: "wss://environment.example.test", | 32 | wsBaseUrl: "wss://environment.example.test", |
| 45 | }); | 33 | }); |
| 46 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 47 | const CACHED_SNAPSHOT_SEQUENCE = 7; | ||
| 48 | const PREPARED: PreparedConnection = { | ||
| 49 | environmentId: TARGET.environmentId, | ||
| 50 | label: TARGET.label, | ||
| 51 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 52 | socketUrl: TARGET.wsBaseUrl, | ||
| 53 | httpAuthorization: null, | ||
| 54 | target: TARGET, | ||
| 55 | }; | ||
| 56 | const BASE_THREAD: OrchestrationThread = { | ||
| 57 | id: THREAD_ID, | ||
| 58 | projectId: ProjectId.make("project-1"), | ||
| 59 | title: "Cached thread", | ||
| 60 | modelSelection: { | ||
| 61 | instanceId: ProviderInstanceId.make("codex"), | ||
| 62 | model: "gpt-5.4", | ||
| 63 | }, | ||
| 64 | runtimeMode: "full-access", | ||
| 65 | interactionMode: "default", | ||
| 66 | branch: "main", | ||
| 67 | worktreePath: null, | ||
| 68 | latestTurn: null, | ||
| 69 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 70 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 71 | archivedAt: null, | ||
| 72 | settledOverride: null, | ||
| 73 | settledAt: null, | ||
| 74 | deletedAt: null, | ||
| 75 | messages: [], | ||
| 76 | proposedPlans: [], | ||
| 77 | activities: [], | ||
| 78 | checkpoints: [], | ||
| 79 | session: null, | ||
| 80 | }; | ||
| 81 | const ACTIVE_THREAD: OrchestrationThread = { | ||
| 82 | ...BASE_THREAD, | ||
| 83 | latestTurn: { | ||
| 84 | turnId: TurnId.make("turn-1"), | ||
| 85 | state: "running", | ||
| 86 | requestedAt: "2026-04-01T00:01:00.000Z", | ||
| 87 | startedAt: "2026-04-01T00:01:00.000Z", | ||
| 88 | completedAt: null, | ||
| 89 | assistantMessageId: null, | ||
| 90 | }, | ||
| 91 | session: { | ||
| 92 | threadId: THREAD_ID, | ||
| 93 | status: "running", | ||
| 94 | providerName: "codex", | ||
| 95 | runtimeMode: "full-access", | ||
| 96 | activeTurnId: TurnId.make("turn-1"), | ||
| 97 | lastError: null, | ||
| 98 | updatedAt: "2026-04-01T00:01:00.000Z", | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | |||
| 102 | type TestThreadInput = OrchestrationThreadStreamItem | Error; | ||
| 103 | |||
| 104 | function testSession( | ||
| 105 | client: WsRpcProtocolClient, | ||
| 106 | options?: { readonly completionMarker?: boolean }, | ||
| 107 | ): RpcSession.RpcSession { | ||
| 108 | return { | ||
| 109 | client, | ||
| 110 | initialConfig: Effect.succeed( | ||
| 111 | options?.completionMarker === true | ||
| 112 | ? ({ threadResumeCompletionMarker: true } as never) | ||
| 113 | : ({} as never), | ||
| 114 | ), | ||
| 115 | ready: Effect.void, | ||
| 116 | probe: Effect.void, | ||
| 117 | closed: Effect.never, | ||
| 118 | }; | ||
| 119 | } | ||
| 120 | |||
| 121 | function awaitThreadState( | ||
| 122 | observed: Queue.Queue<EnvironmentThreadState>, | ||
| 123 | predicate: (state: EnvironmentThreadState) => boolean, | ||
| 124 | ) { | ||
| 125 | return Queue.take(observed).pipe( | ||
| 126 | Effect.repeat({ | ||
| 127 | until: predicate, | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { | ||
| 133 | readonly cached?: OrchestrationThread; | ||
| 134 | readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>; | ||
| 135 | readonly completionMarker?: boolean; | ||
| 136 | }) { | ||
| 137 | const inputs = yield* Queue.unbounded<TestThreadInput>(); | ||
| 138 | const observed = yield* Queue.unbounded<EnvironmentThreadState>(); | ||
| 139 | const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE); | ||
| 140 | const retryCount = yield* Ref.make(0); | ||
| 141 | const subscriptionCount = yield* Ref.make(0); | ||
| 142 | const loaderCalls = yield* Ref.make(0); | ||
| 143 | const lastSubscribeAfterSequence = yield* Ref.make<number | undefined>(undefined); | ||
| 144 | const lastRequestCompletionMarker = yield* Ref.make<boolean | undefined>(undefined); | ||
| 145 | const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]); | ||
| 146 | const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]); | ||
| 147 | const wakeups = yield* Queue.unbounded<ConnectionWakeups.ConnectionWakeup>(); | ||
| 148 | const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>( | ||
| 149 | AVAILABLE_CONNECTION_STATE, | ||
| 150 | ); | ||
| 151 | const streamFrom = (queue: Queue.Queue<TestThreadInput>) => | ||
| 152 | Stream.fromQueue(queue).pipe( | ||
| 153 | Stream.mapEffect((input) => | ||
| 154 | input instanceof Error ? Effect.fail(input) : Effect.succeed(input), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | const client = { | ||
| 158 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { | ||
| 159 | readonly afterSequence?: number; | ||
| 160 | readonly requestCompletionMarker?: boolean; | ||
| 161 | }) => | ||
| 162 | Stream.unwrap( | ||
| 163 | Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( | ||
| 164 | Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)), | ||
| 165 | Effect.andThen(Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker)), | ||
| 166 | Effect.as(streamFrom(inputs)), | ||
| 167 | ), | ||
| 168 | ), | ||
| 169 | } as unknown as WsRpcProtocolClient; | ||
| 170 | const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | ||
| 171 | Option.some( | ||
| 172 | testSession( | ||
| 173 | client, | ||
| 174 | options?.completionMarker === true ? { completionMarker: true } : undefined, | ||
| 175 | ), | ||
| 176 | ), | ||
| 177 | ); | ||
| 178 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>( | ||
| 179 | Option.some(PREPARED), | ||
| 180 | ); | ||
| 181 | const snapshotLoader = ThreadSnapshotLoader.of({ | ||
| 182 | load: (_prepared, threadId) => | ||
| 183 | Ref.update(loaderCalls, (count) => count + 1).pipe( | ||
| 184 | Effect.as( | ||
| 185 | threadId === THREAD_ID | ||
| 186 | ? (options?.httpSnapshot ?? Option.none<OrchestrationThreadDetailSnapshot>()) | ||
| 187 | : Option.none<OrchestrationThreadDetailSnapshot>(), | ||
| 188 | ), | ||
| 189 | ), | ||
| 190 | }); | ||
| 191 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 192 | target: TARGET, | ||
| 193 | state: supervisorState, | ||
| 194 | session: supervisorSession, | ||
| 195 | prepared, | ||
| 196 | connect: Effect.void, | ||
| 197 | disconnect: Effect.void, | ||
| 198 | retryNow: Ref.update(retryCount, (count) => count + 1), | ||
| 199 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 200 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 201 | loadShell: () => Effect.succeed(Option.none()), | ||
| 202 | saveShell: () => Effect.void, | ||
| 203 | loadThread: (_environmentId, threadId) => | ||
| 204 | Effect.succeed( | ||
| 205 | threadId === THREAD_ID && options?.cached !== undefined | ||
| 206 | ? Option.some({ | ||
| 207 | snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, | ||
| 208 | thread: options.cached, | ||
| 209 | }) | ||
| 210 | : Option.none(), | ||
| 211 | ), | ||
| 212 | saveThread: (_environmentId, thread) => | ||
| 213 | Ref.update(savedThreads, (current) => [...current, thread]), | ||
| 214 | removeThread: (_environmentId, threadId) => | ||
| 215 | Ref.update(removedThreads, (current) => [...current, threadId]), | ||
| 216 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 217 | saveServerConfig: () => Effect.void, | ||
| 218 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 219 | saveVcsRefs: () => Effect.void, | ||
| 220 | removeVcsRefs: () => Effect.void, | ||
| 221 | clearVcsRefs: () => Effect.void, | ||
| 222 | clear: () => Effect.void, | ||
| 223 | }); | ||
| 224 | const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( | ||
| 225 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 226 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 227 | Effect.provideService(ThreadSnapshotLoader, snapshotLoader), | ||
| 228 | Effect.provideService( | ||
| 229 | ConnectionWakeups.ConnectionWakeups, | ||
| 230 | ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), | ||
| 231 | ), | ||
| 232 | ); | ||
| 233 | yield* SubscriptionRef.changes(threadState).pipe( | ||
| 234 | Stream.runForEach((state) => | ||
| 235 | Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), | ||
| 236 | ), | ||
| 237 | Effect.forkScoped, | ||
| 238 | ); | ||
| 239 | |||
| 240 | return { | ||
| 241 | inputs, | ||
| 242 | observed, | ||
| 243 | latest, | ||
| 244 | retryCount, | ||
| 245 | subscriptionCount, | ||
| 246 | loaderCalls, | ||
| 247 | lastSubscribeAfterSequence, | ||
| 248 | lastRequestCompletionMarker, | ||
| 249 | supervisorState, | ||
| 250 | supervisorSession, | ||
| 251 | savedThreads, | ||
| 252 | removedThreads, | ||
| 253 | wakeups, | ||
| 254 | replaceSession: SubscriptionRef.set( | ||
| 255 | supervisorSession, | ||
| 256 | Option.some( | ||
| 257 | testSession( | ||
| 258 | client, | ||
| 259 | options?.completionMarker === true ? { completionMarker: true } : undefined, | ||
| 260 | ), | ||
| 261 | ), | ||
| 262 | ), | ||
| 263 | }; | ||
| 264 | }); | ||
| 265 | |||
| 266 | const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({ | ||
| 267 | kind: "snapshot", | ||
| 268 | snapshot: { | ||
| 269 | snapshotSequence: 1, | ||
| 270 | thread, | ||
| 271 | }, | ||
| 272 | }); | ||
| 273 | |||
| 274 | const synchronized = (): OrchestrationThreadStreamItem => ({ kind: "synchronized" }); | ||
| 275 | |||
| 276 | const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({ | ||
| 277 | kind: "event", | ||
| 278 | event: { | ||
| 279 | eventId: EventId.make("event-title"), | ||
| 280 | sequence, | ||
| 281 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 282 | commandId: null, | ||
| 283 | causationEventId: null, | ||
| 284 | correlationId: null, | ||
| 285 | metadata: {}, | ||
| 286 | aggregateKind: "thread", | ||
| 287 | aggregateId: THREAD_ID, | ||
| 288 | type: "thread.meta-updated", | ||
| 289 | payload: { | ||
| 290 | threadId: THREAD_ID, | ||
| 291 | title, | ||
| 292 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 293 | }, | ||
| 294 | }, | ||
| 295 | }); | ||
| 296 | |||
| 297 | const deleted = (): OrchestrationThreadStreamItem => ({ | ||
| 298 | kind: "event", | ||
| 299 | event: { | ||
| 300 | eventId: EventId.make("event-deleted"), | ||
| 301 | sequence: 3, | ||
| 302 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 303 | commandId: null, | ||
| 304 | causationEventId: null, | ||
| 305 | correlationId: null, | ||
| 306 | metadata: {}, | ||
| 307 | aggregateKind: "thread", | ||
| 308 | aggregateId: THREAD_ID, | ||
| 309 | type: "thread.deleted", | ||
| 310 | payload: { | ||
| 311 | threadId: THREAD_ID, | ||
| 312 | deletedAt: "2026-04-01T02:00:00.000Z", | ||
| 313 | }, | ||
| 314 | }, | ||
| 315 | }); | ||
| 316 | |||
| 317 | describe("EnvironmentThreads", () => { | ||
| 318 | it.effect("publishes cached data immediately from a warm cache", () => | ||
| 319 | Effect.gen(function* () { | ||
| 320 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 321 | const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data)); | ||
| 322 | |||
| 323 | expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); | ||
| 324 | expect(Option.isNone(state.error)).toBe(true); | ||
| 325 | }), | ||
| 326 | ); | ||
| 327 | |||
| 328 | it.effect("resumes a warm cache via afterSequence without an HTTP fetch", () => | ||
| 329 | Effect.gen(function* () { | ||
| 330 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 331 | |||
| 332 | // The warm cache reaches live from the cached data, and a live event | ||
| 333 | // applies on top of it. | ||
| 334 | yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); | ||
| 335 | yield* awaitThreadState( | ||
| 336 | harness.observed, | ||
| 337 | (value) => | ||
| 338 | value.status === "live" && | ||
| 339 | Option.isSome(value.data) && | ||
| 340 | value.data.value.title === "Live title", | ||
| 341 | ); | ||
| 342 | 34 | ||
| 343 | // The subscription resumed from the cached sequence and never fetched the | 35 | describe("V2 thread synchronization", () => { |
| 344 | // full snapshot over HTTP. | 36 | it.effect("applies a snapshot followed by committed projection events", () => |
| 345 | expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); | ||
| 346 | expect(yield* Ref.get(harness.loaderCalls)).toBe(0); | ||
| 347 | }), | ||
| 348 | ); | ||
| 349 | |||
| 350 | it.effect("reduces live events and persists the latest thread", () => | ||
| 351 | Effect.gen(function* () { | ||
| 352 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 353 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 354 | yield* Queue.offer(harness.inputs, titleUpdated("Live title")); | ||
| 355 | |||
| 356 | const state = yield* awaitThreadState( | ||
| 357 | harness.observed, | ||
| 358 | (value) => | ||
| 359 | value.status === "live" && | ||
| 360 | Option.isSome(value.data) && | ||
| 361 | value.data.value.title === "Live title", | ||
| 362 | ); | ||
| 363 | yield* TestClock.adjust("500 millis"); | ||
| 364 | yield* Effect.yieldNow; | ||
| 365 | |||
| 366 | expect(Option.getOrThrow(state.data).title).toBe("Live title"); | ||
| 367 | expect((yield* Ref.get(harness.savedThreads)).at(-1)?.thread.title).toBe("Live title"); | ||
| 368 | expect((yield* Ref.get(harness.savedThreads)).at(-1)?.snapshotSequence).toBe(2); | ||
| 369 | }), | ||
| 370 | ); | ||
| 371 | |||
| 372 | it.effect("does not persist active thread snapshots during streaming or teardown", () => | ||
| 373 | Effect.gen(function* () { | ||
| 374 | const savedThreads = yield* Effect.scoped( | ||
| 375 | Effect.gen(function* () { | ||
| 376 | const harness = yield* makeHarness({ cached: ACTIVE_THREAD }); | ||
| 377 | yield* awaitThreadState( | ||
| 378 | harness.observed, | ||
| 379 | (value) => | ||
| 380 | value.status === "live" && | ||
| 381 | Option.isSome(value.data) && | ||
| 382 | value.data.value.session?.status === "running", | ||
| 383 | ); | ||
| 384 | |||
| 385 | yield* TestClock.adjust("500 millis"); | ||
| 386 | yield* Effect.yieldNow; | ||
| 387 | |||
| 388 | expect(yield* Ref.get(harness.savedThreads)).toEqual([]); | ||
| 389 | return harness.savedThreads; | ||
| 390 | }), | ||
| 391 | ); | ||
| 392 | |||
| 393 | expect(yield* Ref.get(savedThreads)).toEqual([]); | ||
| 394 | }), | ||
| 395 | ); | ||
| 396 | |||
| 397 | it.effect("seeds the thread from the HTTP snapshot and resumes live events", () => | ||
| 398 | Effect.gen(function* () { | 37 | Effect.gen(function* () { |
| 399 | const httpThread: OrchestrationThread = { ...BASE_THREAD, title: "HTTP title" }; | 38 | const events = yield* Queue.unbounded<OrchestrationV2ThreadStreamItem>(); |
| 400 | const harness = yield* makeHarness({ | 39 | const client = { |
| 401 | httpSnapshot: Option.some({ snapshotSequence: 1, thread: httpThread }), | 40 | [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: () => Stream.fromQueue(events), |
| 41 | } as unknown as WsRpcProtocolClient; | ||
| 42 | const rpcSession: RpcSession.RpcSession = { | ||
| 43 | client, | ||
| 44 | initialConfig: Effect.never, | ||
| 45 | ready: Effect.void, | ||
| 46 | probe: Effect.void, | ||
| 47 | closed: Effect.never, | ||
| 48 | }; | ||
| 49 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 50 | target: TARGET, | ||
| 51 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 52 | session: yield* SubscriptionRef.make(Option.some(rpcSession)), | ||
| 53 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 54 | connect: Effect.void, | ||
| 55 | disconnect: Effect.void, | ||
| 56 | retryNow: Effect.void, | ||
| 402 | }); | 57 | }); |
| 403 | // No socket snapshot is pushed; only a live event arrives over the socket. | 58 | const cache = Persistence.EnvironmentCacheStore.of({ |
| 404 | // It can only be applied if the HTTP snapshot already seeded the thread. | 59 | loadShell: () => Effect.succeed(Option.none()), |
| 405 | yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); | 60 | saveShell: () => Effect.void, |
| 406 | 61 | loadThread: () => Effect.succeed(Option.none()), | |
| 407 | const state = yield* awaitThreadState( | 62 | saveThread: () => Effect.void, |
| 408 | harness.observed, | 63 | removeThread: () => Effect.void, |
| 409 | (value) => | 64 | clear: () => Effect.void, |
| 410 | value.status === "live" && | ||
| 411 | Option.isSome(value.data) && | ||
| 412 | value.data.value.title === "Live title", | ||
| 413 | ); | ||
| 414 | |||
| 415 | expect(Option.getOrThrow(state.data).title).toBe("Live title"); | ||
| 416 | // Cold cache: the full snapshot was loaded over HTTP and the socket | ||
| 417 | // resumed from that snapshot's sequence. | ||
| 418 | expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1); | ||
| 419 | expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(1); | ||
| 420 | }), | ||
| 421 | ); | ||
| 422 | |||
| 423 | it.effect("ignores replayed thread events at or below the snapshot sequence", () => | ||
| 424 | Effect.gen(function* () { | ||
| 425 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 426 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 427 | yield* Queue.offer(harness.inputs, titleUpdated("Replayed title", 1)); | ||
| 428 | yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); | ||
| 429 | |||
| 430 | const state = yield* awaitThreadState( | ||
| 431 | harness.observed, | ||
| 432 | (value) => | ||
| 433 | value.status === "live" && | ||
| 434 | Option.isSome(value.data) && | ||
| 435 | value.data.value.title === "Live title", | ||
| 436 | ); | ||
| 437 | |||
| 438 | expect(Option.getOrThrow(state.data).title).toBe("Live title"); | ||
| 439 | }), | ||
| 440 | ); | ||
| 441 | |||
| 442 | it.effect("removes cached data when the thread is deleted", () => | ||
| 443 | Effect.gen(function* () { | ||
| 444 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 445 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 446 | yield* Queue.offer(harness.inputs, deleted()); | ||
| 447 | |||
| 448 | const state = yield* awaitThreadState( | ||
| 449 | harness.observed, | ||
| 450 | (value) => value.status === "deleted", | ||
| 451 | ); | ||
| 452 | |||
| 453 | expect(Option.isNone(state.data)).toBe(true); | ||
| 454 | expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); | ||
| 455 | }), | ||
| 456 | ); | ||
| 457 | |||
| 458 | it.effect("does not resurrect a deleted thread when the app returns to the foreground", () => | ||
| 459 | Effect.gen(function* () { | ||
| 460 | const harness = yield* makeHarness({ | ||
| 461 | cached: BASE_THREAD, | ||
| 462 | completionMarker: true, | ||
| 463 | httpSnapshot: Option.some({ | ||
| 464 | snapshotSequence: 4, | ||
| 465 | thread: { ...BASE_THREAD, title: "Stale HTTP thread" }, | ||
| 466 | }), | ||
| 467 | }); | 65 | }); |
| 468 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | 66 | const state = yield* makeEnvironmentThreadState(v2ThreadId).pipe( |
| 469 | yield* Queue.offer(harness.inputs, deleted()); | 67 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 470 | yield* awaitThreadState(harness.observed, (value) => value.status === "deleted"); | 68 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), |
| 471 | |||
| 472 | expect(yield* Ref.get(harness.loaderCalls)).toBe(0); | ||
| 473 | yield* Queue.offer(harness.wakeups, "application-active"); | ||
| 474 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 475 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; | ||
| 476 | yield* Effect.yieldNow; | ||
| 477 | } | ||
| 478 | |||
| 479 | const latest = yield* Ref.get(harness.latest); | ||
| 480 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | ||
| 481 | expect(yield* Ref.get(harness.loaderCalls)).toBe(0); | ||
| 482 | expect(latest.status).toBe("deleted"); | ||
| 483 | expect(Option.isNone(latest.data)).toBe(true); | ||
| 484 | }), | ||
| 485 | ); | ||
| 486 | |||
| 487 | it.effect("preserves data after a domain failure and resumes on a replacement session", () => | ||
| 488 | Effect.gen(function* () { | ||
| 489 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 490 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 491 | yield* Queue.offer(harness.inputs, new Error("stream failed")); | ||
| 492 | |||
| 493 | const state = yield* awaitThreadState(harness.observed, (value) => | ||
| 494 | Option.isSome(value.error), | ||
| 495 | ); | ||
| 496 | |||
| 497 | expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); | ||
| 498 | expect(Option.getOrThrow(state.error)).toBe("stream failed"); | ||
| 499 | expect(yield* Ref.get(harness.retryCount)).toBe(0); | ||
| 500 | |||
| 501 | yield* harness.replaceSession; | ||
| 502 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 503 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { | ||
| 504 | break; | ||
| 505 | } | ||
| 506 | yield* Effect.yieldNow; | ||
| 507 | } | ||
| 508 | yield* Queue.offer( | ||
| 509 | harness.inputs, | ||
| 510 | snapshot({ | ||
| 511 | ...BASE_THREAD, | ||
| 512 | title: "Recovered thread", | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | const recovered = yield* awaitThreadState( | ||
| 516 | harness.observed, | ||
| 517 | (value) => | ||
| 518 | value.status === "live" && | ||
| 519 | Option.isSome(value.data) && | ||
| 520 | value.data.value.title === "Recovered thread", | ||
| 521 | ); | ||
| 522 | |||
| 523 | expect(Option.isNone(recovered.error)).toBe(true); | ||
| 524 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | ||
| 525 | }), | ||
| 526 | ); | ||
| 527 | |||
| 528 | it.effect("recovers from a transient domain failure without replacing the session", () => | ||
| 529 | Effect.gen(function* () { | ||
| 530 | const harness = yield* makeHarness(); | ||
| 531 | yield* Queue.offer(harness.inputs, new Error("thread not found yet")); | ||
| 532 | |||
| 533 | const failed = yield* awaitThreadState(harness.observed, (value) => | ||
| 534 | Option.isSome(value.error), | ||
| 535 | ); | ||
| 536 | expect(Option.getOrThrow(failed.error)).toBe("thread not found yet"); | ||
| 537 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); | ||
| 538 | |||
| 539 | yield* TestClock.adjust("250 millis"); | ||
| 540 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 541 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { | ||
| 542 | break; | ||
| 543 | } | ||
| 544 | yield* Effect.yieldNow; | ||
| 545 | } | ||
| 546 | yield* Queue.offer( | ||
| 547 | harness.inputs, | ||
| 548 | snapshot({ | ||
| 549 | ...BASE_THREAD, | ||
| 550 | title: "Materialized thread", | ||
| 551 | }), | ||
| 552 | ); | ||
| 553 | |||
| 554 | const recovered = yield* awaitThreadState( | ||
| 555 | harness.observed, | ||
| 556 | (value) => | ||
| 557 | value.status === "live" && | ||
| 558 | Option.isSome(value.data) && | ||
| 559 | value.data.value.title === "Materialized thread", | ||
| 560 | ); | 69 | ); |
| 561 | 70 | ||
| 562 | expect(Option.isNone(recovered.error)).toBe(true); | 71 | yield* Queue.offer(events, { |
| 563 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | 72 | kind: "snapshot", |
| 564 | expect(yield* Ref.get(harness.retryCount)).toBe(0); | 73 | snapshotSequence: 1, |
| 565 | }), | 74 | projection: v2Projection, |
| 566 | ); | ||
| 567 | |||
| 568 | it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () => | ||
| 569 | Effect.gen(function* () { | ||
| 570 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 571 | yield* SubscriptionRef.set(harness.supervisorState, { | ||
| 572 | desired: true, | ||
| 573 | network: "online", | ||
| 574 | phase: "connecting", | ||
| 575 | stage: "synchronizing", | ||
| 576 | attempt: 1, | ||
| 577 | generation: 0, | ||
| 578 | lastFailure: null, | ||
| 579 | retryAt: null, | ||
| 580 | }); | 75 | }); |
| 581 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | 76 | yield* SubscriptionRef.changes(state).pipe( |
| 582 | yield* awaitThreadState(harness.observed, (value) => value.status === "live"); | 77 | Stream.filter((current) => current.status === "live"), |
| 583 | 78 | Stream.runHead, | |
| 584 | yield* SubscriptionRef.set(harness.supervisorState, { | 79 | ); |
| 585 | desired: true, | 80 | |
| 586 | network: "online", | 81 | const occurredAt = DateTime.makeUnsafe("2026-06-20T02:00:00.000Z"); |
| 587 | phase: "connected", | 82 | yield* Queue.offer(events, { |
| 588 | stage: null, | 83 | kind: "event", |
| 589 | attempt: 1, | 84 | sequence: 2, |
| 590 | generation: 1, | 85 | event: { |
| 591 | lastFailure: null, | 86 | id: EventId.make("event-title"), |
| 592 | retryAt: null, | 87 | type: "thread.metadata-updated", |
| 88 | threadId: v2ThreadId, | ||
| 89 | occurredAt, | ||
| 90 | payload: { ...v2Projection.thread, title: "Updated", updatedAt: occurredAt }, | ||
| 91 | }, | ||
| 593 | }); | 92 | }); |
| 594 | for (let index = 0; index < 10; index += 1) { | 93 | yield* SubscriptionRef.changes(state).pipe( |
| 595 | yield* Effect.yieldNow; | 94 | Stream.filter((current) => Option.getOrNull(current.data)?.thread.title === "Updated"), |
| 596 | } | 95 | Stream.runHead, |
| 96 | ); | ||
| 597 | 97 | ||
| 598 | expect((yield* Ref.get(harness.latest)).status).toBe("live"); | 98 | expect(Option.getOrThrow((yield* SubscriptionRef.get(state)).data).thread.title).toBe( |
| 99 | "Updated", | ||
| 100 | ); | ||
| 599 | }), | 101 | }), |
| 600 | ); | 102 | ); |
| 601 | 103 | ||
| @@ -1,9 +1,8 @@ | |||
| 1 | import { | 1 | import { |
| 2 | ORCHESTRATION_WS_METHODS, | 2 | ORCHESTRATION_V2_WS_METHODS, |
| 3 | type EnvironmentId as EnvironmentIdType, | 3 | type EnvironmentId as EnvironmentIdType, |
| 4 | type OrchestrationThread, | 4 | type OrchestrationV2ThreadProjection, |
| 5 | type OrchestrationThreadDetailSnapshot, | 5 | type OrchestrationV2ThreadStreamItem, |
| 6 | type OrchestrationThreadStreamItem, | ||
| 7 | type ThreadId as ThreadIdType, | 6 | type ThreadId as ThreadIdType, |
| 8 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; | 8 | import * as Cause from "effect/Cause"; |
| @@ -23,16 +22,27 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; · 13 unmodified lines | |||
| 23 | import { subscribeDynamic } from "../rpc/client.ts"; | 22 | import { subscribeDynamic } from "../rpc/client.ts"; |
| 24 | import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; | 23 | import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; |
| 25 | import { parseThreadKey, threadKey } from "./entities.ts"; | 24 | import { parseThreadKey, threadKey } from "./entities.ts"; |
| 26 | import { applyThreadDetailEvent } from "./threadReducer.ts"; | 25 | import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; |
| 27 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; | 26 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 28 | import { followStreamInEnvironment } from "./runtime.ts"; | 27 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 29 | import { | ||
| 30 | EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 31 | type EnvironmentThreadState, | ||
| 32 | type EnvironmentThreadStatus, | ||
| 33 | } from "./threadState.ts"; | ||
| 34 | 28 | ||
| 35 | function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus { | 29 | export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; |
| 30 | |||
| 31 | export interface EnvironmentThreadState { | ||
| 32 | readonly data: Option.Option<OrchestrationV2ThreadProjection>; | ||
| 33 | readonly status: EnvironmentThreadStatus; | ||
| 34 | readonly error: Option.Option<string>; | ||
| 35 | } | ||
| 36 | |||
| 37 | export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { | ||
| 38 | data: Option.none(), | ||
| 39 | status: "empty", | ||
| 40 | error: Option.none(), | ||
| 41 | }; | ||
| 42 | |||
| 43 | function statusWithoutLiveData( | ||
| 44 | data: Option.Option<OrchestrationV2ThreadProjection>, | ||
| 45 | ): EnvironmentThreadStatus { | ||
| 36 | return Option.isSome(data) ? "cached" : "empty"; | 46 | return Option.isSome(data) ? "cached" : "empty"; |
| 37 | } | 47 | } |
| 38 | 48 | ||
| @@ -64,7 +74,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 25 unmodified lines | |||
| 64 | threadId, | 74 | threadId, |
| 65 | error: error.message, | 75 | error: error.message, |
| 66 | }), | 76 | }), |
| 67 | Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()), | 77 | Effect.as(Option.none<OrchestrationV2ThreadProjection>()), |
| 68 | ), | 78 | ), |
| 69 | ), | 79 | ), |
| 70 | ); | 80 | ); |
| @@ -74,16 +84,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 3 unmodified lines | |||
| 74 | status: statusWithoutLiveData(cachedThread), | 84 | status: statusWithoutLiveData(cachedThread), |
| 75 | error: Option.none(), | 85 | error: Option.none(), |
| 76 | }); | 86 | }); |
| 77 | // Seed the resume cursor from the cached snapshot so a warm cache can catch up | ||
| 78 | // via `afterSequence` instead of re-downloading the full thread body. | ||
| 79 | const lastSequence = yield* SubscriptionRef.make( | ||
| 80 | Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), | ||
| 81 | ); | ||
147 more lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 74 lines aren’t shown here.
This diff is large: its 53 lines aren’t shown here.