- 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 | 20 | - Shape 3 performs the backend hard cut and intentionally breaks the old client protocol. |
| 21 | 21 | - Shape 4.0 updates web and mobile to the final protocol while preserving the current product experience and component structure. |
| 22 | 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 | 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 | 26 | - Do not drop V1 tables, migrations, provider runtime rows, or attachment files during this plan. |
| 26 | 27 | - V1 removal happens inside the shape that replaces it; there is no deferred general cleanup phase. |
| 27 | 28 | |
| 28 | 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 | 33 | The historical pre-cut flow was: |
| 33 | 34 | |
| @@ -40,21 +41,22 @@ Web/mobile model and mode selection · 6 unmodified lines | ||
| 40 | 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 | 46 | ```text |
| 46 | 47 | ordinary web/mobile state and commands |
| 47 | − -> orchestrationV1.* RPCs | |
| 48 | − -> no server handler after the Shape 3 hard cut | |
| 49 | − | |
| 50 | −V2 debug client, CLI, MCP, and backend consumers | |
| 48 | + -> shared client-runtime V2 shell and thread projections | |
| 51 | 49 | -> final orchestration.* RPCs / V2 application services |
| 52 | 50 | -> shared application event transaction |
| 53 | 51 | -> Agent Orchestrator V2 |
| 54 | 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 | 61 | Current ownership and intended treatment: |
| 60 | 62 | |
| @@ -315,7 +317,7 @@ Add coverage for: · 254 unmodified lines | ||
| 315 | 317 | |
| 316 | 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 | 322 | ### Goal |
| 321 | 323 | |
| @@ -702,7 +704,16 @@ Add backend integration coverage for: · 380 unmodified lines | ||
| 702 | 704 | |
| 703 | 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 | 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 | 727 | - Reuse existing sidebar, chat, composer, approval, archive, diff, settings, and model-picker components where their behavior remains correct. |
| 717 | 728 | - Generalize component props around V2-native view models instead of manufacturing V1 commands, events, activities, or thread projections. |
| 718 | 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 | 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 | 733 | - Defer graph visualization, rich execution inspection, and provider-native tool presentation to Shape 4.5. |
| 721 | 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 | 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 | 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 | 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 | 768 | ### 3. Replace production client commands |
| 756 | 769 | |
| @@ -847,6 +860,9 @@ Add web and mobile integration coverage for: · 90 unmodified lines | ||
| 847 | 860 | - A user can create a project and thread, converse with an agent, reconnect, respond to requests, inspect diffs, archive, and delete. |
| 848 | 861 | - Existing V1-era product behavior that remains supported by V2 is present without routing through V1 data shapes. |
| 849 | 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 | 866 | - No production code calls an `orchestrationV1.*` endpoint. |
| 851 | 867 | - No V2-to-V1 server or persistence compatibility projection exists. |
| 852 | 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 | 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 | 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` | |
| 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: | |
| 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. | |
| 897 | 885 | |
| 898 | −- root, parent, child, fork, and subagent relationships | |
| 899 | −- navigation between related threads | |
| 900 | −- per-node run and terminal status | |
| 901 | −- merge-back and context-transfer state | |
| 902 | −- archived or deleted related threads | |
| 903 | −- missing-parent, partial-replay, and cycle-safe fallback behavior | |
| 886 | +### 1. Establish the scoped projection and item atom boundary | |
| 887 | + | |
| 888 | +Keep shell and detail as independent sources: | |
| 889 | + | |
| 890 | +- `EnvironmentThreadShell` remains the environment-scoped, list-oriented view of the server shell stream. | |
| 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 | 956 | - file changes with affected paths and patch/diff navigation |
| 913 | 957 | - file, code, and web search with query and result provenance |
| 914 | −- MCP and provider-native tool calls | |
| 915 | −- dynamic tools with complete structured input/output fallback | |
| 958 | +- MCP, dynamic, and provider-native tool calls with complete structured fallback | |
| 916 | 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 | |
| 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 | |
| 973 | +Build environment-scoped, cycle-safe graph/lineage derivation from shell threads, thread lineage, execution nodes, subagents, forks, and context transfers. It must support: | |
| 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 | 986 | - thread fork and merge-back |
| 940 | 987 | - provider switching and provider-session detach |
| 941 | 988 | - advanced queue reorder and promotion |
| 942 | 989 | - subagent navigation and delegated-task state |
| 943 | 990 | - plan/todo progress |
| 944 | −- checkpoint scopes, diff, and rollback | |
| 991 | +- checkpoint scope, diff, and rollback | |
| 945 | 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. | |
| 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. | |
| 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. | |
| 958 | 1008 | |
| 959 | 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 | |
| 964 | −- graph construction for roots, forks, subagents, merges, archives, and missing nodes | |
| 965 | −- incremental updates without duplicate graph nodes or timeline rows | |
| 966 | −- run attempts, retry, recovery, and interruption presentation | |
| 967 | −- provider-session transition and context-handoff presentation | |
| 968 | −- plans, todos, checkpoints, and rollback | |
| 1013 | +- environment-scoped detail identity without copying or mutating the server projection | |
| 1014 | +- cached, synchronizing, live, deleted, and error state independent of projection content | |
| 1015 | +- preservation of local, inherited, and synthetic `visibleTurnItems` | |
| 1016 | +- unchanged `visibleTurnItems` identity for run/attempt updates that do not change visibility | |
| 1017 | +- single-row replacement and unaffected-row identity for streaming item updates | |
| 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 | 1021 | - capability-gated action availability |
| 970 | 1022 | |
| 971 | 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 | 1030 | - related-thread navigation |
| 974 | −- rich tool-call expansion and live updates | |
| 975 | 1031 | - fork and merge-back |
| 976 | 1032 | - subagent and delegated-task inspection |
| 977 | 1033 | - queue reorder and promotion |
| 978 | 1034 | - provider switch and detach |
| 979 | 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 | 1039 | ### Shape 4.5 exit gate |
| 983 | 1040 | |
| 984 | 1041 | - Web and mobile remain fully usable for every Shape 4.0 flow. |
| 985 | −- Thread lineage, forks, subagents, and merge relationships are navigable. | |
| 986 | −- Typed tool calls and their structured results are visible without losing provider-native fallback data. | |
| 987 | −- Runs, attempts, retries, recovery, plans, checkpoints, and handoffs have coherent presentation. | |
| 1042 | +- Production web and mobile conversations render from server-authoritative `visibleTurnItems`; neither reconstructs the chat by merging separate message, plan, and work-entry collections. | |
| 1043 | +- Thread detail values carry explicit environment scope while retaining the pristine V2 projection and nested entity identities. | |
| 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 | 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 | 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 | 1057 | - `vp check`, `vp run typecheck`, and `vp test` pass. |
| 992 | 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 | 1077 | At the end of Shape 4.5: |
| 1012 | 1078 | |
| 1013 | 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 | 1082 | - V1 runtime, server endpoints, production client state, and production client operations are removed. |
| 1016 | 1083 | - Reusable application event-sourcing and platform infrastructure remain. |
| 1017 | 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 | 2 | ConnectionPersistenceError, |
| 3 | 3 | ConnectionRegistrationStore, |
| 4 | 4 | ConnectionTargetStore, |
| 5 | + EnvironmentCacheStore, | |
| 6 | + ORCHESTRATION_CACHE_SCHEMA_VERSION, | |
| 7 | + StoredOrchestrationShellSnapshot, | |
| 8 | + StoredOrchestrationThreadSnapshot, | |
| 9 | + decodeOrDiscardOrchestrationCache, | |
| 5 | 10 | registerConnectionInCatalog, |
| 6 | 11 | removeConnectionFromCatalog, |
| 7 | 12 | removeCatalogValue, |
| @@ -13,11 +18,88 @@ import { · 5 unmodified lines | ||
| 13 | 18 | CredentialStore, |
| 14 | 19 | ProfileStore, |
| 15 | 20 | } from "@t3tools/client-runtime/connection"; |
| 21 | +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 16 | 22 | import * as Context from "effect/Context"; |
| 17 | 23 | import * as Effect from "effect/Effect"; |
| 18 | 24 | import * as Layer from "effect/Layer"; |
| 19 | 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 | 104 | function targetPersistenceError( |
| 23 | 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 | 379 | return Context.make(ConnectionTargetStore, targetStore).pipe( |
| 132 | 380 | Context.add(ConnectionRegistrationStore, registrationStore), |
| 133 | 381 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; |
| 2 | −import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts"; | |
| 3 | −import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | +import type { OrchestrationProjectShell, OrchestrationV2ThreadShell } from "@t3tools/contracts"; | |
| 3 | +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 4 | 4 | import { describe, expect, it } from "vite-plus/test"; |
| 5 | +import * as DateTime from "effect/DateTime"; | |
| 5 | 6 | |
| 6 | 7 | import { buildArchivedThreadGroups } from "./archivedThreadList"; |
| 8 | +import { makeRawThreadShell } from "../../test-fixtures"; | |
| 7 | 9 | |
| 8 | 10 | const environmentId = EnvironmentId.make("environment-1"); |
| 9 | 11 | |
| @@ -22,42 +24,30 @@ function makeProject( · 12 unmodified lines | ||
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | function makeThread( |
| 25 | − input: Partial<OrchestrationThreadShell> & | |
| 26 | − Pick<OrchestrationThreadShell, "id" | "projectId" | "title">, | |
| 27 | −): OrchestrationThreadShell { | |
| 28 | − return { | |
| 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-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, | |
| 27 | + input: Pick<OrchestrationV2ThreadShell, "id" | "projectId" | "title"> & { | |
| 28 | + readonly branch?: string | null; | |
| 29 | + readonly archivedAt?: string | null; | |
| 30 | + }, | |
| 31 | +): OrchestrationV2ThreadShell { | |
| 32 | + const archivedAt = input.archivedAt === undefined ? "2026-06-02T00:00:00.000Z" : input.archivedAt; | |
| 33 | + return makeRawThreadShell({ | |
| 43 | 34 | ...input, |
| 44 | − settledOverride: input.settledOverride ?? null, | |
| 45 | − settledAt: input.settledAt ?? null, | |
| 46 | − }; | |
| 35 | + archivedAt: archivedAt === null ? null : DateTime.makeUnsafe(archivedAt), | |
| 36 | + }); | |
| 47 | 37 | } |
| 48 | 38 | |
| 49 | 39 | function makeSnapshot( |
| 50 | 40 | projects: ReadonlyArray<OrchestrationProjectShell>, |
| 51 | − threads: ReadonlyArray<OrchestrationThreadShell>, | |
| 41 | + threads: ReadonlyArray<OrchestrationV2ThreadShell>, | |
| 52 | 42 | targetEnvironmentId = environmentId, |
| 53 | 43 | ): ArchivedSnapshotEntry { |
| 54 | 44 | return { |
| 55 | 45 | environmentId: targetEnvironmentId, |
| 56 | 46 | snapshot: { |
| 47 | + schemaVersion: 1, | |
| 57 | 48 | snapshotSequence: 1, |
| 58 | 49 | projects, |
| 59 | 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 | 119 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| 120 | 120 | |
| 121 | −const ESTIMATED_THREAD_ROW_HEIGHT = 72; | |
| 122 | −const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; | |
| 123 | −/** | |
| 124 | − * Top spacing between the list and the Android custom header. The Android | |
| 125 | − * header (AndroidHomeHeader) is rendered in-flow above this screen and | |
| 126 | − * already consumes the top safe-area inset, so the list only needs breathing | |
| 127 | − * room here. | |
| 128 | − */ | |
| 121 | +function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { | |
| 122 | + switch (thread.runtime?.status) { | |
| 123 | + case "running": | |
| 124 | + case "waiting": | |
| 125 | + return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; | |
| 126 | + case "completed": | |
| 127 | + return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" }; | |
| 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 | 167 | function deriveEmptyState(props: { |
| 131 | 168 | readonly catalogState: WorkspaceState; |
| @@ -185,7 +222,7 @@ function deriveEmptyState(props: { · 53 unmodified lines | ||
| 185 | 222 | |
| 186 | 223 | return { |
| 187 | 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 | 226 | loading: false, |
| 190 | 227 | }; |
| 191 | 228 | } |
| @@ -971,25 +1008,17 @@ export function HomeScreen(props: HomeScreenProps) { · 779 unmodified lines | ||
| 971 | 1008 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} |
| 972 | 1009 | variant="plain" |
| 973 | 1010 | /> |
| 974 | − {emptyState.loading && !shouldShowConnectionStatus ? ( | |
| 975 | − <View className="mt-4 items-center"> | |
| 976 | − <ActivityIndicator color={accentColor} /> | |
| 977 | − </View> | |
| 978 | − ) : null} | |
| 979 | − {shouldShowConnectionStatus && Platform.OS === "ios" ? ( | |
| 980 | − <View className="mt-4"> | |
| 981 | − <WorkspaceConnectionStatus | |
| 982 | − state={props.catalogState} | |
| 983 | − onPress={props.onOpenEnvironments} | |
| 984 | − variant="sidebar" | |
| 985 | − /> | |
| 986 | − </View> | |
| 987 | − ) : null} | |
| 988 | − </View> | |
| 989 | − {connectionStatus} | |
| 990 | − </View> | |
| 991 | − ); | |
| 992 | − } | |
| 1011 | + ) : !hasResults ? ( | |
| 1012 | + <EmptyState | |
| 1013 | + title="No threads yet" | |
| 1014 | + detail="Create a task to start a new coding runtime." | |
| 1015 | + /> | |
| 1016 | + ) : ( | |
| 1017 | + projectGroups.map((group) => { | |
| 1018 | + const isExpanded = expandedProjects.has(group.key); | |
| 1019 | + const visibleThreads = isExpanded | |
| 1020 | + ? group.threads | |
| 1021 | + : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); | |
| 993 | 1022 | |
| 994 | 1023 | const listHeader = ( |
| 995 | 1024 | <> |
| @@ -6,11 +6,8 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc · 5 unmodified lines | ||
| 6 | 6 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 7 | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 8 | |
| 9 | −import { | |
| 10 | − buildHomeProjectScopes, | |
| 11 | − buildHomeThreadGroups, | |
| 12 | − sortHomeProjectScopes, | |
| 13 | −} from "./homeThreadList"; | |
| 9 | +import { buildHomeThreadGroups } from "./homeThreadList"; | |
| 10 | +import { makeThreadShellFixture } from "../../test-fixtures"; | |
| 14 | 11 | |
| 15 | 12 | function makeProject( |
| 16 | 13 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| @@ -30,25 +27,21 @@ function makeThread( · 13 unmodified lines | ||
| 30 | 27 | input: Partial<EnvironmentThreadShell> & |
| 31 | 28 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">, |
| 32 | 29 | ): EnvironmentThreadShell { |
| 33 | − return { | |
| 30 | + return makeThreadShellFixture({ | |
| 34 | 31 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 35 | 32 | runtimeMode: "full-access", |
| 36 | 33 | interactionMode: "default", |
| 37 | 34 | branch: null, |
| 38 | 35 | worktreePath: null, |
| 39 | − latestTurn: null, | |
| 40 | 36 | createdAt: "2026-06-01T00:00:00.000Z", |
| 41 | 37 | updatedAt: "2026-06-01T00:00:00.000Z", |
| 42 | 38 | archivedAt: null, |
| 43 | − session: null, | |
| 44 | 39 | latestUserMessageAt: null, |
| 45 | 40 | hasPendingApprovals: false, |
| 46 | 41 | hasPendingUserInput: false, |
| 47 | 42 | hasActionableProposedPlan: false, |
| 48 | 43 | ...input, |
| 49 | − settledOverride: input.settledOverride ?? null, | |
| 50 | − settledAt: input.settledAt ?? null, | |
| 51 | − }; | |
| 44 | + }); | |
| 52 | 45 | } |
| 53 | 46 | |
| 54 | 47 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); |
| @@ -1,11 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | −import { | |
| 4 | − MessageId, | |
| 5 | − TurnId, | |
| 6 | − type OrchestrationCheckpointSummary, | |
| 7 | − type ReviewDiffPreviewSource, | |
| 8 | −} from "@t3tools/contracts"; | |
| 3 | +import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contracts"; | |
| 4 | +import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell"; | |
| 9 | 5 | |
| 10 | 6 | import { |
| 11 | 7 | buildReviewListItems, |
| @@ -18,9 +14,9 @@ import { · 6 unmodified lines | ||
| 18 | 14 | } from "./reviewModel"; |
| 19 | 15 | |
| 20 | 16 | function makeCheckpoint( |
| 21 | − input: Partial<OrchestrationCheckpointSummary> & | |
| 22 | − Pick<OrchestrationCheckpointSummary, "turnId" | "checkpointTurnCount" | "completedAt">, | |
| 23 | −): OrchestrationCheckpointSummary { | |
| 17 | + input: Partial<ThreadCheckpointSummary> & | |
| 18 | + Pick<ThreadCheckpointSummary, "runId" | "checkpointTurnCount" | "completedAt">, | |
| 19 | +): ThreadCheckpointSummary { | |
| 24 | 20 | return { |
| 25 | 21 | checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any, |
| 26 | 22 | status: "ready", |
| @@ -52,12 +48,12 @@ describe("buildReviewSectionItems", () => { · 25 unmodified lines | ||
| 52 | 48 | it("keeps one chip per checkpoint and appends git sources", () => { |
| 53 | 49 | const checkpoints = [ |
| 54 | 50 | makeCheckpoint({ |
| 55 | − turnId: TurnId.make("turn-1"), | |
| 51 | + runId: RunId.make("run-1"), | |
| 56 | 52 | checkpointTurnCount: 1, |
| 57 | 53 | completedAt: "2026-04-01T00:00:00.000Z", |
| 58 | 54 | }), |
| 59 | 55 | makeCheckpoint({ |
| 60 | − turnId: TurnId.make("turn-2"), | |
| 56 | + runId: RunId.make("run-2"), | |
| 61 | 57 | checkpointTurnCount: 2, |
| 62 | 58 | completedAt: "2026-04-02T00:00:00.000Z", |
| 63 | 59 | }), |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; |
| 2 | 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 | 5 | import * as Arr from "effect/Array"; |
| 5 | 6 | import { pipe } from "effect/Function"; |
| 6 | 7 | import * as Order from "effect/Order"; |
| @@ -128,11 +129,11 @@ export type ReviewParsedDiff = · 121 unmodified lines | ||
| 128 | 129 | readonly notice: string | null; |
| 129 | 130 | }; |
| 130 | 131 | |
| 131 | −function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string { | |
| 132 | +function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { | |
| 132 | 133 | return `Turn ${checkpoint.checkpointTurnCount}`; |
| 133 | 134 | } |
| 134 | 135 | |
| 135 | −function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string { | |
| 136 | +function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { | |
| 136 | 137 | const fileCount = checkpoint.files.length; |
| 137 | 138 | if (checkpoint.status !== "ready") { |
| 138 | 139 | return `Diff ${checkpoint.status}`; |
| @@ -141,8 +142,8 @@ function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string · 2 unmodified lines | ||
| 141 | 142 | } |
| 142 | 143 | |
| 143 | 144 | function compareCheckpointTurnCountDescending( |
| 144 | − left: OrchestrationCheckpointSummary, | |
| 145 | − right: OrchestrationCheckpointSummary, | |
| 145 | + left: ThreadCheckpointSummary, | |
| 146 | + right: ThreadCheckpointSummary, | |
| 146 | 147 | ): -1 | 0 | 1 { |
| 147 | 148 | if (left.checkpointTurnCount === right.checkpointTurnCount) { |
| 148 | 149 | return 0; |
| @@ -151,7 +152,7 @@ function compareCheckpointTurnCountDescending( · 2 unmodified lines | ||
| 151 | 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 | 156 | compareCheckpointTurnCountDescending, |
| 156 | 157 | ); |
| 157 | 158 | |
| @@ -510,14 +511,14 @@ function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { · 352 unmodified lines | ||
| 510 | 511 | } |
| 511 | 512 | |
| 512 | 513 | export function getReviewSectionIdForCheckpoint( |
| 513 | − checkpoint: Pick<OrchestrationCheckpointSummary, "checkpointTurnCount">, | |
| 514 | + checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount">, | |
| 514 | 515 | ): string { |
| 515 | 516 | return `turn:${checkpoint.checkpointTurnCount}`; |
| 516 | 517 | } |
| 517 | 518 | |
| 518 | 519 | export function getReadyReviewCheckpoints( |
| 519 | − checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | |
| 520 | −): ReadonlyArray<OrchestrationCheckpointSummary> { | |
| 520 | + checkpoints: ReadonlyArray<ThreadCheckpointSummary>, | |
| 521 | +): ReadonlyArray<ThreadCheckpointSummary> { | |
| 521 | 522 | return pipe( |
| 522 | 523 | checkpoints, |
| 523 | 524 | Arr.filter((checkpoint) => checkpoint.status === "ready"), |
| @@ -526,7 +527,7 @@ export function getReadyReviewCheckpoints( · 2 unmodified lines | ||
| 526 | 527 | } |
| 527 | 528 | |
| 528 | 529 | export function buildReviewSectionItems(input: { |
| 529 | − readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>; | |
| 530 | + readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>; | |
| 530 | 531 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; |
| 531 | 532 | readonly turnDiffById: Readonly<Record<string, string | undefined>>; |
| 532 | 533 | readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>; |
| @@ -1,6 +1,7 @@ | ||
| 1 | 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 | 6 | import { useCheckpointDiff } from "../../state/queries"; |
| 6 | 7 | import { useEnvironmentQuery } from "../../state/query"; |
| @@ -59,7 +60,7 @@ export function useReviewSections(input: { · 52 unmodified lines | ||
| 59 | 60 | getReviewSectionIdForCheckpoint(checkpoint), |
| 60 | 61 | checkpoint, |
| 61 | 62 | ]), |
| 62 | − ) as Record<string, OrchestrationCheckpointSummary>, | |
| 63 | + ) as Record<string, ThreadCheckpointSummary>, | |
| 63 | 64 | [readyCheckpoints], |
| 64 | 65 | ); |
| 65 | 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 | 2 | import { Pressable, View } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { AppText as Text } from "../../components/AppText"; |
| @@ -6,14 +6,16 @@ import type { PendingApproval } from "../../lib/threadActivity"; · 1 unmodified line | ||
| 6 | 6 | |
| 7 | 7 | export interface PendingApprovalCardProps { |
| 8 | 8 | readonly approval: PendingApproval; |
| 9 | − readonly respondingApprovalId: ApprovalRequestId | null; | |
| 9 | + readonly respondingApprovalId: RuntimeRequestId | null; | |
| 10 | 10 | readonly onRespond: ( |
| 11 | − requestId: ApprovalRequestId, | |
| 11 | + requestId: RuntimeRequestId, | |
| 12 | 12 | decision: ProviderApprovalDecision, |
| 13 | 13 | ) => Promise<unknown>; |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | 16 | export function PendingApprovalCard(props: PendingApprovalCardProps) { |
| 17 | + const canRespond = props.approval.responseCapability === "live"; | |
| 18 | + const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId; | |
| 17 | 19 | return ( |
| 18 | 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 | 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 | 29 | {props.approval.detail} |
| 28 | 30 | </Text> |
| 29 | 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 | 38 | <View className="flex-row flex-wrap gap-2.5"> |
| 31 | 39 | <Pressable |
| 32 | 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 | 42 | onPress={() => void props.onRespond(props.approval.requestId, "accept")} |
| 35 | 43 | > |
| 36 | 44 | <Text className="font-t3-extrabold text-sm text-white">Allow once</Text> |
| 37 | 45 | </Pressable> |
| 38 | 46 | <Pressable |
| 39 | 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 | 49 | onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")} |
| 42 | 50 | > |
| 43 | 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 | 54 | </Pressable> |
| 47 | 55 | <Pressable |
| 48 | 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 | 58 | onPress={() => void props.onRespond(props.approval.requestId, "decline")} |
| 51 | 59 | > |
| 52 | 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 | 2 | import { Pressable, View } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| @@ -9,14 +9,10 @@ export interface PendingUserInputCardProps { · 4 unmodified lines | ||
| 9 | 9 | readonly pendingUserInput: PendingUserInput; |
| 10 | 10 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; |
| 11 | 11 | readonly answers: Record<string, string> | null; |
| 12 | − readonly respondingUserInputId: ApprovalRequestId | null; | |
| 13 | − readonly onSelectOption: ( | |
| 14 | − requestId: ApprovalRequestId, | |
| 15 | − questionId: string, | |
| 16 | − label: string, | |
| 17 | − ) => void; | |
| 12 | + readonly respondingUserInputId: RuntimeRequestId | null; | |
| 13 | + readonly onSelectOption: (requestId: RuntimeRequestId, questionId: string, label: string) => void; | |
| 18 | 14 | readonly onChangeCustomAnswer: ( |
| 19 | − requestId: ApprovalRequestId, | |
| 15 | + requestId: RuntimeRequestId, | |
| 20 | 16 | questionId: string, |
| 21 | 17 | customAnswer: string, |
| 22 | 18 | ) => void; |
| @@ -24,6 +20,7 @@ export interface PendingUserInputCardProps { · 1 unmodified line | ||
| 24 | 20 | } |
| 25 | 21 | |
| 26 | 22 | export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 23 | + const canRespond = props.pendingUserInput.responseCapability === "live"; | |
| 27 | 24 | return ( |
| 28 | 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 | 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 | 29 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> |
| 33 | 30 | Fill in the pending answers |
| 34 | 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 | 38 | {props.pendingUserInput.questions.map((question) => { |
| 36 | 39 | const draft = props.drafts[question.id]; |
| 37 | 40 | return ( |
| @@ -49,6 +52,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 11 unmodified lines | ||
| 49 | 52 | return ( |
| 50 | 53 | <Pressable |
| 51 | 54 | key={option.label} |
| 55 | + disabled={!canRespond} | |
| 52 | 56 | className={cn( |
| 53 | 57 | "rounded-full border px-3 py-2.5 ", |
| 54 | 58 | selected |
| @@ -78,6 +82,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 23 unmodified lines | ||
| 78 | 82 | })} |
| 79 | 83 | </View> |
| 80 | 84 | <TextInput |
| 85 | + editable={canRespond} | |
| 81 | 86 | value={draft?.customAnswer ?? ""} |
| 82 | 87 | onChangeText={(value) => |
| 83 | 88 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) |
| @@ -94,7 +99,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 10 unmodified lines | ||
| 94 | 99 | props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60", |
| 95 | 100 | )} |
| 96 | 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 | 106 | onPress={() => void props.onSubmit()} |
| 100 | 107 | > |
| @@ -1,9 +1,12 @@ | ||
| 1 | 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; |
| 2 | +import { | |
| 3 | + threadRuntimeIsActive, | |
| 4 | + type EnvironmentThreadShell, | |
| 5 | +} from "@t3tools/client-runtime/state/shell"; | |
| 2 | 6 | import type { |
| 3 | 7 | EnvironmentId, |
| 4 | 8 | MessageId, |
| 5 | 9 | ModelSelection, |
| 6 | − OrchestrationThreadShell, | |
| 7 | 10 | ProviderInteractionMode, |
| 8 | 11 | RuntimeMode, |
| 9 | 12 | ServerConfig as T3ServerConfig, |
| @@ -91,13 +94,7 @@ export interface ThreadComposerProps { · 81 unmodified lines | ||
| 91 | 94 | readonly connectionState: RemoteClientConnectionState; |
| 92 | 95 | readonly connectionError: string | null; |
| 93 | 96 | readonly environmentLabel: string | null; |
| 94 | − /** | |
| 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; | |
| 97 | + readonly selectedThread: EnvironmentThreadShell; | |
| 101 | 98 | readonly serverConfig: T3ServerConfig | null; |
| 102 | 99 | readonly queueCount: number; |
| 103 | 100 | readonly activeThreadBusy: boolean; |
| @@ -305,9 +302,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 201 unmodified lines | ||
| 305 | 302 | setIsFocused(false); |
| 306 | 303 | onExpandedChange?.(false); |
| 307 | 304 | }, [onExpandedChange]); |
| 308 | − const showStopAction = | |
| 309 | − props.selectedThread.session?.status === "running" || | |
| 310 | − props.selectedThread.session?.status === "starting"; | |
| 305 | + const showStopAction = threadRuntimeIsActive(props.selectedThread.runtime); | |
| 311 | 306 | |
| 312 | 307 | const sendLabel = |
| 313 | 308 | props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 |
| @@ -1,16 +1,15 @@ | ||
| 1 | 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 | 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 4 | 4 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 5 | 5 | import type { |
| 6 | − ApprovalRequestId, | |
| 7 | 6 | EnvironmentId, |
| 8 | 7 | MessageId, |
| 9 | 8 | ModelSelection, |
| 10 | − OrchestrationThreadShell, | |
| 11 | 9 | ProviderApprovalDecision, |
| 12 | 10 | ProviderInteractionMode, |
| 13 | 11 | RuntimeMode, |
| 12 | + RuntimeRequestId, | |
| 14 | 13 | ServerConfig as T3ServerConfig, |
| 15 | 14 | ThreadId, |
| 16 | 15 | } from "@t3tools/contracts"; |
| @@ -43,7 +42,7 @@ import { ThreadFeed } from "./ThreadFeed"; · 26 unmodified lines | ||
| 43 | 42 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 44 | 43 | |
| 45 | 44 | export interface ThreadDetailScreenProps { |
| 46 | − readonly selectedThread: OrchestrationThreadShell; | |
| 45 | + readonly selectedThread: EnvironmentThreadShell; | |
| 47 | 46 | readonly contentPresentation: ThreadContentPresentation; |
| 48 | 47 | readonly screenTone: StatusTone; |
| 49 | 48 | readonly connectionError: string | null; |
| @@ -51,11 +50,11 @@ export interface ThreadDetailScreenProps { · 1 unmodified line | ||
| 51 | 50 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; |
| 52 | 51 | readonly activeWorkStartedAt: string | null; |
| 53 | 52 | readonly activePendingApproval: PendingApproval | null; |
| 54 | − readonly respondingApprovalId: ApprovalRequestId | null; | |
| 53 | + readonly respondingApprovalId: RuntimeRequestId | null; | |
| 55 | 54 | readonly activePendingUserInput: PendingUserInput | null; |
| 56 | 55 | readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; |
| 57 | 56 | readonly activePendingUserInputAnswers: Record<string, string> | null; |
| 58 | − readonly respondingUserInputId: ApprovalRequestId | null; | |
| 57 | + readonly respondingUserInputId: RuntimeRequestId | null; | |
| 59 | 58 | readonly draftMessage: string; |
| 60 | 59 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 61 | 60 | readonly connectionStateLabel: EnvironmentConnectionPhase; |
| @@ -82,16 +81,16 @@ export interface ThreadDetailScreenProps { · 20 unmodified lines | ||
| 82 | 81 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| 83 | 82 | readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| 84 | 83 | readonly onRespondToApproval: ( |
| 85 | − requestId: ApprovalRequestId, | |
| 84 | + requestId: RuntimeRequestId, | |
| 86 | 85 | decision: ProviderApprovalDecision, |
| 87 | 86 | ) => Promise<unknown>; |
| 88 | 87 | readonly onSelectUserInputOption: ( |
| 89 | − requestId: ApprovalRequestId, | |
| 88 | + requestId: RuntimeRequestId, | |
| 90 | 89 | questionId: string, |
| 91 | 90 | label: string, |
| 92 | 91 | ) => void; |
| 93 | 92 | readonly onChangeUserInputCustomAnswer: ( |
| 94 | − requestId: ApprovalRequestId, | |
| 93 | + requestId: RuntimeRequestId, | |
| 95 | 94 | questionId: string, |
| 96 | 95 | customAnswer: string, |
| 97 | 96 | ) => void; |
| @@ -341,41 +340,49 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 243 unmodified lines | ||
| 341 | 340 | }, []); |
| 342 | 341 | |
| 343 | 342 | return ( |
| 344 | − <View className="flex-1"> | |
| 345 | − {showContent ? ( | |
| 346 | − <View | |
| 347 | − className="flex-1" | |
| 348 | − onTouchStart={handleFeedTouchStart} | |
| 349 | − onTouchMove={handleFeedTouchMove} | |
| 350 | − onTouchEnd={handleFeedTouchEnd} | |
| 351 | − onTouchCancel={handleFeedTouchCancel} | |
| 352 | − > | |
| 353 | − <ThreadFeed | |
| 354 | − key={props.selectedThread.id} | |
| 355 | − environmentId={props.environmentId} | |
| 356 | − threadId={props.selectedThread.id} | |
| 357 | − workspaceRoot={props.threadCwd} | |
| 358 | − feed={props.selectedThreadFeed} | |
| 359 | − contentPresentation={props.contentPresentation} | |
| 360 | − agentLabel={agentLabel} | |
| 361 | − latestTurn={props.selectedThread.latestTurn} | |
| 362 | − activeWorkStartedAt={props.activeWorkStartedAt} | |
| 363 | − listRef={listRef} | |
| 364 | − freeze={freeze} | |
| 365 | − anchorMessageId={anchorMessageId} | |
| 366 | − contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 367 | − contentTopInset={0} | |
| 368 | − contentBottomInset={estimatedOverlayHeight} | |
| 369 | − contentMaxWidth={contentMaxWidth} | |
| 370 | − layoutVariant={layoutVariant} | |
| 371 | − usesAutomaticContentInsets={props.usesAutomaticContentInsets} | |
| 372 | − onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | |
| 373 | − skills={selectedProviderSkills} | |
| 374 | − /> | |
| 375 | − </View> | |
| 376 | − ) : ( | |
| 377 | − <View className="flex-1" /> | |
| 378 | − )} | |
| 343 | + <GestureDetector gesture={headerDrawerGesture}> | |
| 344 | + <View className="flex-1"> | |
| 345 | + {showContent ? ( | |
| 346 | + <View | |
| 347 | + style={{ flex: 1 }} | |
| 348 | + onTouchStart={handleFeedTouchStart} | |
| 349 | + onTouchMove={handleFeedTouchMove} | |
| 350 | + onTouchEnd={handleFeedTouchEnd} | |
| 351 | + onTouchCancel={handleFeedTouchCancel} | |
| 352 | + > | |
| 353 | + <ThreadFeed | |
| 354 | + key={props.selectedThread.id} | |
| 355 | + environmentId={props.environmentId} | |
| 356 | + threadId={props.selectedThread.id} | |
| 357 | + workspaceRoot={props.threadCwd} | |
| 358 | + feed={props.selectedThreadFeed} | |
| 359 | + contentPresentation={props.contentPresentation} | |
| 360 | + agentLabel={agentLabel} | |
| 361 | + latestRun={props.selectedThread.latestRun} | |
| 362 | + listRef={listRef} | |
| 363 | + freeze={freeze} | |
| 364 | + anchorMessageId={anchorMessageId} | |
| 365 | + contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 366 | + contentTopInset={headerHeight} | |
| 367 | + contentBottomInset={estimatedOverlayHeight} | |
| 368 | + layoutVariant={layoutVariant} | |
| 369 | + skills={selectedProviderSkills} | |
| 370 | + /> | |
| 371 | + </View> | |
| 372 | + ) : ( | |
| 373 | + <View style={{ flex: 1 }} /> | |
| 374 | + )} | |
| 375 | + | |
| 376 | + {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | |
| 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 | 387 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} |
| 381 | 388 | {showContent ? ( |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import * as Haptics from "expo-haptics"; |
| 2 | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | 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 | 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | 6 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 7 | 7 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -92,7 +92,7 @@ import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/l · 84 unmodified lines | ||
| 92 | 92 | import { |
| 93 | 93 | deriveThreadFeedPresentation, |
| 94 | 94 | type ThreadFeedEntry, |
| 95 | − type ThreadFeedLatestTurn, | |
| 95 | + type ThreadFeedLatestRun, | |
| 96 | 96 | } from "../../lib/threadActivity"; |
| 97 | 97 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 98 | 98 | import { |
| @@ -151,8 +151,7 @@ export interface ThreadFeedProps { · 52 unmodified lines | ||
| 151 | 151 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 152 | 152 | readonly contentPresentation: ThreadContentPresentation; |
| 153 | 153 | readonly agentLabel: string; |
| 154 | − readonly latestTurn: ThreadFeedLatestTurn | null; | |
| 155 | − readonly activeWorkStartedAt: string | null; | |
| 154 | + readonly latestRun: ThreadFeedLatestRun | null; | |
| 156 | 155 | readonly listRef: RefObject<LegendListRef | null>; |
| 157 | 156 | readonly freeze: SharedValue<boolean>; |
| 158 | 157 | readonly anchorMessageId: MessageId | null; |
| @@ -823,11 +822,11 @@ function renderFeedEntry( · 664 unmodified lines | ||
| 823 | 822 | readonly copiedRowId: string | null; |
| 824 | 823 | readonly expandedWorkRows: Record<string, boolean>; |
| 825 | 824 | readonly terminalAssistantMessageIds: ReadonlySet<string>; |
| 826 | − readonly unsettledTurnId: TurnId | null; | |
| 825 | + readonly unsettledTurnId: RunId | null; | |
| 827 | 826 | readonly onCopyWorkRow: (rowId: string, value: string) => void; |
| 828 | 827 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 829 | 828 | readonly onToggleWorkRow: (rowId: string) => void; |
| 830 | − readonly onToggleTurnFold: (turnId: TurnId) => void; | |
| 829 | + readonly onToggleTurnFold: (runId: RunId) => void; | |
| 831 | 830 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 832 | 831 | readonly onMarkdownLinkPress: (href: string) => void; |
| 833 | 832 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| @@ -841,16 +840,12 @@ function renderFeedEntry( · 7 unmodified lines | ||
| 841 | 840 | const entry = info.item; |
| 842 | 841 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; |
| 843 | 842 | |
| 844 | − if (entry.type === "working") { | |
| 845 | − return <WorkingTimelineRow startedAt={entry.createdAt} />; | |
| 846 | − } | |
| 847 | − | |
| 848 | − if (entry.type === "turn-fold") { | |
| 843 | + if (entry.type === "run-fold") { | |
| 849 | 844 | return ( |
| 850 | 845 | <Pressable |
| 851 | 846 | accessibilityRole="button" |
| 852 | 847 | accessibilityState={{ expanded: entry.expanded }} |
| 853 | − onPress={() => props.onToggleTurnFold(entry.turnId)} | |
| 848 | + onPress={() => props.onToggleTurnFold(entry.runId)} | |
| 854 | 849 | hitSlop={4} |
| 855 | 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 | 884 | const assistantTurnStillInProgress = |
| 890 | 885 | message.role === "assistant" && |
| 891 | 886 | props.unsettledTurnId !== null && |
| 892 | − message.turnId === props.unsettledTurnId; | |
| 887 | + message.runId === props.unsettledTurnId; | |
| 893 | 888 | const showAssistantMeta = |
| 894 | 889 | message.role === "assistant" && |
| 895 | 890 | props.terminalAssistantMessageIds.has(message.id) && |
| @@ -1320,21 +1315,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 424 unmodified lines | ||
| 1320 | 1315 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1321 | 1316 | const foldSettleFrameRef = useRef<number | null>(null); |
| 1322 | 1317 | const foldSettleSecondFrameRef = useRef<number | null>(null); |
| 1323 | − const disclosureAnchorKeyRef = useRef<string | null>(null); | |
| 1324 | − const headerMaterialVisibleRef = useRef(false); | |
| 1325 | − const previousLatestTurnRef = useRef(props.latestTurn); | |
| 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); | |
| 1318 | + const previousLatestTurnRef = useRef(props.latestRun); | |
| 1319 | + const { width: viewportWidth } = useWindowDimensions(); | |
| 1320 | + const [foldToggleSettling, setFoldToggleSettling] = useState(false); | |
| 1333 | 1321 | const [interactionState, setInteractionState] = useState<{ |
| 1334 | 1322 | readonly copiedRowId: string | null; |
| 1335 | 1323 | readonly expandedWorkGroups: Record<string, boolean>; |
| 1336 | 1324 | readonly expandedWorkRows: Record<string, boolean>; |
| 1337 | − readonly expandedTurnIds: ReadonlySet<TurnId>; | |
| 1325 | + readonly expandedTurnIds: ReadonlySet<RunId>; | |
| 1338 | 1326 | }>({ |
| 1339 | 1327 | copiedRowId: null, |
| 1340 | 1328 | expandedWorkGroups: {}, |
| @@ -1424,15 +1412,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 83 unmodified lines | ||
| 1424 | 1412 | viewportWidth, |
| 1425 | 1413 | ], |
| 1426 | 1414 | ); |
| 1427 | − const reportHeaderMaterialVisibility = useCallback( | |
| 1428 | − (visible: boolean) => { | |
| 1429 | − if (headerMaterialVisibleRef.current === visible) { | |
| 1430 | − return; | |
| 1431 | − } | |
| 1432 | − headerMaterialVisibleRef.current = visible; | |
| 1433 | − props.onHeaderMaterialVisibilityChange?.(visible); | |
| 1434 | − }, | |
| 1435 | − [props.onHeaderMaterialVisibilityChange], | |
| 1415 | + const presentedFeed = useMemo( | |
| 1416 | + () => deriveThreadFeedPresentation(props.feed, props.latestRun, expandedTurnIds), | |
| 1417 | + [expandedTurnIds, props.feed, props.latestRun], | |
| 1436 | 1418 | ); |
| 1437 | 1419 | // True while the viewport sits within ~one screen of the list end — the |
| 1438 | 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 | 1526 | [presentedFeed, props.anchorMessageId, anchorTopInset], |
| 1545 | 1527 | ); |
| 1546 | 1528 | const terminalAssistantMessageIds = useMemo(() => { |
| 1547 | − const terminalIdsByTurn = new Map<TurnId, string>(); | |
| 1529 | + const terminalIdsByTurn = new Map<RunId, string>(); | |
| 1548 | 1530 | for (const entry of props.feed) { |
| 1549 | − if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { | |
| 1550 | − terminalIdsByTurn.set(entry.message.turnId, entry.message.id); | |
| 1531 | + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) { | |
| 1532 | + terminalIdsByTurn.set(entry.message.runId, entry.message.id); | |
| 1551 | 1533 | } |
| 1552 | 1534 | } |
| 1553 | 1535 | return new Set(terminalIdsByTurn.values()); |
| 1554 | 1536 | }, [props.feed]); |
| 1555 | 1537 | const unsettledTurnId = |
| 1556 | − props.latestTurn && | |
| 1557 | − (props.latestTurn.completedAt === null || props.latestTurn.state === "running") | |
| 1558 | − ? props.latestTurn.turnId | |
| 1538 | + props.latestRun && | |
| 1539 | + (props.latestRun.completedAt === null || props.latestRun.status === "running") | |
| 1540 | + ? props.latestRun.runId | |
| 1559 | 1541 | : null; |
| 1560 | 1542 | |
| 1561 | 1543 | useEffect(() => { |
| 1562 | 1544 | const previous = previousLatestTurnRef.current; |
| 1563 | − previousLatestTurnRef.current = props.latestTurn; | |
| 1564 | − if (!props.latestTurn || !previous) { | |
| 1545 | + previousLatestTurnRef.current = props.latestRun; | |
| 1546 | + if (!props.latestRun || !previous) { | |
| 1565 | 1547 | return; |
| 1566 | 1548 | } |
| 1567 | − if (props.latestTurn.turnId === previous.turnId) { | |
| 1568 | − if (previous.state === "running" && props.latestTurn.state === "interrupted") { | |
| 1569 | − const interruptedTurnId = props.latestTurn.turnId; | |
| 1549 | + if (props.latestRun.runId === previous.runId) { | |
| 1550 | + if (previous.status === "running" && props.latestRun.status === "interrupted") { | |
| 1551 | + const interruptedTurnId = props.latestRun.runId; | |
| 1570 | 1552 | setInteractionState((current) => ({ |
| 1571 | 1553 | ...current, |
| 1572 | 1554 | expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), |
| @@ -1575,14 +1557,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines | ||
| 1575 | 1557 | return; |
| 1576 | 1558 | } |
| 1577 | 1559 | setInteractionState((current) => { |
| 1578 | − if (!current.expandedTurnIds.has(previous.turnId)) { | |
| 1560 | + if (!current.expandedTurnIds.has(previous.runId)) { | |
| 1579 | 1561 | return current; |
| 1580 | 1562 | } |
| 1581 | 1563 | const next = new Set(current.expandedTurnIds); |
| 1582 | − next.delete(previous.turnId); | |
| 1564 | + next.delete(previous.runId); | |
| 1583 | 1565 | return { ...current, expandedTurnIds: next }; |
| 1584 | 1566 | }); |
| 1585 | − }, [props.latestTurn]); | |
| 1567 | + }, [props.latestRun]); | |
| 1586 | 1568 | |
| 1587 | 1569 | useEffect(() => { |
| 1588 | 1570 | return () => { |
| @@ -1676,17 +1658,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 87 unmodified lines | ||
| 1676 | 1658 | [suspendEndScrollMaintenanceForDisclosure], |
| 1677 | 1659 | ); |
| 1678 | 1660 | |
| 1679 | − const onToggleTurnFold = useCallback( | |
| 1680 | − (turnId: TurnId) => { | |
| 1681 | − suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`); | |
| 1682 | − setInteractionState((current) => { | |
| 1683 | − const next = new Set(current.expandedTurnIds); | |
| 1684 | − if (next.has(turnId)) { | |
| 1685 | − next.delete(turnId); | |
| 1686 | − } else { | |
| 1687 | − next.add(turnId); | |
| 1688 | − } | |
| 1689 | − return { ...current, expandedTurnIds: next }; | |
| 1661 | + const onToggleTurnFold = useCallback((runId: RunId) => { | |
| 1662 | + setFoldToggleSettling(true); | |
| 1663 | + if (foldSettleFrameRef.current !== null) { | |
| 1664 | + cancelAnimationFrame(foldSettleFrameRef.current); | |
| 1665 | + } | |
| 1666 | + if (foldSettleSecondFrameRef.current !== null) { | |
| 1667 | + cancelAnimationFrame(foldSettleSecondFrameRef.current); | |
| 1668 | + } | |
| 1669 | + setInteractionState((current) => { | |
| 1670 | + const next = new Set(current.expandedTurnIds); | |
| 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 | 1685 | [suspendEndScrollMaintenanceForDisclosure], |
| @@ -1,11 +1,6 @@ | ||
| 1 | −import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 2 | −import { | |
| 3 | − StackActions, | |
| 4 | − useFocusEffect, | |
| 5 | − useNavigation, | |
| 6 | − type StaticScreenProps, | |
| 7 | −} from "@react-navigation/native"; | |
| 8 | −import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 1 | +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | |
| 2 | +import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; | |
| 3 | +import { useCallback, useMemo, useState } from "react"; | |
| 9 | 4 | import * as Option from "effect/Option"; |
| 10 | 5 | import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; |
| 11 | 6 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| @@ -461,20 +456,15 @@ function ThreadRouteContent( · 449 unmodified lines | ||
| 461 | 456 | void navigation.navigate("Connections"); |
| 462 | 457 | }, [navigation]); |
| 463 | 458 | const handleStopThread = useCallback(() => { |
| 464 | − if ( | |
| 465 | − !selectedThread || | |
| 466 | − (selectedThread.session?.status !== "running" && | |
| 467 | − selectedThread.session?.status !== "starting") | |
| 468 | − ) { | |
| 459 | + const runtime = selectedThread?.runtime; | |
| 460 | + if (!selectedThread || !runtime || !threadRuntimeIsActive(runtime)) { | |
| 469 | 461 | return; |
| 470 | 462 | } |
| 471 | 463 | return interruptThreadTurn({ |
| 472 | 464 | environmentId: selectedThread.environmentId, |
| 473 | 465 | input: { |
| 474 | 466 | threadId: selectedThread.id, |
| 475 | − ...(selectedThread.session.activeTurnId | |
| 476 | − ? { turnId: selectedThread.session.activeTurnId } | |
| 477 | − : {}), | |
| 467 | + ...(runtime.activeRunId ? { runId: runtime.activeRunId } : {}), | |
| 478 | 468 | }, |
| 479 | 469 | }); |
| 480 | 470 | }, [interruptThreadTurn, selectedThread]); |
| @@ -504,7 +494,7 @@ function ThreadRouteContent( · 23 unmodified lines | ||
| 504 | 494 | terminalDebugLog("terminal-menu:open-new", { |
| 505 | 495 | hasThread: Boolean(selectedThread), |
| 506 | 496 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), |
| 507 | − listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | |
| 497 | + listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId), | |
| 508 | 498 | }); |
| 509 | 499 | |
| 510 | 500 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| @@ -512,7 +502,7 @@ function ThreadRouteContent( · 1 unmodified line | ||
| 512 | 502 | } |
| 513 | 503 | |
| 514 | 504 | const nextId = nextOpenTerminalId({ |
| 515 | − listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | |
| 505 | + listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId), | |
| 516 | 506 | }); |
| 517 | 507 | void navigation.navigate("ThreadTerminal", { |
| 518 | 508 | environmentId: String(selectedThread.environmentId), |
| @@ -539,9 +529,9 @@ function ThreadRouteContent( · 20 unmodified lines | ||
| 539 | 529 | } |
| 540 | 530 | |
| 541 | 531 | const targetTerminalId = resolveProjectScriptTerminalId({ |
| 542 | − existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | |
| 532 | + existingTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId), | |
| 543 | 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 | 537 | const preferredWorktreePath = resolvePreferredThreadWorktreePath({ |
| @@ -7,49 +7,9 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { · 6 unmodified lines | ||
| 7 | 7 | return Number.isNaN(candidate) ? 0 : candidate; |
| 8 | 8 | } |
| 9 | 9 | |
| 10 | −export type ThreadStatusKind = | |
| 11 | − | "pending-approval" | |
| 12 | − | "awaiting-input" | |
| 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) { | |
| 10 | +export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone { | |
| 11 | + const status = thread.runtime?.status; | |
| 12 | + if (status === "running" || status === "waiting") { | |
| 53 | 13 | return { |
| 54 | 14 | kind: "pending-approval", |
| 55 | 15 | label: "Needs Approval", |
| @@ -60,8 +20,7 @@ export function resolveThreadStatus( · 4 unmodified lines | ||
| 60 | 20 | pulse: false, |
| 61 | 21 | }; |
| 62 | 22 | } |
| 63 | − | |
| 64 | − if (thread.hasPendingUserInput) { | |
| 23 | + if (status === "completed") { | |
| 65 | 24 | return { |
| 66 | 25 | kind: "awaiting-input", |
| 67 | 26 | label: "Awaiting Input", |
| @@ -72,8 +31,7 @@ export function resolveThreadStatus( · 4 unmodified lines | ||
| 72 | 31 | pulse: false, |
| 73 | 32 | }; |
| 74 | 33 | } |
| 75 | − | |
| 76 | − if (thread.session?.status === "running") { | |
| 34 | + if (status === "queued" || status === "starting") { | |
| 77 | 35 | return { |
| 78 | 36 | kind: "working", |
| 79 | 37 | label: "Working", |
| @@ -84,8 +42,7 @@ export function resolveThreadStatus( · 4 unmodified lines | ||
| 84 | 42 | pulse: true, |
| 85 | 43 | }; |
| 86 | 44 | } |
| 87 | − | |
| 88 | − if (thread.session?.status === "starting") { | |
| 45 | + if (status === "failed") { | |
| 89 | 46 | return { |
| 90 | 47 | kind: "connecting", |
| 91 | 48 | label: "Connecting", |
| @@ -58,15 +58,16 @@ export function useCreateProjectThread() { · 57 unmodified lines | ||
| 58 | 58 | |
| 59 | 59 | const result = await startTurn({ |
| 60 | 60 | environmentId: input.project.environmentId, |
| 61 | − input: buildProjectThreadStartTurnInput({ | |
| 62 | − projectId: input.project.id, | |
| 63 | − projectCwd: input.project.workspaceRoot, | |
| 64 | − threadId: metadata.threadId, | |
| 65 | − commandId: metadata.commandId, | |
| 66 | − messageId: metadata.messageId, | |
| 67 | − createdAt: metadata.createdAt, | |
| 68 | − text: initialMessageText, | |
| 69 | − attachments: input.initialAttachments, | |
| 61 | + input: { | |
| 62 | + commandId: CommandId.make(metadata.commandId), | |
| 63 | + creationSource: "mobile", | |
| 64 | + threadId, | |
| 65 | + message: { | |
| 66 | + messageId: MessageId.make(metadata.messageId), | |
| 67 | + role: "user", | |
| 68 | + text: initialMessageText, | |
| 69 | + attachments: input.initialAttachments, | |
| 70 | + }, | |
| 70 | 71 | modelSelection: input.modelSelection, |
| 71 | 72 | runtimeMode: input.runtimeMode, |
| 72 | 73 | interactionMode: input.interactionMode, |
| @@ -4,6 +4,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { groupProjectsByRepository } from "./repositoryGroups"; |
| 6 | 6 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 7 | +import { makeThreadShellFixture } from "../test-fixtures"; | |
| 7 | 8 | |
| 8 | 9 | function makeProject( |
| 9 | 10 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| @@ -23,24 +24,20 @@ function makeThread( · 13 unmodified lines | ||
| 23 | 24 | input: Partial<EnvironmentThreadShell> & |
| 24 | 25 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">, |
| 25 | 26 | ): EnvironmentThreadShell { |
| 26 | − return { | |
| 27 | + return makeThreadShellFixture({ | |
| 27 | 28 | runtimeMode: "full-access", |
| 28 | 29 | interactionMode: "default", |
| 29 | 30 | branch: null, |
| 30 | 31 | worktreePath: null, |
| 31 | − latestTurn: null, | |
| 32 | 32 | createdAt: "2026-04-01T00:00:00.000Z", |
| 33 | 33 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 34 | 34 | archivedAt: null, |
| 35 | − session: null, | |
| 36 | 35 | latestUserMessageAt: null, |
| 37 | 36 | hasPendingApprovals: false, |
| 38 | 37 | hasPendingUserInput: false, |
| 39 | 38 | hasActionableProposedPlan: false, |
| 40 | 39 | ...input, |
| 41 | − settledOverride: input.settledOverride ?? null, | |
| 42 | − settledAt: input.settledAt ?? null, | |
| 43 | − }; | |
| 40 | + }); | |
| 44 | 41 | } |
| 45 | 42 | |
| 46 | 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 | 3 | export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string { |
| 4 | 4 | return `${environmentId}:${projectId}`; |
| @@ -10,7 +10,7 @@ export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId · 5 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | export function scopedRequestKey( |
| 12 | 12 | environmentId: EnvironmentId, |
| 13 | − requestId: ApprovalRequestId, | |
| 13 | + requestId: RuntimeRequestId, | |
| 14 | 14 | ): string { |
| 15 | 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 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 4 | |
| 3 | −import { | |
| 4 | − EventId, | |
| 5 | − MessageId, | |
| 6 | − ProjectId, | |
| 7 | − ProviderInstanceId, | |
| 8 | − ThreadId, | |
| 9 | − TurnId, | |
| 10 | − type OrchestrationThread, | |
| 11 | − type OrchestrationThreadActivity, | |
| 12 | −} from "@t3tools/contracts"; | |
| 5 | +import { makeThreadFixture } from "../test-fixtures"; | |
| 6 | +import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; | |
| 13 | 7 | |
| 14 | −import { | |
| 15 | − buildThreadFeed, | |
| 16 | − deriveThreadFeedPresentation, | |
| 17 | − type ThreadFeedActivity, | |
| 18 | − type ThreadFeedEntry, | |
| 19 | −} from "./threadActivity"; | |
| 8 | +const runId = RunId.make("run-1"); | |
| 20 | 9 | |
| 21 | −function makeActivity( | |
| 22 | − input: Partial<OrchestrationThreadActivity> & | |
| 23 | − Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, | |
| 24 | −): OrchestrationThreadActivity { | |
| 10 | +function message(role: "user" | "assistant", text: string, createdAt: string, id: string) { | |
| 25 | 11 | return { |
| 26 | − tone: "info", | |
| 27 | − payload: {}, | |
| 28 | − turnId: null, | |
| 29 | − ...input, | |
| 30 | − }; | |
| 12 | + id: MessageId.make(id), | |
| 13 | + role, | |
| 14 | + text, | |
| 15 | + attachments: [], | |
| 16 | + runId: role === "assistant" ? runId : null, | |
| 17 | + streaming: false, | |
| 18 | + createdAt, | |
| 19 | + updatedAt: createdAt, | |
| 20 | + } as const; | |
| 31 | 21 | } |
| 32 | 22 | |
| 33 | −function makeThread( | |
| 34 | − input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">, | |
| 35 | −): OrchestrationThread { | |
| 23 | +function commandEntry(overrides: Partial<ThreadWorkEntry> = {}): ThreadWorkEntry { | |
| 24 | + const structuredPayload = { | |
| 25 | + type: "command_execution", | |
| 26 | + input: "vp check", | |
| 27 | + output: "ok", | |
| 28 | + } as ThreadWorkEntry["structuredPayload"]; | |
| 36 | 29 | return { |
| 37 | − modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 38 | − runtimeMode: "full-access", | |
| 39 | − interactionMode: "default", | |
| 40 | − branch: null, | |
| 41 | − worktreePath: null, | |
| 42 | − latestTurn: null, | |
| 43 | − createdAt: "2026-04-01T00:00:00.000Z", | |
| 44 | − updatedAt: "2026-04-01T00:00:00.000Z", | |
| 45 | − archivedAt: null, | |
| 46 | − deletedAt: null, | |
| 47 | − messages: [], | |
| 48 | − proposedPlans: [], | |
| 49 | − activities: [], | |
| 50 | − checkpoints: [], | |
| 51 | − session: null, | |
| 52 | − ...input, | |
| 53 | − settledOverride: input.settledOverride ?? null, | |
| 54 | − settledAt: input.settledAt ?? null, | |
| 30 | + id: "item-1", | |
| 31 | + createdAt: "2026-06-20T00:00:02.000Z", | |
| 32 | + runId, | |
| 33 | + label: "Ran command", | |
| 34 | + command: "vp check", | |
| 35 | + detail: "ok", | |
| 36 | + tone: "tool", | |
| 37 | + itemType: "command_execution", | |
| 38 | + toolLifecycleStatus: "completed", | |
| 39 | + structuredPayload, | |
| 40 | + ...overrides, | |
| 55 | 41 | }; |
| 56 | 42 | } |
| 57 | 43 | |
| 58 | 44 | describe("buildThreadFeed", () => { |
| 59 | − it("keeps historic work entries attributed to their turns", () => { | |
| 60 | − const thread = makeThread({ | |
| 61 | − id: ThreadId.make("thread-1"), | |
| 62 | − projectId: ProjectId.make("project-1"), | |
| 63 | − title: "Runtime warning thread", | |
| 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 | − }), | |
| 45 | + it("orders V2 messages and work entries while retaining structured tool data", () => { | |
| 46 | + const thread = makeThreadFixture({ | |
| 47 | + messages: [ | |
| 48 | + message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"), | |
| 49 | + message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"), | |
| 151 | 50 | ], |
| 51 | + workEntries: [commandEntry()], | |
| 152 | 52 | }); |
| 153 | 53 | |
| 154 | 54 | const feed = buildThreadFeed(thread); |
| 155 | − const group = feed[0]; | |
| 156 | − | |
| 157 | − expect(group).toMatchObject({ | |
| 158 | − type: "activity-group", | |
| 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"); | |
| 55 | + expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]); | |
| 56 | + const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0]; | |
| 57 | + expect(activity?.runId).toBe(runId); | |
| 58 | + expect(activity?.fullDetail).toContain('"input": "vp check"'); | |
| 230 | 59 | }); |
| 231 | 60 | |
| 232 | − it("defers large tool output expansion until a work row is opened or copied", () => { | |
| 233 | − let serializedToolOutputs = 0; | |
| 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 | − }, | |
| 61 | + it("folds settled V2 run work while keeping the terminal assistant message visible", () => { | |
| 62 | + const thread = makeThreadFixture({ | |
| 293 | 63 | messages: [ |
| 294 | − { | |
| 295 | − id: MessageId.make("assistant-commentary"), | |
| 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 | − }), | |
| 64 | + message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"), | |
| 65 | + message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"), | |
| 327 | 66 | ], |
| 67 | + workEntries: [commandEntry()], | |
| 328 | 68 | }); |
| 329 | − | |
| 330 | 69 | const feed = buildThreadFeed(thread); |
| 331 | − const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | |
| 332 | − expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); | |
| 333 | − expect(collapsed[0]).toMatchObject({ | |
| 334 | − type: "turn-fold", | |
| 335 | − label: "Worked for 17s", | |
| 336 | − expanded: false, | |
| 337 | − }); | |
| 338 | − | |
| 339 | − const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); | |
| 340 | − expect(expanded.map((entry) => entry.id)).toEqual([ | |
| 341 | − "turn-fold:turn-1", | |
| 342 | − "assistant-commentary", | |
| 343 | − "tool-completed", | |
| 344 | − "assistant-final", | |
| 70 | + const latestRun = { | |
| 71 | + runId, | |
| 72 | + status: "completed" as const, | |
| 73 | + startedAt: "2026-06-20T00:00:01.000Z", | |
| 74 | + completedAt: "2026-06-20T00:00:03.000Z", | |
| 75 | + }; | |
| 76 | + | |
| 77 | + const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); | |
| 78 | + expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); | |
| 79 | + | |
| 80 | + const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); | |
| 81 | + expect(expanded.map((entry) => entry.type)).toEqual([ | |
| 82 | + "message", | |
| 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", () => { | |
| 349 | − const firstTurnId = TurnId.make("turn-1"); | |
| 350 | − const secondTurnId = TurnId.make("turn-2"); | |
| 351 | − const thread = makeThread({ | |
| 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 | − ], | |
| 89 | + it("keeps an active run expanded and marks failed tools as failures", () => { | |
| 90 | + const thread = makeThreadFixture({ | |
| 91 | + messages: [message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user")], | |
| 92 | + workEntries: [commandEntry({ tone: "error", toolLifecycleStatus: "failed" })], | |
| 416 | 93 | }); |
| 417 | − | |
| 418 | 94 | const feed = buildThreadFeed(thread); |
| 419 | − const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | |
| 420 | − expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ | |
| 421 | − turnId: firstTurnId, | |
| 422 | − label: "Worked for 12s", | |
| 423 | − }); | |
| 424 | − }); | |
| 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", | |
| 95 | + const presented = deriveThreadFeedPresentation( | |
| 96 | + feed, | |
| 97 | + { | |
| 98 | + runId, | |
| 99 | + status: "running", | |
| 100 | + startedAt: "2026-06-20T00:00:01.000Z", | |
| 437 | 101 | completedAt: null, |
| 438 | − assistantMessageId: null, | |
| 439 | 102 | }, |
| 440 | − activities: [ | |
| 441 | − makeActivity({ | |
| 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 | − }); | |
| 103 | + new Set(), | |
| 104 | + ); | |
| 457 | 105 | |
| 458 | − const feed = buildThreadFeed(thread); | |
| 459 | − expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); | |
| 460 | − expect(feed[0]).toMatchObject({ | |
| 461 | − type: "activity-group", | |
| 462 | − activities: [{ status: "failure" }], | |
| 463 | − }); | |
| 106 | + expect(presented.some((entry) => entry.type === "run-fold")).toBe(false); | |
| 107 | + expect(presented.find((entry) => entry.type === "activity-group")?.activities[0]?.status).toBe( | |
| 108 | + "failure", | |
| 109 | + ); | |
| 464 | 110 | }); |
| 465 | 111 | |
| 466 | 112 | it("appends active work as a normal timeline row", () => { |
| @@ -1,29 +1,17 @@ | ||
| 1 | −import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; | |
| 2 | 1 | import type { |
| 3 | − OrchestrationLatestTurn, | |
| 4 | − OrchestrationThread, | |
| 5 | − OrchestrationThreadActivity, | |
| 6 | − ToolLifecycleItemType, | |
| 7 | − TurnId, | |
| 8 | − UserInputQuestion, | |
| 9 | −} from "@t3tools/contracts"; | |
| 2 | + EnvironmentThread, | |
| 3 | + ThreadConversationMessage, | |
| 4 | + ThreadPendingApproval, | |
| 5 | + ThreadPendingUserInput, | |
| 6 | + ThreadRunSummary, | |
| 7 | + ThreadUserInputQuestion, | |
| 8 | + ThreadWorkEntry, | |
| 9 | +} from "@t3tools/client-runtime/state/shell"; | |
| 10 | +import type { RunId } from "@t3tools/contracts"; | |
| 10 | 11 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 11 | 12 | |
| 12 | −import * as Arr from "effect/Array"; | |
| 13 | −import * as Order from "effect/Order"; | |
| 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 | −} | |
| 13 | +export type PendingApproval = ThreadPendingApproval; | |
| 14 | +export type PendingUserInput = ThreadPendingUserInput; | |
| 27 | 15 | |
| 28 | 16 | export interface PendingUserInputDraftAnswer { |
| 29 | 17 | readonly selectedOptionLabel?: string; |
| @@ -33,7 +21,7 @@ export interface PendingUserInputDraftAnswer { · 3 unmodified lines | ||
| 33 | 21 | export interface ThreadFeedActivity { |
| 34 | 22 | readonly id: string; |
| 35 | 23 | readonly createdAt: string; |
| 36 | − readonly turnId: TurnId | null; | |
| 24 | + readonly runId: RunId | null; | |
| 37 | 25 | readonly summary: string; |
| 38 | 26 | readonly detail: string | null; |
| 39 | 27 | readonly canExpand: boolean; |
| @@ -56,44 +44,18 @@ export interface ThreadFeedActivity { · 16 unmodified lines | ||
| 56 | 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 | 47 | type RawThreadFeedEntry = |
| 86 | 48 | | { |
| 87 | 49 | readonly type: "message"; |
| 88 | 50 | readonly id: string; |
| 89 | 51 | readonly createdAt: string; |
| 90 | − readonly message: OrchestrationThread["messages"][number]; | |
| 52 | + readonly message: ThreadConversationMessage; | |
| 91 | 53 | } |
| 92 | 54 | | { |
| 93 | 55 | readonly type: "activity"; |
| 94 | 56 | readonly id: string; |
| 95 | 57 | readonly createdAt: string; |
| 96 | − readonly turnId: TurnId | null; | |
| 58 | + readonly runId: RunId | null; | |
| 97 | 59 | readonly activity: ThreadFeedActivity; |
| 98 | 60 | }; |
| 99 | 61 | |
| @@ -108,119 +70,25 @@ export type ThreadFeedEntry = · 8 unmodified lines | ||
| 108 | 70 | readonly type: "activity-group"; |
| 109 | 71 | readonly id: string; |
| 110 | 72 | readonly createdAt: string; |
| 111 | − readonly turnId: TurnId | null; | |
| 73 | + readonly runId: RunId | null; | |
| 112 | 74 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 113 | 75 | } |
| 114 | 76 | | { |
| 115 | − readonly type: "work-toggle"; | |
| 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"; | |
| 77 | + readonly type: "run-fold"; | |
| 126 | 78 | readonly id: string; |
| 127 | 79 | readonly createdAt: string; |
| 128 | − readonly turnId: TurnId; | |
| 80 | + readonly runId: RunId; | |
| 129 | 81 | readonly label: string; |
| 130 | 82 | readonly expanded: boolean; |
| 131 | 83 | }; |
| 132 | 84 | |
| 133 | −export type ThreadFeedLatestTurn = Pick< | |
| 134 | − OrchestrationLatestTurn, | |
| 135 | − "turnId" | "state" | "startedAt" | "completedAt" | |
| 85 | +export type ThreadFeedLatestRun = Pick< | |
| 86 | + ThreadRunSummary, | |
| 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 | 90 | function normalizeDraftAnswer(value: string | undefined): string | null { |
| 221 | − if (typeof value !== "string") { | |
| 222 | − return null; | |
| 223 | − } | |
| 91 | + if (typeof value !== "string") return null; | |
| 224 | 92 | const trimmed = value.trim(); |
| 225 | 93 | return trimmed.length > 0 ? trimmed : null; |
| 226 | 94 | } |
| @@ -228,882 +96,247 @@ function normalizeDraftAnswer(value: string | undefined): string | null { · 1 unmodified line | ||
| 228 | 96 | function resolvePendingUserInputAnswer( |
| 229 | 97 | draft: PendingUserInputDraftAnswer | undefined, |
| 230 | 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 | 99 | return ( |
| 456 | − normalized.includes("file not found") || | |
| 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) | |
| 100 | + normalizeDraftAnswer(draft?.customAnswer) ?? normalizeDraftAnswer(draft?.selectedOptionLabel) | |
| 468 | 101 | ); |
| 469 | 102 | } |
| 470 | 103 | |
| 471 | −function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { | |
| 472 | − if (entry.tone === "error") { | |
| 473 | − return true; | |
| 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")); | |
| 104 | +function capitalizePhrase(value: string): string { | |
| 105 | + const trimmed = value.trim(); | |
| 106 | + return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; | |
| 482 | 107 | } |
| 483 | 108 | |
| 484 | −function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | |
| 485 | − if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { | |
| 486 | − return false; | |
| 487 | − } | |
| 488 | − if (entry.tone === "thinking") { | |
| 489 | − return false; | |
| 490 | − } | |
| 109 | +function workEntryIsToolLike(entry: ThreadWorkEntry): boolean { | |
| 491 | 110 | return ( |
| 492 | − entry.toolLifecycleStatus !== "inProgress" && | |
| 493 | − entry.toolLifecycleStatus !== "stopped" && | |
| 494 | − entry.toolLifecycleStatus !== "failed" && | |
| 495 | − entry.toolLifecycleStatus !== "declined" | |
| 111 | + entry.tone === "tool" || | |
| 112 | + entry.tone === "thinking" || | |
| 113 | + entry.tone === "error" || | |
| 114 | + entry.command !== undefined || | |
| 115 | + entry.requestKind !== undefined | |
| 496 | 116 | ); |
| 497 | 117 | } |
| 498 | 118 | |
| 499 | −function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { | |
| 500 | − if (!workLogEntryIsToolLike(entry)) { | |
| 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"] { | |
| 119 | +function workEntryStatus(entry: ThreadWorkEntry): ThreadFeedActivity["status"] { | |
| 120 | + if (!workEntryIsToolLike(entry)) return null; | |
| 513 | 121 | if ( |
| 514 | − entry.activityKind === "user-input.requested" || | |
| 515 | − entry.activityKind === "user-input.resolved" | |
| 122 | + entry.tone === "error" || | |
| 123 | + entry.toolLifecycleStatus === "failed" || | |
| 124 | + entry.toolLifecycleStatus === "declined" | |
| 516 | 125 | ) { |
| 517 | − return "message"; | |
| 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"; | |
| 126 | + return "failure"; | |
| 530 | 127 | } |
| 531 | − if (entry.tone === "error") return "alert"; | |
| 532 | − if (entry.tone === "thinking") return "agent"; | |
| 533 | − if (entry.tone === "info") return "check"; | |
| 534 | − return "zap"; | |
| 128 | + return entry.toolLifecycleStatus === "completed" ? "success" : "neutral"; | |
| 535 | 129 | } |
| 536 | 130 | |
| 537 | −function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { | |
| 538 | − const blocks: string[] = []; | |
| 539 | − const appendUniqueBlock = (value: string | null | undefined) => { | |
| 540 | − const trimmed = value?.trim(); | |
| 541 | − if (trimmed && !blocks.includes(trimmed)) { | |
| 542 | − blocks.push(trimmed); | |
| 543 | − } | |
| 544 | − }; | |
| 545 | − | |
| 546 | − if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { | |
| 547 | − appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); | |
| 548 | − } | |
| 549 | − appendUniqueBlock(entry.rawCommand ?? entry.command); | |
| 550 | − appendUniqueBlock(entry.detail); | |
| 551 | − if ((entry.changedFiles?.length ?? 0) > 0) { | |
| 552 | − appendUniqueBlock(entry.changedFiles!.join("\n")); | |
| 131 | +function workEntryIcon(entry: ThreadWorkEntry): ThreadFeedActivity["icon"] { | |
| 132 | + switch (entry.itemType) { | |
| 133 | + case "reasoning": | |
| 134 | + return "agent"; | |
| 135 | + case "command_execution": | |
| 136 | + return "command"; | |
| 137 | + case "file_change": | |
| 138 | + return "edit"; | |
| 139 | + case "file_search": | |
| 140 | + return "eye"; | |
| 141 | + case "web_search": | |
| 142 | + return "globe"; | |
| 143 | + case "approval_request": | |
| 144 | + case "user_input_request": | |
| 145 | + return "message"; | |
| 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( | |
| 580 | − workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, | |
| 581 | −): string | null { | |
| 582 | − if (workEntry.command) return workEntry.command; | |
| 583 | − if (workEntry.detail) return workEntry.detail; | |
| 584 | − if ((workEntry.changedFiles?.length ?? 0) === 0) return null; | |
| 585 | − const [firstPath] = workEntry.changedFiles ?? []; | |
| 163 | +function workEntryPreview(entry: ThreadWorkEntry): string | null { | |
| 164 | + if (entry.command) return entry.command; | |
| 165 | + if (entry.detail) return entry.detail; | |
| 166 | + const firstPath = entry.changedFiles?.[0]; | |
| 586 | 167 | if (!firstPath) return null; |
| 587 | − return workEntry.changedFiles!.length === 1 | |
| 168 | + return entry.changedFiles?.length === 1 | |
| 588 | 169 | ? firstPath |
| 589 | − : `${firstPath} +${workEntry.changedFiles!.length - 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 | − ); | |
| 170 | + : `${firstPath} +${(entry.changedFiles?.length ?? 1) - 1} more`; | |
| 693 | 171 | } |
| 694 | 172 | |
| 695 | −function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { | |
| 696 | − const match = wrapperFlagPattern.exec(value); | |
| 697 | − if (!match) { | |
| 698 | − return null; | |
| 699 | − } | |
| 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, | |
| 173 | +function buildWorkEntryExpandedBody(entry: ThreadWorkEntry): string | null { | |
| 174 | + const blocks: string[] = []; | |
| 175 | + const append = (value: string | null | undefined) => { | |
| 176 | + const trimmed = value?.trim(); | |
| 177 | + if (trimmed && !blocks.includes(trimmed)) blocks.push(trimmed); | |
| 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 { | |
| 803 | − return asTrimmedString(payload?.title); | |
| 804 | −} | |
| 805 | − | |
| 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() ?? ""; | |
| 186 | +function toFeedActivity(entry: ThreadWorkEntry): ThreadFeedActivity { | |
| 187 | + const summary = capitalizePhrase(entry.toolTitle ?? entry.label); | |
| 188 | + const detail = workEntryPreview(entry); | |
| 189 | + const fullDetail = buildWorkEntryExpandedBody(entry); | |
| 837 | 190 | return { |
| 838 | − output: normalizedOutput.length > 0 ? normalizedOutput : null, | |
| 839 | − ...(Number.isInteger(exitCode) ? { exitCode } : {}), | |
| 191 | + id: entry.id, | |
| 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( | |
| 844 | − payload: Record<string, unknown> | null, | |
| 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); | |
| 209 | +function byCreatedAt<A extends { readonly createdAt: string }>(left: A, right: A): number { | |
| 210 | + return left.createdAt.localeCompare(right.createdAt); | |
| 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 | 213 | function isEmptyMessage(entry: RawThreadFeedEntry): boolean { |
| 950 | − if (entry.type !== "message") { | |
| 951 | − return false; | |
| 952 | − } | |
| 953 | − const hasText = entry.message.text.trim().length > 0; | |
| 954 | − const hasAttachments = (entry.message.attachments ?? []).length > 0; | |
| 955 | − return !hasText && !hasAttachments; | |
| 214 | + return ( | |
| 215 | + entry.type === "message" && | |
| 216 | + entry.message.text.trim().length === 0 && | |
| 217 | + (entry.message.attachments ?? []).length === 0 | |
| 218 | + ); | |
| 956 | 219 | } |
| 957 | 220 | |
| 958 | 221 | function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] { |
| 959 | 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 | 223 | for (const entry of entries) { |
| 967 | − // Skip empty messages so they don't break activity grouping. | |
| 968 | − if (isEmptyMessage(entry)) { | |
| 969 | − continue; | |
| 970 | − } | |
| 971 | − | |
| 224 | + if (isEmptyMessage(entry)) continue; | |
| 972 | 225 | if (entry.type !== "activity") { |
| 973 | 226 | grouped.push(entry); |
| 974 | 227 | openGroupActivities = null; |
| 975 | 228 | continue; |
| 976 | 229 | } |
| 977 | − | |
| 978 | − if (openGroupActivities !== null && openGroupTurnId === entry.turnId) { | |
| 979 | − openGroupActivities.push(entry.activity); | |
| 230 | + const previous = grouped.at(-1); | |
| 231 | + if (previous?.type === "activity-group" && previous.runId === entry.runId) { | |
| 232 | + grouped[grouped.length - 1] = { | |
| 233 | + ...previous, | |
| 234 | + activities: [...previous.activities, entry.activity], | |
| 235 | + }; | |
| 980 | 236 | continue; |
| 981 | 237 | } |
| 982 | − | |
| 983 | − openGroupActivities = [entry.activity]; | |
| 984 | − openGroupTurnId = entry.turnId; | |
| 985 | 238 | grouped.push({ |
| 986 | 239 | type: "activity-group", |
| 987 | 240 | id: entry.id, |
| 988 | 241 | createdAt: entry.createdAt, |
| 989 | − turnId: entry.turnId, | |
| 990 | − activities: openGroupActivities, | |
| 242 | + runId: entry.runId, | |
| 243 | + activities: [entry.activity], | |
| 991 | 244 | }); |
| 992 | 245 | } |
| 993 | − | |
| 994 | 246 | return grouped; |
| 995 | 247 | } |
| 996 | 248 | |
| 997 | 249 | function computeElapsedMs(startIso: string, endIso: string): number | null { |
| 998 | 250 | const start = Date.parse(startIso); |
| 999 | 251 | const end = Date.parse(endIso); |
| 1000 | − if (!Number.isFinite(start) || !Number.isFinite(end)) { | |
| 1001 | − return null; | |
| 1002 | − } | |
| 1003 | − return Math.max(0, end - start); | |
| 252 | + return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; | |
| 1004 | 253 | } |
| 1005 | 254 | |
| 1006 | −function maxIsoTimestamp(a: string | null, b: string | null): string | null { | |
| 1007 | − if (a === null) return b; | |
| 1008 | − if (b === null) return a; | |
| 1009 | − const aMs = Date.parse(a); | |
| 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; | |
| 255 | +function maxIsoTimestamp(left: string | null, right: string | null): string | null { | |
| 256 | + if (left === null) return right; | |
| 257 | + if (right === null) return left; | |
| 258 | + return Date.parse(right) > Date.parse(left) ? right : left; | |
| 1014 | 259 | } |
| 1015 | 260 | |
| 1016 | −function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { | |
| 1017 | − if (!latestTurn) { | |
| 1018 | − return null; | |
| 1019 | − } | |
| 1020 | − const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; | |
| 1021 | − return settled ? null : latestTurn.turnId; | |
| 261 | +function unsettledRunId(latestRun: ThreadFeedLatestRun | null): RunId | null { | |
| 262 | + if (!latestRun) return null; | |
| 263 | + return latestRun.completedAt === null || | |
| 264 | + latestRun.status === "starting" || | |
| 265 | + latestRun.status === "running" || | |
| 266 | + latestRun.status === "waiting" | |
| 267 | + ? latestRun.runId | |
| 268 | + : null; | |
| 1022 | 269 | } |
| 1023 | 270 | |
| 1024 | −interface ThreadFeedTurnFold { | |
| 1025 | − readonly turnId: TurnId; | |
| 271 | +interface ThreadFeedRunFold { | |
| 272 | + readonly runId: RunId; | |
| 1026 | 273 | readonly createdAt: string; |
| 1027 | 274 | readonly hiddenEntryIds: ReadonlySet<string>; |
| 1028 | 275 | readonly label: string; |
| 1029 | 276 | } |
| 1030 | 277 | |
| 1031 | −function deriveThreadFeedTurnFolds( | |
| 278 | +function deriveThreadFeedRunFolds( | |
| 1032 | 279 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1033 | − latestTurn: ThreadFeedLatestTurn | null, | |
| 1034 | −): ReadonlyMap<string, ThreadFeedTurnFold> { | |
| 1035 | − const terminalAssistantMessageIdByTurn = new Map<TurnId, string>(); | |
| 280 | + latestRun: ThreadFeedLatestRun | null, | |
| 281 | +): ReadonlyMap<string, ThreadFeedRunFold> { | |
| 282 | + const terminalAssistantMessageIdByRun = new Map<RunId, string>(); | |
| 1036 | 283 | for (const entry of feed) { |
| 1037 | − if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { | |
| 1038 | − terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); | |
| 284 | + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) { | |
| 285 | + terminalAssistantMessageIdByRun.set(entry.message.runId, entry.id); | |
| 1039 | 286 | } |
| 1040 | 287 | } |
| 1041 | 288 | |
| 1042 | − interface TurnGroup { | |
| 1043 | − readonly entries: ThreadFeedEntry[]; | |
| 1044 | − readonly startBoundary: string | null; | |
| 1045 | − } | |
| 1046 | − const groupsByTurnId = new Map<TurnId, TurnGroup>(); | |
| 289 | + const groupsByRunId = new Map< | |
| 290 | + RunId, | |
| 291 | + { entries: ThreadFeedEntry[]; startBoundary: string | null } | |
| 292 | + >(); | |
| 1047 | 293 | let pendingUserBoundary: string | null = null; |
| 1048 | 294 | for (const entry of feed) { |
| 1049 | 295 | if (entry.type === "message" && entry.message.role === "user") { |
| 1050 | 296 | pendingUserBoundary = entry.message.createdAt; |
| 1051 | 297 | continue; |
| 1052 | 298 | } |
| 1053 | − const turnId = | |
| 299 | + const runId = | |
| 1054 | 300 | entry.type === "message" && entry.message.role === "assistant" |
| 1055 | − ? entry.message.turnId | |
| 301 | + ? entry.message.runId | |
| 1056 | 302 | : entry.type === "activity-group" |
| 1057 | − ? entry.turnId | |
| 303 | + ? entry.runId | |
| 1058 | 304 | : null; |
| 1059 | − if (!turnId) { | |
| 1060 | − continue; | |
| 1061 | − } | |
| 1062 | − let group = groupsByTurnId.get(turnId); | |
| 305 | + if (!runId) continue; | |
| 306 | + let group = groupsByRunId.get(runId); | |
| 1063 | 307 | if (!group) { |
| 1064 | − group = { | |
| 1065 | − entries: [], | |
| 1066 | − startBoundary: pendingUserBoundary, | |
| 1067 | − }; | |
| 308 | + group = { entries: [], startBoundary: pendingUserBoundary }; | |
| 1068 | 309 | pendingUserBoundary = null; |
| 1069 | − groupsByTurnId.set(turnId, group); | |
| 310 | + groupsByRunId.set(runId, group); | |
| 1070 | 311 | } |
| 1071 | 312 | group.entries.push(entry); |
| 1072 | 313 | } |
| 1073 | 314 | |
| 1074 | − const unsettledTurnId = deriveUnsettledTurnId(latestTurn); | |
| 1075 | − const foldsByAnchorId = new Map<string, ThreadFeedTurnFold>(); | |
| 1076 | − for (const [turnId, group] of groupsByTurnId) { | |
| 1077 | − const { entries } = group; | |
| 1078 | − if (turnId === unsettledTurnId) { | |
| 1079 | − continue; | |
| 1080 | − } | |
| 1081 | − if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) { | |
| 315 | + const activeRunId = unsettledRunId(latestRun); | |
| 316 | + const foldsByAnchorId = new Map<string, ThreadFeedRunFold>(); | |
| 317 | + for (const [runId, group] of groupsByRunId) { | |
| 318 | + if ( | |
| 319 | + runId === activeRunId || | |
| 320 | + group.entries.some((entry) => entry.type === "message" && entry.message.streaming) | |
| 321 | + ) { | |
| 1082 | 322 | continue; |
| 1083 | 323 | } |
| 1084 | − | |
| 1085 | − const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); | |
| 324 | + const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId); | |
| 1086 | 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) { | |
| 1090 | − continue; | |
| 1091 | − } | |
| 1092 | − | |
| 1093 | − const firstEntry = entries[0]; | |
| 1094 | − const lastEntry = entries.at(-1); | |
| 1095 | − if (!firstEntry || !lastEntry) { | |
| 1096 | − continue; | |
| 1097 | − } | |
| 1098 | − const terminalEntry = terminalAssistantMessageId | |
| 1099 | − ? entries.find((entry) => entry.id === terminalAssistantMessageId) | |
| 328 | + const firstEntry = group.entries[0]; | |
| 329 | + const lastEntry = group.entries.at(-1); | |
| 330 | + if (hiddenEntryIds.size === 0 || !firstEntry || !lastEntry) continue; | |
| 331 | + const terminalEntry = terminalAssistantId | |
| 332 | + ? group.entries.find((entry) => entry.id === terminalAssistantId) | |
| 1100 | 333 | : null; |
| 1101 | − const latestTurnMatches = latestTurn?.turnId === turnId; | |
| 334 | + const latestRunMatches = latestRun?.runId === runId; | |
| 1102 | 335 | const lastEntryEnd = |
| 1103 | 336 | lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; |
| 1104 | 337 | const elapsedMs = |
| 1105 | − latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt | |
| 1106 | − ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt) | |
| 338 | + latestRunMatches && latestRun.startedAt && latestRun.completedAt | |
| 339 | + ? computeElapsedMs(latestRun.startedAt, latestRun.completedAt) | |
| 1107 | 340 | : computeElapsedMs( |
| 1108 | 341 | group.startBoundary ?? firstEntry.createdAt, |
| 1109 | 342 | maxIsoTimestamp( |
| @@ -1112,20 +345,19 @@ function deriveThreadFeedTurnFolds( · 2 unmodified lines | ||
| 1112 | 345 | ) ?? lastEntryEnd, |
| 1113 | 346 | ); |
| 1114 | 347 | const duration = elapsedMs === null ? null : formatDuration(elapsedMs); |
| 1115 | − const interrupted = latestTurnMatches && latestTurn.state === "interrupted"; | |
| 1116 | − const label = interrupted | |
| 1117 | − ? duration | |
| 1118 | − ? `You stopped after ${duration}` | |
| 1119 | − : "You stopped this response" | |
| 1120 | − : duration | |
| 1121 | − ? `Worked for ${duration}` | |
| 1122 | − : "Worked"; | |
| 1123 | − | |
| 348 | + const interrupted = | |
| 349 | + latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); | |
| 1124 | 350 | foldsByAnchorId.set(firstEntry.id, { |
| 1125 | − turnId, | |
| 351 | + runId, | |
| 1126 | 352 | createdAt: firstEntry.createdAt, |
| 1127 | 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 | 363 | return foldsByAnchorId; |
| @@ -1133,41 +365,31 @@ function deriveThreadFeedTurnFolds( · 1 unmodified line | ||
| 1133 | 365 | |
| 1134 | 366 | export function deriveThreadFeedPresentation( |
| 1135 | 367 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1136 | − latestTurn: ThreadFeedLatestTurn | null, | |
| 1137 | − expandedTurnIds: ReadonlySet<TurnId>, | |
| 1138 | − expandedWorkGroupIds: ReadonlySet<string> = new Set(), | |
| 1139 | − activeWorkStartedAt: string | null = null, | |
| 368 | + latestRun: ThreadFeedLatestRun | null, | |
| 369 | + expandedRunIds: ReadonlySet<RunId>, | |
| 1140 | 370 | ): ThreadFeedEntry[] { |
| 1141 | − const sourceFeed = feed.filter( | |
| 1142 | − (entry) => | |
| 1143 | − entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", | |
| 1144 | − ); | |
| 1145 | − const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); | |
| 371 | + const sourceFeed = feed.filter((entry) => entry.type !== "run-fold"); | |
| 372 | + const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun); | |
| 1146 | 373 | const collapsedEntryIds = new Set<string>(); |
| 1147 | 374 | for (const fold of foldsByAnchorId.values()) { |
| 1148 | − if (!expandedTurnIds.has(fold.turnId)) { | |
| 1149 | − for (const entryId of fold.hiddenEntryIds) { | |
| 1150 | − collapsedEntryIds.add(entryId); | |
| 1151 | − } | |
| 375 | + if (!expandedRunIds.has(fold.runId)) { | |
| 376 | + for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); | |
| 1152 | 377 | } |
| 1153 | 378 | } |
| 1154 | − | |
| 1155 | 379 | const result: ThreadFeedEntry[] = []; |
| 1156 | 380 | for (const entry of sourceFeed) { |
| 1157 | 381 | const fold = foldsByAnchorId.get(entry.id); |
| 1158 | 382 | if (fold) { |
| 1159 | 383 | result.push({ |
| 1160 | − type: "turn-fold", | |
| 1161 | − id: `turn-fold:${fold.turnId}`, | |
| 384 | + type: "run-fold", | |
| 385 | + id: `run-fold:${fold.runId}`, | |
| 1162 | 386 | createdAt: fold.createdAt, |
| 1163 | − turnId: fold.turnId, | |
| 387 | + runId: fold.runId, | |
| 1164 | 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)) { | |
| 1169 | − appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); | |
| 1170 | − } | |
| 392 | + if (!collapsedEntryIds.has(entry.id)) result.push(entry); | |
| 1171 | 393 | } |
| 1172 | 394 | if (activeWorkStartedAt !== null) { |
| 1173 | 395 | result.push({ |
| @@ -1179,248 +401,54 @@ export function deriveThreadFeedPresentation( · 5 unmodified lines | ||
| 1179 | 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 | 404 | export function setPendingUserInputCustomAnswer( |
| 1334 | 405 | draft: PendingUserInputDraftAnswer | undefined, |
| 1335 | 406 | customAnswer: string, |
| 1336 | 407 | ): PendingUserInputDraftAnswer { |
| 1337 | 408 | const selectedOptionLabel = |
| 1338 | 409 | customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; |
| 1339 | − return { | |
| 1340 | − customAnswer, | |
| 1341 | − ...(selectedOptionLabel ? { selectedOptionLabel } : {}), | |
| 1342 | − }; | |
| 410 | + return { customAnswer, ...(selectedOptionLabel ? { selectedOptionLabel } : {}) }; | |
| 1343 | 411 | } |
| 1344 | 412 | |
| 1345 | 413 | export function buildPendingUserInputAnswers( |
| 1346 | − questions: ReadonlyArray<UserInputQuestion>, | |
| 414 | + questions: ReadonlyArray<ThreadUserInputQuestion>, | |
| 1347 | 415 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 1348 | 416 | ): Record<string, string> | null { |
| 1349 | 417 | const answers: Record<string, string> = {}; |
| 1350 | − | |
| 1351 | 418 | for (const question of questions) { |
| 1352 | 419 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); |
| 1353 | − if (!answer) { | |
| 1354 | − return null; | |
| 1355 | − } | |
| 420 | + if (!answer) return null; | |
| 1356 | 421 | answers[question.id] = answer; |
| 1357 | 422 | } |
| 1358 | − | |
| 1359 | 423 | return answers; |
| 1360 | 424 | } |
| 1361 | 425 | |
| 1362 | 426 | export function buildThreadFeed( |
| 1363 | − thread: OrchestrationThread, | |
| 1364 | − options?: { | |
| 1365 | − readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; | |
| 1366 | − }, | |
| 427 | + thread: EnvironmentThread, | |
| 428 | + options?: { readonly loadedMessages?: ReadonlyArray<ThreadConversationMessage> }, | |
| 1367 | 429 | ): ThreadFeedEntry[] { |
| 1368 | 430 | const loadedMessages = options?.loadedMessages ?? thread.messages; |
| 1369 | 431 | const oldestLoadedMessageCreatedAt = |
| 1370 | − options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; | |
| 1371 | − const workLogEntries = deriveWorkLogEntries(thread.activities); | |
| 1372 | − const entries = Arr.sortWith( | |
| 1373 | − [ | |
| 1374 | − ...loadedMessages.map<RawThreadFeedEntry>((message) => ({ | |
| 1375 | − type: "message", | |
| 1376 | − id: message.id, | |
| 1377 | − createdAt: message.createdAt, | |
| 1378 | − message, | |
| 432 | + options?.loadedMessages === undefined ? null : (loadedMessages[0]?.createdAt ?? null); | |
| 433 | + const entries: RawThreadFeedEntry[] = [ | |
| 434 | + ...loadedMessages.map((message) => ({ | |
| 435 | + type: "message" as const, | |
| 436 | + id: message.id, | |
| 437 | + createdAt: message.createdAt, | |
| 438 | + message, | |
| 439 | + })), | |
| 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 | |
| 1381 | − .filter((entry) => { | |
| 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); | |
| 452 | + ]; | |
| 453 | + return groupAdjacentActivities(entries.toSorted(byCreatedAt)); | |
| 1426 | 454 | } |
| @@ -1,10 +1,5 @@ | ||
| 1 | −import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; | |
| 2 | −import { | |
| 3 | − createThreadSearchResultsAtomFamily, | |
| 4 | − makeThreadSearchKey, | |
| 5 | − type EnvironmentThreadSearchMatch, | |
| 6 | −} from "@t3tools/client-runtime/state/thread-search"; | |
| 7 | −import { useAtomValue } from "@effect/atom-react"; | |
| 1 | +import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; | |
| 2 | +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 8 | 3 | import * as Option from "effect/Option"; |
| 9 | 4 | import { Atom } from "effect/unstable/reactivity"; |
| 10 | 5 | import { useEffect, useMemo, useState } from "react"; |
| @@ -40,7 +35,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 29 unmodified lines | ||
| 40 | 35 | }); |
| 41 | 36 | |
| 42 | 37 | export interface ThreadDetailView { |
| 43 | − readonly data: OrchestrationThread | null; | |
| 38 | + readonly data: EnvironmentThread | null; | |
| 44 | 39 | readonly error: string | null; |
| 45 | 40 | readonly isPending: boolean; |
| 46 | 41 | readonly isDeleted: boolean; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type EnvironmentThreadState, |
| 8 | 8 | createThreadEnvironmentAtoms, |
| 9 | 9 | } from "@t3tools/client-runtime/state/threads"; |
| 10 | +import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell"; | |
| 10 | 11 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 11 | 12 | import * as Option from "effect/Option"; |
| 12 | 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 | 33 | export function useEnvironmentThread( |
| 33 | 34 | environmentId: EnvironmentId | null, |
| 34 | 35 | threadId: ThreadId | null, |
| 35 | −): EnvironmentThreadState { | |
| 36 | +): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } { | |
| 36 | 37 | const result = useAtomValue( |
| 37 | 38 | environmentId !== null && threadId !== null |
| 38 | 39 | ? environmentThreads.stateAtom(environmentId, threadId) |
| 39 | 40 | : EMPTY_THREAD_STATE_ATOM, |
| 40 | 41 | ); |
| 41 | − return Option.getOrElse( | |
| 42 | + const state = Option.getOrElse( | |
| 42 | 43 | AsyncResult.value(result), |
| 43 | 44 | () => EMPTY_ENVIRONMENT_THREAD_STATE, |
| 44 | 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 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 | 5 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 6 | |
| 7 | 7 | import { threadEnvironment } from "../state/threads"; |
| 8 | 8 | import { scopedRequestKey } from "../lib/scopedEntities"; |
| 9 | 9 | import { |
| 10 | 10 | buildPendingUserInputAnswers, |
| 11 | − derivePendingApprovals, | |
| 12 | − derivePendingUserInputs, | |
| 13 | 11 | setPendingUserInputCustomAnswer, |
| 14 | 12 | sortThreadActivities, |
| 15 | 13 | type PendingUserInputDraftAnswer, |
| @@ -66,14 +64,11 @@ export function useSelectedThreadRequests() { · 50 unmodified lines | ||
| 66 | 64 | const { selectedThread: selectedThreadShell } = useThreadSelection(); |
| 67 | 65 | const selectedThread = useSelectedThreadDetail(); |
| 68 | 66 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); |
| 69 | − const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null); | |
| 70 | − const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>( | |
| 71 | − null, | |
| 72 | − ); | |
| 67 | + const [respondingApprovalId, setRespondingApprovalId] = useState<RuntimeRequestId | null>(null); | |
| 68 | + const [respondingUserInputId, setRespondingUserInputId] = useState<RuntimeRequestId | null>(null); | |
| 73 | 69 | |
| 74 | − // Sort once; both derivations expect the same lifecycle ordering. | |
| 75 | − const sortedActivities = useMemo( | |
| 76 | − () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []), | |
| 70 | + const activePendingApprovals = useMemo( | |
| 71 | + () => selectedThread?.pendingApprovals ?? [], | |
| 77 | 72 | [selectedThread], |
| 78 | 73 | ); |
| 79 | 74 | const activePendingApprovals = useMemo( |
| @@ -82,8 +77,8 @@ export function useSelectedThreadRequests() { · 2 unmodified lines | ||
| 82 | 77 | ); |
| 83 | 78 | const activePendingApproval = activePendingApprovals[0] ?? null; |
| 84 | 79 | const activePendingUserInputs = useMemo( |
| 85 | − () => derivePendingUserInputs(sortedActivities), | |
| 86 | − [sortedActivities], | |
| 80 | + () => selectedThread?.pendingUserInputs ?? [], | |
| 81 | + [selectedThread], | |
| 87 | 82 | ); |
| 88 | 83 | const activePendingUserInput = activePendingUserInputs[0] ?? null; |
| 89 | 84 | const activePendingUserInputDrafts = |
| @@ -97,7 +92,7 @@ export function useSelectedThreadRequests() { · 7 unmodified lines | ||
| 97 | 92 | : null; |
| 98 | 93 | |
| 99 | 94 | const onSelectUserInputOption = useCallback( |
| 100 | − (requestId: ApprovalRequestId, questionId: string, label: string) => { | |
| 95 | + (requestId: RuntimeRequestId, questionId: string, label: string) => { | |
| 101 | 96 | if (!selectedThreadShell) { |
| 102 | 97 | return; |
| 103 | 98 | } |
| @@ -109,7 +104,7 @@ export function useSelectedThreadRequests() { · 5 unmodified lines | ||
| 109 | 104 | ); |
| 110 | 105 | |
| 111 | 106 | const onChangeUserInputCustomAnswer = useCallback( |
| 112 | − (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => { | |
| 107 | + (requestId: RuntimeRequestId, questionId: string, customAnswer: string) => { | |
| 113 | 108 | if (!selectedThreadShell) { |
| 114 | 109 | return; |
| 115 | 110 | } |
| @@ -121,10 +116,16 @@ export function useSelectedThreadRequests() { · 5 unmodified lines | ||
| 121 | 116 | ); |
| 122 | 117 | |
| 123 | 118 | const onRespondToApproval = useCallback( |
| 124 | − async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | |
| 119 | + async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { | |
| 125 | 120 | if (!selectedThreadShell) { |
| 126 | 121 | return; |
| 127 | 122 | } |
| 123 | + if ( | |
| 124 | + activePendingApprovals.find((approval) => approval.requestId === requestId) | |
| 125 | + ?.responseCapability !== "live" | |
| 126 | + ) { | |
| 127 | + return; | |
| 128 | + } | |
| 128 | 129 | |
| 129 | 130 | setRespondingApprovalId(requestId); |
| 130 | 131 | const result = await respondToApproval({ |
| @@ -138,11 +139,16 @@ export function useSelectedThreadRequests() { · 7 unmodified lines | ||
| 138 | 139 | setRespondingApprovalId((current) => (current === requestId ? null : current)); |
| 139 | 140 | return result; |
| 140 | 141 | }, |
| 141 | − [respondToApproval, selectedThreadShell], | |
| 142 | + [activePendingApprovals, respondToApproval, selectedThreadShell], | |
| 142 | 143 | ); |
| 143 | 144 | |
| 144 | 145 | const onSubmitUserInput = useCallback(async () => { |
| 145 | − if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) { | |
| 146 | + if ( | |
| 147 | + !selectedThreadShell || | |
| 148 | + !activePendingUserInput || | |
| 149 | + activePendingUserInput.responseCapability !== "live" || | |
| 150 | + !activePendingUserInputAnswers | |
| 151 | + ) { | |
| 146 | 152 | return; |
| 147 | 153 | } |
| 148 | 154 | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | +import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; | |
| 2 | 3 | import { useCallback, useEffect, useMemo } from "react"; |
| 3 | 4 | |
| 4 | 5 | import { |
| @@ -106,13 +107,13 @@ export function useThreadComposerState() { · 101 unmodified lines | ||
| 106 | 107 | |
| 107 | 108 | const selectedThreadSessionActivity = useMemo(() => { |
| 108 | 109 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; |
| 109 | − if (!selectedThread?.session) { | |
| 110 | + if (!selectedThread?.runtime) { | |
| 110 | 111 | return null; |
| 111 | 112 | } |
| 112 | 113 | |
| 113 | 114 | return { |
| 114 | − orchestrationStatus: selectedThread.session.status, | |
| 115 | − activeTurnId: selectedThread.session.activeTurnId ?? undefined, | |
| 115 | + orchestrationStatus: selectedThread.runtime.status, | |
| 116 | + activeRunId: selectedThread.runtime.activeRunId ?? undefined, | |
| 116 | 117 | }; |
| 117 | 118 | }, [selectedThreadDetail, selectedThreadShell]); |
| 118 | 119 | |
| @@ -123,15 +124,13 @@ export function useThreadComposerState() { · 4 unmodified lines | ||
| 123 | 124 | } |
| 124 | 125 | |
| 125 | 126 | return deriveActiveWorkStartedAt( |
| 126 | − selectedThread.latestTurn, | |
| 127 | + selectedThread.latestRun, | |
| 127 | 128 | selectedThreadSessionActivity, |
| 128 | 129 | null, |
| 129 | 130 | ); |
| 130 | 131 | }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); |
| 131 | 132 | |
| 132 | − const activeThreadBusy = | |
| 133 | − !!selectedThread && | |
| 134 | − (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); | |
| 133 | + const activeThreadBusy = !!selectedThread && threadRuntimeIsActive(selectedThread.runtime); | |
| 135 | 134 | |
| 136 | 135 | const onSendMessage = useCallback(async () => { |
| 137 | 136 | if (!selectedThreadShell) { |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | −import type { | |
| 3 | − EnvironmentProject, | |
| 4 | − EnvironmentThreadShell, | |
| 2 | +import { | |
| 3 | + threadRuntimeIsActive, | |
| 4 | + type EnvironmentThreadShell, | |
| 5 | 5 | } from "@t3tools/client-runtime/state/shell"; |
| 6 | 6 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 7 | 7 | import { |
| @@ -221,6 +221,7 @@ export function useThreadOutboxDrain(): void { · 213 unmodified lines | ||
| 221 | 221 | environmentId: queuedMessage.environmentId, |
| 222 | 222 | input: { |
| 223 | 223 | commandId: queuedMessage.commandId, |
| 224 | + creationSource: "mobile", | |
| 224 | 225 | threadId: queuedMessage.threadId, |
| 225 | 226 | message: { |
| 226 | 227 | messageId: queuedMessage.messageId, |
| @@ -314,7 +315,7 @@ export function useThreadOutboxDrain(): void { · 87 unmodified lines | ||
| 314 | 315 | threadExists: thread !== undefined, |
| 315 | 316 | shellStatus, |
| 316 | 317 | environmentConnected: environment?.connectionState === "connected", |
| 317 | − threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", | |
| 318 | + threadBusy: threadRuntimeIsActive(thread?.runtime), | |
| 318 | 319 | }); |
| 319 | 320 | if (deliveryAction === "wait") { |
| 320 | 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 | 8 | import { |
| 9 | 9 | createAttachmentId, |
| 10 | + createDeterministicAttachmentId, | |
| 10 | 11 | parseThreadSegmentFromAttachmentId, |
| 11 | 12 | resolveAttachmentPathById, |
| 12 | 13 | } from "./attachmentStore.ts"; |
| 13 | 14 | |
| 14 | 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 | 26 | it("sanitizes thread ids when creating attachment ids", () => { |
| 16 | 27 | const attachmentId = createAttachmentId("thread.folder/unsafe space"); |
| 17 | 28 | expect(attachmentId).toBeTruthy(); |
| @@ -42,6 +42,17 @@ export function createAttachmentId(threadId: string): string | null { · 41 unmodified lines | ||
| 42 | 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 | 56 | export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { |
| 46 | 57 | const normalizedId = normalizeAttachmentRelativePath(attachmentId); |
| 47 | 58 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { |
| @@ -11,7 +11,9 @@ import { · 10 unmodified lines | ||
| 11 | 11 | type OrchestrationV2AppThread, |
| 12 | 12 | type OrchestrationV2DomainEvent, |
| 13 | 13 | ProjectId, |
| 14 | + ProviderDriverKind, | |
| 14 | 15 | ProviderInstanceId, |
| 16 | + ProviderThreadId, | |
| 15 | 17 | RunId, |
| 16 | 18 | ThreadId, |
| 17 | 19 | TurnItemId, |
| @@ -60,6 +62,7 @@ const TestLayer = Layer.mergeAll( · 42 unmodified lines | ||
| 60 | 62 | ); |
| 61 | 63 | |
| 62 | 64 | const providerInstanceId = ProviderInstanceId.make("codex"); |
| 65 | +const providerDriver = ProviderDriverKind.make("codex"); | |
| 63 | 66 | const modelSelection = { |
| 64 | 67 | instanceId: providerInstanceId, |
| 65 | 68 | model: "gpt-5.4", |
| @@ -404,7 +407,32 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 338 unmodified lines | ||
| 404 | 407 | const now = yield* DateTime.now; |
| 405 | 408 | const threadId = ThreadId.make("thread:foundation-many-items"); |
| 406 | 409 | const runId = RunId.make("run:foundation-many-items"); |
| 410 | + const providerThreadId = ProviderThreadId.make("provider-thread:foundation-many-items"); | |
| 407 | 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 | 436 | const runEvent = { |
| 409 | 437 | id: EventId.make("event:foundation-many-items:run"), |
| 410 | 438 | type: "run.created" as const, |
| @@ -467,11 +495,13 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 56 unmodified lines | ||
| 467 | 495 | yield* eventSink.write({ |
| 468 | 496 | events: [ |
| 469 | 497 | threadCreatedEvent({ id: "event:foundation-many-items:thread", thread, now }), |
| 498 | + providerThreadEvent, | |
| 470 | 499 | runEvent, |
| 471 | 500 | ...itemEvents, |
| 472 | 501 | ], |
| 473 | 502 | }); |
| 474 | 503 | const beforeUpdate = yield* projectionStore.getThreadProjection(threadId); |
| 504 | + assert.equal(beforeUpdate.thread.activeProviderThreadId, providerThreadId); | |
| 475 | 505 | const ordinals = beforeUpdate.turnItems.map((item) => item.ordinal); |
| 476 | 506 | assert.lengthOf(ordinals, 151); |
| 477 | 507 | assert.equal(new Set(ordinals).size, 151); |
| @@ -1649,6 +1649,54 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1648 unmodified lines | ||
| 1649 | 1649 | const projection = yield* getProjectionWithPendingEvents(command.threadId, events); |
| 1650 | 1650 | const modelSelection = command.modelSelection ?? projection.thread.modelSelection; |
| 1651 | 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 | 1701 | if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { |
| 1654 | 1702 | yield* dispatchSteerIntoRun({ |
| @@ -1783,6 +1831,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 128 unmodified lines | ||
| 1783 | 1831 | completedAt: null, |
| 1784 | 1832 | checkpointId: null, |
| 1785 | 1833 | contextHandoffId: null, |
| 1834 | + ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), | |
| 1786 | 1835 | }; |
| 1787 | 1836 | const attempt: OrchestrationV2RunAttempt = { |
| 1788 | 1837 | id: attemptId, |
| @@ -1861,6 +1910,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 72 unmodified lines | ||
| 1861 | 1910 | occurredAt: now, |
| 1862 | 1911 | payload: run, |
| 1863 | 1912 | }); |
| 1913 | + yield* completeSourcePlan(now); | |
| 1864 | 1914 | yield* emitEvent({ |
| 1865 | 1915 | type: "run-attempt.created", |
| 1866 | 1916 | threadId: command.threadId, |
| @@ -2030,6 +2080,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 163 unmodified lines | ||
| 2030 | 2080 | completedAt: null, |
| 2031 | 2081 | checkpointId: null, |
| 2032 | 2082 | contextHandoffId: null, |
| 2083 | + ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), | |
| 2033 | 2084 | }; |
| 2034 | 2085 | const attempt: OrchestrationV2RunAttempt = { |
| 2035 | 2086 | id: attemptId, |
| @@ -2116,6 +2167,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 80 unmodified lines | ||
| 2116 | 2167 | occurredAt: now, |
| 2117 | 2168 | payload: run, |
| 2118 | 2169 | }); |
| 2170 | + yield* completeSourcePlan(now); | |
| 2119 | 2171 | yield* emitEvent({ |
| 2120 | 2172 | type: "run-attempt.created", |
| 2121 | 2173 | threadId: command.threadId, |
| @@ -2613,6 +2665,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 491 unmodified lines | ||
| 2613 | 2665 | checkpointId: null, |
| 2614 | 2666 | contextHandoffId: |
| 2615 | 2667 | portableForkHandoff?.id ?? providerSwitchHandoff?.id ?? mergeBackHandoff?.id ?? null, |
| 2668 | + ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), | |
| 2616 | 2669 | }; |
| 2617 | 2670 | const attempt: OrchestrationV2RunAttempt = { |
| 2618 | 2671 | id: attemptId, |
| @@ -2917,6 +2970,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 298 unmodified lines | ||
| 2917 | 2970 | occurredAt: now, |
| 2918 | 2971 | payload: run, |
| 2919 | 2972 | }); |
| 2973 | + yield* completeSourcePlan(now); | |
| 2920 | 2974 | yield* emitEvent({ |
| 2921 | 2975 | type: "run-attempt.created", |
| 2922 | 2976 | threadId: command.threadId, |
| @@ -223,6 +223,13 @@ export function applyToProjection( · 222 unmodified lines | ||
| 223 | 223 | case "provider-thread.updated": |
| 224 | 224 | return { |
| 225 | 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 | 233 | providerThreads: upsertById(base.providerThreads, event.payload), |
| 227 | 234 | }; |
| 228 | 235 | case "provider-turn.updated": |
| @@ -288,6 +295,8 @@ type ShellThreadRow = { · 59 unmodified lines | ||
| 288 | 295 | readonly active_run_id: string | null; |
| 289 | 296 | readonly pending_request_payload_json: string | null; |
| 290 | 297 | readonly latest_message_payload_json: string | null; |
| 298 | + readonly latest_user_message_at: string | null; | |
| 299 | + readonly has_actionable_proposed_plan: number; | |
| 291 | 300 | readonly item_count: number; |
| 292 | 301 | }; |
| 293 | 302 | |
| @@ -638,6 +647,13 @@ export function threadShellFromProjection( · 344 unmodified lines | ||
| 638 | 647 | (left, right) => |
| 639 | 648 | DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), |
| 640 | 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 | 657 | return { |
| 642 | 658 | createdBy: projection.thread.createdBy, |
| 643 | 659 | creationSource: projection.thread.creationSource, |
| @@ -673,6 +689,10 @@ export function threadShellFromProjection( · 29 unmodified lines | ||
| 673 | 689 | text: latestVisibleMessage.text, |
| 674 | 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 | 696 | itemCount: activeLocalTurnItems(projection).length, |
| 677 | 697 | visibleItemCount: projection.visibleTurnItems.length, |
| 678 | 698 | createdAt: projection.thread.createdAt, |
| @@ -693,6 +713,8 @@ type ShellThreadState = { · 14 unmodified lines | ||
| 693 | 713 | readonly activeRunId: RunId | null; |
| 694 | 714 | readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null; |
| 695 | 715 | readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null; |
| 716 | + readonly latestUserMessageAt: DateTime.Utc | null; | |
| 717 | + readonly hasActionableProposedPlan: boolean; | |
| 696 | 718 | readonly itemCount: number; |
| 697 | 719 | readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"]; |
| 698 | 720 | readonly runOrdinalById: ReadonlyMap<RunId, number>; |
| @@ -819,6 +841,8 @@ function shellFromState(input: { · 120 unmodified lines | ||
| 819 | 841 | text: input.state.latestVisibleMessage.text, |
| 820 | 842 | updatedAt: input.state.latestVisibleMessage.updatedAt, |
| 821 | 843 | }, |
| 844 | + latestUserMessageAt: input.state.latestUserMessageAt, | |
| 845 | + hasActionableProposedPlan: input.state.hasActionableProposedPlan, | |
| 822 | 846 | itemCount: input.state.itemCount, |
| 823 | 847 | visibleItemCount: input.visibleItemCount, |
| 824 | 848 | createdAt: input.state.thread.createdAt, |
| @@ -1949,6 +1973,21 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 1124 unmodified lines | ||
| 1949 | 1973 | ORDER BY message.updated_at DESC, message.message_id DESC |
| 1950 | 1974 | LIMIT 1 |
| 1951 | 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 | 1992 | SELECT COUNT(*) |
| 1954 | 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 | 2054 | activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), |
| 2016 | 2055 | pendingRuntimeRequest, |
| 2017 | 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 | 2062 | itemCount: row.item_count, |
| 2019 | 2063 | updatedAt: thread.updatedAt, |
| 2020 | 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 | 7 | import * as Context from "effect/Context"; |
| 3 | 8 | import * as Effect from "effect/Effect"; |
| 4 | 9 | import * as Layer from "effect/Layer"; |
| 10 | +import * as Option from "effect/Option"; | |
| 5 | 11 | import * as Schema from "effect/Schema"; |
| 6 | 12 | |
| 13 | +import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; | |
| 7 | 14 | import { |
| 8 | 15 | ProviderAdapterV2RuntimePolicy, |
| 9 | 16 | type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, |
| @@ -15,12 +22,13 @@ import { · 5 unmodified lines | ||
| 15 | 22 | export class RuntimePolicyResolveError extends Schema.TaggedErrorClass<RuntimePolicyResolveError>()( |
| 16 | 23 | "RuntimePolicyResolveError", |
| 17 | 24 | { |
| 18 | − driver: ProviderDriverKind, | |
| 25 | + projectId: ProjectId, | |
| 26 | + providerInstanceId: ProviderInstanceId, | |
| 19 | 27 | cause: Schema.optional(Schema.Defect()), |
| 20 | 28 | }, |
| 21 | 29 | ) { |
| 22 | 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 | 117 | export function layerWithOverride( |
| 65 | 118 | override: RuntimePolicyV2Override, |
| 66 | 119 | ): Layer.Layer<RuntimePolicyV2, never, RuntimePolicyV2> { |
| @@ -80,6 +80,20 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 79 unmodified lines | ||
| 80 | 80 | ( |
| 81 | 81 | command: Parameters<ThreadManagement.ThreadManagementService["Service"]["dispatch"]>[0], |
| 82 | 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 | 97 | if (command.type !== "thread.create") return Effect.die("unexpected command"); |
| 84 | 98 | if (options?.failCreate) { |
| 85 | 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 | 117 | return Effect.succeed({ sequence: 1, storedEvents: [] }); |
| 95 | 118 | }, |
| 96 | 119 | ); |
| 97 | − const createWorktree = vi.fn(() => | |
| 98 | − Effect.succeed({ | |
| 99 | − worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" }, | |
| 100 | − } as never), | |
| 120 | + const createWorktree = vi.fn( | |
| 121 | + (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["createWorktree"]>[0]) => | |
| 122 | + Effect.succeed({ | |
| 123 | + worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" }, | |
| 124 | + } as never), | |
| 101 | 125 | ); |
| 102 | 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 | 138 | const runForThread = vi.fn(() => |
| 104 | 139 | options?.failSetup |
| 105 | 140 | ? Effect.fail( |
| @@ -126,7 +161,12 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 20 unmodified lines | ||
| 126 | 161 | getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), |
| 127 | 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 | 170 | Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { |
| 131 | 171 | runForThread, |
| 132 | 172 | }), |
| @@ -150,8 +190,11 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 17 unmodified lines | ||
| 150 | 190 | Layer.provideMerge(SqlitePersistenceMemory), |
| 151 | 191 | ), |
| 152 | 192 | dispatch, |
| 193 | + fetchRemote, | |
| 153 | 194 | createWorktree, |
| 195 | + projections, | |
| 154 | 196 | removeWorktree, |
| 197 | + resolveRemoteTrackingCommit, | |
| 155 | 198 | runForThread, |
| 156 | 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 | 357 | it.effect("removes a new worktree and does not create a thread when setup fails", () => { |
| 217 | 358 | const test = makeLayer({ failSetup: true }); |
| 218 | 359 | return Effect.gen(function* () { |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | CommandId, |
| 3 | 3 | type ChatAttachment, |
| 4 | + type MessageId, | |
| 4 | 5 | type ModelSelection, |
| 5 | 6 | type OrchestrationV2Actor, |
| 6 | 7 | type OrchestrationV2CreationSource, |
| @@ -27,20 +28,29 @@ import * as IdAllocator from "./IdAllocator.ts"; · 20 unmodified lines | ||
| 27 | 28 | import * as ThreadManagement from "./ThreadManagementService.ts"; |
| 28 | 29 | |
| 29 | 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 | 38 | readonly type: "worktree"; |
| 33 | 39 | readonly baseRef: string; |
| 34 | − readonly branch?: string; | |
| 40 | + readonly branch?: string | undefined; | |
| 41 | + readonly startFromOrigin?: boolean | undefined; | |
| 35 | 42 | }; |
| 36 | 43 | |
| 37 | 44 | export interface ThreadLaunchInitialMessage { |
| 45 | + readonly messageId?: MessageId; | |
| 38 | 46 | readonly text: string; |
| 39 | 47 | readonly attachments: ReadonlyArray<ChatAttachment>; |
| 40 | 48 | } |
| 41 | 49 | |
| 42 | 50 | export interface ThreadLaunchInput { |
| 43 | 51 | readonly commandId: CommandId; |
| 52 | + readonly threadId?: ThreadId; | |
| 53 | + readonly reuseExistingThread?: boolean; | |
| 44 | 54 | readonly projectId: ProjectId; |
| 45 | 55 | readonly title: string; |
| 46 | 56 | readonly modelSelection: ModelSelection; |
| @@ -69,6 +79,7 @@ export class ThreadLaunchError extends Schema.TaggedErrorClass<ThreadLaunchError · 22 unmodified lines | ||
| 69 | 79 | "provision-worktree", |
| 70 | 80 | "run-setup-script", |
| 71 | 81 | "create-thread", |
| 82 | + "update-thread", | |
| 72 | 83 | "dispatch-message", |
| 73 | 84 | "compensate-worktree", |
| 74 | 85 | ]), |
| @@ -185,11 +196,18 @@ export const make = Effect.gen(function* () { · 110 unmodified lines | ||
| 185 | 196 | ), |
| 186 | 197 | ); |
| 187 | 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 | 205 | const threadId = |
| 189 | 206 | stored === null |
| 190 | − ? yield* ids.allocate | |
| 207 | + ? (input.threadId ?? | |
| 208 | + (yield* ids.allocate | |
| 191 | 209 | .thread({ projectId: input.projectId }) |
| 192 | − .pipe(Effect.mapError(mapError(input, "persist-workflow"))) | |
| 210 | + .pipe(Effect.mapError(mapError(input, "persist-workflow"))))) | |
| 193 | 211 | : ThreadId.make(stored.thread_id); |
| 194 | 212 | if (stored?.status === "completed") { |
| 195 | 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 | 254 | let title = stored?.title ?? input.title; |
| 206 | 255 | if (input.initialMessage !== undefined) { |
| 207 | 256 | if (title === "New thread") { |
| @@ -235,15 +284,36 @@ export const make = Effect.gen(function* () { · 27 unmodified lines | ||
| 235 | 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 | 293 | const threadCommitted = stored?.thread_committed === 1; |
| 240 | 294 | let setupCommitted = stored?.setup_committed === 1; |
| 241 | 295 | let messageCommitted = stored?.message_committed === 1; |
| 242 | 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 | 313 | const worktree = yield* git |
| 244 | 314 | .createWorktree({ |
| 245 | 315 | cwd: project.workspaceRoot, |
| 246 | − refName: input.workspaceStrategy.baseRef, | |
| 316 | + refName: startRef, | |
| 247 | 317 | newRefName: branch!, |
| 248 | 318 | baseRefName: input.workspaceStrategy.baseRef, |
| 249 | 319 | path: null, |
| @@ -278,7 +348,7 @@ export const make = Effect.gen(function* () { · 28 unmodified lines | ||
| 278 | 348 | }), |
| 279 | 349 | ); |
| 280 | 350 | if (setupExit._tag === "Failure") { |
| 281 | − if (worktreePath !== null && !threadCommitted) { | |
| 351 | + if (ownsProvisionedWorktree && worktreePath !== null && !threadCommitted) { | |
| 282 | 352 | yield* git |
| 283 | 353 | .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath }) |
| 284 | 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 | 385 | if (!threadCommitted) { |
| 316 | − const createExit = yield* Effect.exit( | |
| 317 | − threads.dispatch({ | |
| 318 | − type: "thread.create", | |
| 319 | − commandId: input.commandId, | |
| 320 | − threadId, | |
| 321 | − projectId: input.projectId, | |
| 322 | − title, | |
| 323 | − modelSelection: input.modelSelection, | |
| 324 | − runtimeMode: input.runtimeMode, | |
| 325 | − interactionMode: input.interactionMode, | |
| 326 | − branch, | |
| 327 | − worktreePath, | |
| 328 | − createdBy: input.createdBy, | |
| 329 | − creationSource: input.creationSource, | |
| 330 | − }), | |
| 331 | − ); | |
| 386 | + const commitThread = | |
| 387 | + input.reuseExistingThread === true | |
| 388 | + ? Effect.gen(function* () { | |
| 389 | + yield* validateReusableThread(); | |
| 390 | + return yield* threads.dispatch({ | |
| 391 | + type: "thread.metadata.update", | |
| 392 | + commandId: input.commandId, | |
| 393 | + threadId, | |
| 394 | + title, | |
| 395 | + branch, | |
| 396 | + worktreePath, | |
| 397 | + }); | |
| 398 | + }) | |
| 399 | + : threads.dispatch({ | |
| 400 | + type: "thread.create", | |
| 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 | 414 | if (createExit._tag === "Failure") { |
| 333 | − if (worktreePath !== null) { | |
| 415 | + if (ownsProvisionedWorktree && worktreePath !== null) { | |
| 334 | 416 | yield* git |
| 335 | 417 | .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath }) |
| 336 | 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 | 422 | yield* saveWorkflow(input, { |
| 341 | 423 | threadId, |
| 342 | − status: "thread_create_failed", | |
| 424 | + status: | |
| 425 | + input.reuseExistingThread === true ? "thread_update_failed" : "thread_create_failed", | |
| 343 | 426 | title, |
| 344 | 427 | worktreePath, |
| 345 | 428 | branch, |
| 346 | 429 | setupCommitted, |
| 347 | 430 | threadCommitted: false, |
| 348 | 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 | 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 | 447 | yield* saveWorkflow(input, { |
| @@ -365,9 +457,11 @@ export const make = Effect.gen(function* () { · 9 unmodified lines | ||
| 365 | 457 | } |
| 366 | 458 | |
| 367 | 459 | if (input.initialMessage !== undefined && !messageCommitted) { |
| 368 | − const messageId = yield* ids.allocate | |
| 369 | − .message({ threadId, ordinal: 1 }) | |
| 370 | − .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); | |
| 460 | + const messageId = | |
| 461 | + input.initialMessage.messageId ?? | |
| 462 | + (yield* ids.allocate | |
| 463 | + .message({ threadId, ordinal: 1 }) | |
| 464 | + .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)))); | |
| 371 | 465 | yield* threads |
| 372 | 466 | .sendToThread({ |
| 373 | 467 | projectId: input.projectId, |
| @@ -32,7 +32,7 @@ import { layer as providerTurnControlServiceLayer } from "./ProviderTurnControlS · 31 unmodified lines | ||
| 32 | 32 | import { layer as providerTurnStartServiceLayer } from "./ProviderTurnStartService.ts"; |
| 33 | 33 | import { layer as runExecutionServiceLayer } from "./RunExecutionService.ts"; |
| 34 | 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 | 36 | import { layer as runtimeRequestServiceLayer } from "./RuntimeRequestService.ts"; |
| 37 | 37 | import { layer as threadManagementServiceLayer } from "./ThreadManagementService.ts"; |
| 38 | 38 | import { layer as threadLaunchServiceLayer } from "./ThreadLaunchService.ts"; |
| @@ -40,6 +40,13 @@ import { layer as threadLifecycleServiceLayer } from "./ThreadLifecycleService.t · 1 unmodified line | ||
| 40 | 40 | import { layer as threadForkServiceLayer } from "./ThreadForkService.ts"; |
| 41 | 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 | 50 | const eventStoreProvided = eventStoreLayer.pipe( |
| 44 | 51 | Layer.provide(OrchestrationEventInfrastructureLayerLive), |
| 45 | 52 | ); |
| @@ -111,7 +118,7 @@ const providerTurnStartServiceProvided = providerTurnStartServiceLayer.pipe( · 65 unmodified lines | ||
| 111 | 118 | projectionStoreLayer, |
| 112 | 119 | providerSessionManagerProvided, |
| 113 | 120 | runExecutionServiceProvided, |
| 114 | − runtimePolicyLayer, | |
| 121 | + runtimePolicyProvided, | |
| 115 | 122 | ), |
| 116 | 123 | ), |
| 117 | 124 | ); |
| @@ -130,7 +137,7 @@ const checkpointRollbackServiceProvided = checkpointRollbackServiceLayer.pipe( · 12 unmodified lines | ||
| 130 | 137 | idAllocatorLayer, |
| 131 | 138 | projectionStoreLayer, |
| 132 | 139 | providerSessionManagerProvided, |
| 133 | − runtimePolicyLayer, | |
| 140 | + runtimePolicyProvided, | |
| 134 | 141 | ), |
| 135 | 142 | ), |
| 136 | 143 | ); |
| @@ -189,7 +196,7 @@ const orchestratorProvided = orchestratorLayer.pipe( · 52 unmodified lines | ||
| 189 | 196 | idAllocatorLayer, |
| 190 | 197 | providerAdapterRegistryProvided, |
| 191 | 198 | providerEventIngestorProvided, |
| 192 | − runtimePolicyLayer, | |
| 199 | + runtimePolicyProvided, | |
| 193 | 200 | providerSessionManagerProvided, |
| 194 | 201 | providerSwitchServiceProvided, |
| 195 | 202 | runExecutionServiceProvided, |
| @@ -201,9 +208,6 @@ const orchestratorProvided = orchestratorLayer.pipe( · 5 unmodified lines | ||
| 201 | 208 | const threadManagementProvided = threadManagementServiceLayer.pipe( |
| 202 | 209 | Layer.provide(orchestratorProvided), |
| 203 | 210 | ); |
| 204 | −export const ProjectServiceLayerLive = projectServiceLayer.pipe( | |
| 205 | − Layer.provide(Layer.merge(ProjectionProjectRepositoryLive, OrchestrationLayerLive)), | |
| 206 | −); | |
| 207 | 211 | export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe( |
| 208 | 212 | Layer.provide(ProjectServiceLayerLive), |
| 209 | 213 | ); |
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | import * as DateTime from "effect/DateTime"; |
| 2 | 2 | import * as Duration from "effect/Duration"; |
| 3 | +import * as Encoding from "effect/Encoding"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 5 | +import * as FileSystem from "effect/FileSystem"; | |
| 4 | 6 | import * as Layer from "effect/Layer"; |
| 5 | 7 | import * as Option from "effect/Option"; |
| 8 | +import * as Path from "effect/Path"; | |
| 6 | 9 | import * as Queue from "effect/Queue"; |
| 7 | 10 | import * as Ref from "effect/Ref"; |
| 8 | 11 | import * as Result from "effect/Result"; |
| @@ -13,9 +16,11 @@ import { · 4 unmodified lines | ||
| 13 | 16 | type AuthAccessStreamEvent, |
| 14 | 17 | type AuthEnvironmentScope, |
| 15 | 18 | AuthSessionId, |
| 19 | + CommandId, | |
| 16 | 20 | type DiscoveredLocalServerList, |
| 17 | 21 | type GitActionProgressEvent, |
| 18 | 22 | type GitManagerServiceError, |
| 23 | + type MessageId, | |
| 19 | 24 | OrchestrationGetFullThreadDiffError, |
| 20 | 25 | OrchestrationGetTurnDiffError, |
| 21 | 26 | ORCHESTRATION_V2_WS_METHODS, |
| @@ -28,18 +33,21 @@ import { · 6 unmodified lines | ||
| 28 | 33 | type ProjectEntriesFailure, |
| 29 | 34 | type ProjectFileFailure, |
| 30 | 35 | type ProjectFileOperation, |
| 36 | + type ProjectMutation, | |
| 31 | 37 | ProjectListEntriesError, |
| 32 | 38 | ProjectReadFileError, |
| 33 | 39 | ProjectSearchContentsError, |
| 34 | 40 | ProjectSearchEntriesError, |
| 35 | 41 | ProjectWriteFileError, |
| 42 | + ProjectMutationError, | |
| 36 | 43 | RelayClientInstallFailedError, |
| 37 | 44 | type RelayClientInstallProgressEvent, |
| 38 | 45 | type FilesystemBrowseFailure, |
| 39 | 46 | FilesystemBrowseError, |
| 40 | 47 | AssetWorkspaceContextNotFoundError, |
| 41 | 48 | AssetWorkspaceContextResolutionError, |
| 42 | − RpcClientId, | |
| 49 | + ChatAttachmentId, | |
| 50 | + PersistChatAttachmentsError, | |
| 43 | 51 | EnvironmentAuthorizationError, |
| 44 | 52 | ThreadId, |
| 45 | 53 | type TerminalAttachStreamEvent, |
| @@ -81,6 +89,8 @@ import * as TerminalManager from "./terminal/Manager.ts"; · 35 unmodified lines | ||
| 81 | 89 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 82 | 90 | import * as PreviewManager from "./preview/Manager.ts"; |
| 83 | 91 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 92 | +import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts"; | |
| 93 | +import { parseBase64DataUrl } from "./imageMime.ts"; | |
| 84 | 94 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 85 | 95 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 86 | 96 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| @@ -118,6 +128,73 @@ function unexpectedCompatibilityError(error: never): never { · 31 unmodified lines | ||
| 118 | 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 | 198 | function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { |
| 122 | 199 | readonly failure: ProjectEntriesFailure; |
| 123 | 200 | readonly normalizedCwd?: string; |
| @@ -253,8 +330,11 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 129 unmodified lines | ||
| 253 | 330 | [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], |
| 254 | 331 | [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], |
| 255 | 332 | [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], |
| 333 | + [WS_METHODS.projectsMutate, AuthOrchestrationOperateScope], | |
| 256 | 334 | [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], |
| 257 | 335 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], |
| 336 | + [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], | |
| 337 | + [WS_METHODS.assetsPersistChatAttachments, AuthOrchestrationOperateScope], | |
| 258 | 338 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], |
| 259 | 339 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], |
| 260 | 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( | |
| 627 | − Effect.map((snapshot) => ({ | |
| 628 | − schemaVersion: snapshot.schemaVersion, | |
| 629 | − snapshotSequence: snapshot.snapshotSequence, | |
| 630 | − threads: snapshot.archivedThreads, | |
| 631 | − })), | |
| 632 | − Effect.mapError( | |
| 633 | − (cause) => | |
| 634 | − new OrchestrationV2GetShellSnapshotError({ | |
| 635 | − message: "Failed to load archived thread snapshot", | |
| 636 | − cause, | |
| 637 | − }), | |
| 638 | − ), | |
| 639 | − ); | |
| 706 | + const getOrchestrationV2ArchivedShellSnapshot = sql | |
| 707 | + .withTransaction( | |
| 708 | + Effect.gen(function* () { | |
| 709 | + const projects = yield* projectionSnapshotQuery.getShellSnapshot(); | |
| 710 | + const threads = yield* threadManagement.getShellSnapshot(); | |
| 711 | + return { | |
| 712 | + schemaVersion: threads.schemaVersion, | |
| 713 | + snapshotSequence: yield* applicationEvents.latestApplicationSequence, | |
| 714 | + projects: projects.projects, | |
| 715 | + threads: threads.archivedThreads, | |
| 716 | + } as const; | |
| 717 | + }), | |
| 718 | + ) | |
| 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 | 729 | const subscribeOrchestrationV2ArchivedShell = Effect.fn( |
| 642 | 730 | "ws.orchestrationV2.subscribeArchivedShell", |
| @@ -671,6 +759,64 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 28 unmodified lines | ||
| 671 | 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 | 820 | const handlers = ServerWsRpcGroup.of({ |
| 675 | 821 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => |
| 676 | 822 | observeRpcEffect( |
| @@ -680,7 +826,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 3 unmodified lines | ||
| 680 | 826 | threadManagement.dispatch( |
| 681 | 827 | ThreadManagementService.withCreationProvenance(command, { |
| 682 | 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 | 917 | .enqueueCommand( |
| 772 | 918 | threadLaunch.launch({ |
| 773 | 919 | commandId: input.commandId, |
| 920 | + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), | |
| 921 | + ...(input.reuseExistingThread === undefined | |
| 922 | + ? {} | |
| 923 | + : { reuseExistingThread: input.reuseExistingThread }), | |
| 774 | 924 | projectId: input.projectId, |
| 775 | 925 | title: input.title, |
| 776 | 926 | modelSelection: input.modelSelection, |
| 777 | 927 | runtimeMode: input.runtimeMode, |
| 778 | 928 | interactionMode: input.interactionMode, |
| 779 | − 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 | − }, | |
| 929 | + workspaceStrategy: input.workspaceStrategy, | |
| 789 | 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 | 941 | createdBy: "user", |
| 793 | − creationSource: "web", | |
| 942 | + creationSource: input.creationSource ?? "web", | |
| 794 | 943 | }), |
| 795 | 944 | ) |
| 796 | 945 | .pipe( |
| @@ -1137,6 +1286,22 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 340 unmodified lines | ||
| 1137 | 1286 | ), |
| 1138 | 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 | 1305 | [WS_METHODS.shellOpenInEditor]: (input) => |
| 1141 | 1306 | observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { |
| 1142 | 1307 | "rpc.aggregate": "workspace", |
| @@ -1195,6 +1360,12 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 52 unmodified lines | ||
| 1195 | 1360 | }), |
| 1196 | 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 | 1369 | [WS_METHODS.subscribeVcsStatus]: (input) => |
| 1199 | 1370 | observeRpcStream( |
| 1200 | 1371 | WS_METHODS.subscribeVcsStatus, |
| @@ -116,6 +116,8 @@ export function BranchToolbarBranchSelector({ · 115 unmodified lines | ||
| 116 | 116 | () => scopeThreadRef(environmentId, threadId), |
| 117 | 117 | [environmentId, threadId], |
| 118 | 118 | ); |
| 119 | + const serverThread = useThread(threadRef); | |
| 120 | + const serverSession = serverThread?.runtime ?? null; | |
| 119 | 121 | const draftThread = useComposerDraftStore((store) => |
| 120 | 122 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), |
| 121 | 123 | ); |
| @@ -1,14 +1,8 @@ | ||
| 1 | −import { | |
| 2 | − EnvironmentId, | |
| 3 | − MessageId, | |
| 4 | − ProjectId, | |
| 5 | − ProviderInstanceId, | |
| 6 | − ThreadId, | |
| 7 | − TurnId, | |
| 8 | −} from "@t3tools/contracts"; | |
| 1 | +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts"; | |
| 9 | 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 | 6 | import { |
| 13 | 7 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, |
| 14 | 8 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| @@ -37,7 +31,7 @@ const threadId = ThreadId.make("thread-1"); · 22 unmodified lines | ||
| 37 | 31 | const now = "2026-03-29T00:00:00.000Z"; |
| 38 | 32 | |
| 39 | 33 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 40 | − return { | |
| 34 | + return makeThreadFixture({ | |
| 41 | 35 | id: threadId, |
| 42 | 36 | environmentId, |
| 43 | 37 | projectId, |
| @@ -48,27 +42,25 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines | ||
| 48 | 42 | }, |
| 49 | 43 | runtimeMode: "full-access", |
| 50 | 44 | interactionMode: "default", |
| 51 | − session: null, | |
| 45 | + runtime: null, | |
| 52 | 46 | messages: [], |
| 53 | 47 | proposedPlans: [], |
| 54 | − activities: [], | |
| 55 | − checkpoints: [], | |
| 56 | 48 | createdAt: now, |
| 57 | 49 | updatedAt: now, |
| 58 | 50 | archivedAt: null, |
| 59 | 51 | settledOverride: null, |
| 60 | 52 | settledAt: null, |
| 61 | 53 | deletedAt: null, |
| 62 | − latestTurn: null, | |
| 54 | + latestRun: null, | |
| 63 | 55 | branch: null, |
| 64 | 56 | worktreePath: null, |
| 65 | 57 | ...overrides, |
| 66 | − }; | |
| 58 | + }); | |
| 67 | 59 | } |
| 68 | 60 | |
| 69 | 61 | const completedTurn = { |
| 70 | − turnId: TurnId.make("turn-1"), | |
| 71 | − state: "completed" as const, | |
| 62 | + runId: RunId.make("turn-1"), | |
| 63 | + status: "completed" as const, | |
| 72 | 64 | requestedAt: now, |
| 73 | 65 | startedAt: "2026-03-29T00:00:01.000Z", |
| 74 | 66 | completedAt: "2026-03-29T00:00:10.000Z", |
| @@ -76,12 +68,10 @@ const completedTurn = { · 1 unmodified line | ||
| 76 | 68 | }; |
| 77 | 69 | |
| 78 | 70 | const readySession = { |
| 79 | − threadId, | |
| 80 | − status: "ready" as const, | |
| 71 | + status: "completed" as const, | |
| 81 | 72 | providerName: "codex", |
| 82 | 73 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 83 | − runtimeMode: "full-access" as const, | |
| 84 | − activeTurnId: null, | |
| 74 | + activeRunId: null, | |
| 85 | 75 | lastError: null, |
| 86 | 76 | updatedAt: "2026-03-29T00:00:10.000Z", |
| 87 | 77 | }; |
| @@ -527,16 +517,15 @@ describe("startNewThreadForProject", () => { · 439 unmodified lines | ||
| 527 | 517 | describe("hasServerAcknowledgedLocalDispatch", () => { |
| 528 | 518 | it("does not acknowledge unchanged server state", () => { |
| 529 | 519 | const localDispatch = createLocalDispatchSnapshot( |
| 530 | − makeThread({ latestTurn: completedTurn, session: readySession }), | |
| 520 | + makeThread({ latestRun: completedTurn, runtime: readySession }), | |
| 531 | 521 | ); |
| 532 | 522 | |
| 533 | 523 | expect( |
| 534 | 524 | hasServerAcknowledgedLocalDispatch({ |
| 535 | 525 | localDispatch, |
| 536 | 526 | phase: "ready", |
| 537 | − latestTurn: completedTurn, | |
| 538 | − latestUserMessageId: localDispatch.latestUserMessageId, | |
| 539 | − session: readySession, | |
| 527 | + latestRun: completedTurn, | |
| 528 | + runtime: readySession, | |
| 540 | 529 | hasPendingApproval: false, |
| 541 | 530 | hasPendingUserInput: false, |
| 542 | 531 | threadError: null, |
| @@ -546,11 +535,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | ||
| 546 | 535 | |
| 547 | 536 | it("acknowledges a settled newer turn", () => { |
| 548 | 537 | const localDispatch = createLocalDispatchSnapshot( |
| 549 | − makeThread({ latestTurn: completedTurn, session: readySession }), | |
| 538 | + makeThread({ latestRun: completedTurn, runtime: readySession }), | |
| 550 | 539 | ); |
| 551 | 540 | const newerTurn = { |
| 552 | 541 | ...completedTurn, |
| 553 | − turnId: TurnId.make("turn-2"), | |
| 542 | + runId: RunId.make("turn-2"), | |
| 554 | 543 | requestedAt: "2026-03-29T00:01:00.000Z", |
| 555 | 544 | startedAt: "2026-03-29T00:01:01.000Z", |
| 556 | 545 | completedAt: "2026-03-29T00:01:30.000Z", |
| @@ -560,9 +549,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | ||
| 560 | 549 | hasServerAcknowledgedLocalDispatch({ |
| 561 | 550 | localDispatch, |
| 562 | 551 | phase: "ready", |
| 563 | − latestTurn: newerTurn, | |
| 564 | − latestUserMessageId: localDispatch.latestUserMessageId, | |
| 565 | − session: { ...readySession, updatedAt: newerTurn.completedAt }, | |
| 552 | + latestRun: newerTurn, | |
| 553 | + runtime: { ...readySession, updatedAt: newerTurn.completedAt }, | |
| 566 | 554 | hasPendingApproval: false, |
| 567 | 555 | hasPendingUserInput: false, |
| 568 | 556 | threadError: null, |
| @@ -572,12 +560,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | ||
| 572 | 560 | |
| 573 | 561 | it("waits for the matching running turn before acknowledging", () => { |
| 574 | 562 | const localDispatch = createLocalDispatchSnapshot( |
| 575 | − makeThread({ latestTurn: completedTurn, session: readySession }), | |
| 563 | + makeThread({ latestRun: completedTurn, runtime: readySession }), | |
| 576 | 564 | ); |
| 577 | 565 | const runningTurn = { |
| 578 | 566 | ...completedTurn, |
| 579 | − turnId: TurnId.make("turn-2"), | |
| 580 | − state: "running" as const, | |
| 567 | + runId: RunId.make("turn-2"), | |
| 568 | + status: "running" as const, | |
| 581 | 569 | requestedAt: "2026-03-29T00:01:00.000Z", |
| 582 | 570 | startedAt: "2026-03-29T00:01:01.000Z", |
| 583 | 571 | completedAt: null, |
| @@ -587,12 +575,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines | ||
| 587 | 575 | hasServerAcknowledgedLocalDispatch({ |
| 588 | 576 | localDispatch, |
| 589 | 577 | phase: "running", |
| 590 | − latestTurn: runningTurn, | |
| 591 | − latestUserMessageId: localDispatch.latestUserMessageId, | |
| 592 | − session: { | |
| 578 | + latestRun: runningTurn, | |
| 579 | + runtime: { | |
| 593 | 580 | ...readySession, |
| 594 | 581 | status: "running", |
| 595 | − activeTurnId: TurnId.make("turn-other"), | |
| 582 | + activeRunId: RunId.make("turn-other"), | |
| 596 | 583 | }, |
| 597 | 584 | hasPendingApproval: false, |
| 598 | 585 | hasPendingUserInput: false, |
| @@ -603,12 +590,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 4 unmodified lines | ||
| 603 | 590 | hasServerAcknowledgedLocalDispatch({ |
| 604 | 591 | localDispatch, |
| 605 | 592 | phase: "running", |
| 606 | − latestTurn: runningTurn, | |
| 607 | − latestUserMessageId: localDispatch.latestUserMessageId, | |
| 608 | − session: { | |
| 593 | + latestRun: runningTurn, | |
| 594 | + runtime: { | |
| 609 | 595 | ...readySession, |
| 610 | 596 | status: "running", |
| 611 | − activeTurnId: runningTurn.turnId, | |
| 597 | + activeRunId: runningTurn.runId, | |
| 612 | 598 | }, |
| 613 | 599 | hasPendingApproval: false, |
| 614 | 600 | hasPendingUserInput: false, |
| @@ -665,9 +651,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 50 unmodified lines | ||
| 665 | 651 | const common = { |
| 666 | 652 | localDispatch, |
| 667 | 653 | phase: "ready" as const, |
| 668 | − latestTurn: null, | |
| 669 | − latestUserMessageId: localDispatch.latestUserMessageId, | |
| 670 | − session: null, | |
| 654 | + latestRun: null, | |
| 655 | + runtime: null, | |
| 671 | 656 | hasPendingApproval: false, |
| 672 | 657 | hasPendingUserInput: false, |
| 673 | 658 | threadError: null, |
| @@ -8,9 +8,11 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type ScopedProjectRef, |
| 9 | 9 | type ScopedThreadRef, |
| 10 | 10 | type ThreadId, |
| 11 | − type TurnId, | |
| 11 | + type RunId, | |
| 12 | 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 | 16 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 15 | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| @@ -70,29 +72,46 @@ export function buildLocalDraftThread( · 53 unmodified lines | ||
| 70 | 72 | draftThread: DraftThreadState, |
| 71 | 73 | fallbackModelSelection: ModelSelection, |
| 72 | 74 | ): Thread { |
| 73 | − return { | |
| 74 | − id: threadId, | |
| 75 | − environmentId: draftThread.environmentId, | |
| 76 | − projectId: draftThread.projectId, | |
| 77 | − title: "New thread", | |
| 78 | − modelSelection: fallbackModelSelection, | |
| 79 | − runtimeMode: draftThread.runtimeMode, | |
| 80 | − interactionMode: draftThread.interactionMode, | |
| 81 | − session: null, | |
| 75 | + const timestamp = DateTime.makeUnsafe(draftThread.createdAt); | |
| 76 | + return presentThread(draftThread.environmentId, { | |
| 77 | + thread: { | |
| 78 | + id: threadId, | |
| 79 | + projectId: draftThread.projectId, | |
| 80 | + title: "New thread", | |
| 81 | + providerInstanceId: fallbackModelSelection.instanceId, | |
| 82 | + modelSelection: fallbackModelSelection, | |
| 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 | 105 | messages: [], |
| 83 | − createdAt: draftThread.createdAt, | |
| 84 | − updatedAt: draftThread.createdAt, | |
| 85 | − archivedAt: null, | |
| 86 | − settledOverride: null, | |
| 87 | − settledAt: null, | |
| 88 | − deletedAt: null, | |
| 89 | − latestTurn: null, | |
| 90 | − branch: draftThread.branch, | |
| 91 | − worktreePath: draftThread.worktreePath, | |
| 106 | + plans: [], | |
| 107 | + turnItems: [], | |
| 108 | + checkpointScopes: [], | |
| 92 | 109 | checkpoints: [], |
| 93 | − activities: [], | |
| 94 | − proposedPlans: [], | |
| 95 | − }; | |
| 110 | + contextHandoffs: [], | |
| 111 | + contextTransfers: [], | |
| 112 | + visibleTurnItems: [], | |
| 113 | + updatedAt: timestamp, | |
| 114 | + }); | |
| 96 | 115 | } |
| 97 | 116 | |
| 98 | 117 | export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { |
| @@ -353,7 +372,7 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean · 254 unmodified lines | ||
| 353 | 372 | |
| 354 | 373 | export function threadHasStarted(thread: Thread | null | undefined): boolean { |
| 355 | 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 | 396 | if (!threadHasStarted(input.thread)) { |
| 378 | 397 | return null; |
| 379 | 398 | } |
| 380 | − const sessionProvider = input.thread?.session?.providerName ?? null; | |
| 399 | + const sessionProvider = input.thread?.runtime?.providerName ?? null; | |
| 381 | 400 | if (sessionProvider && isProviderDriverKind(sessionProvider)) { |
| 382 | 401 | return sessionProvider; |
| 383 | 402 | } |
| @@ -478,41 +497,37 @@ export async function waitForStartedServerThread( · 94 unmodified lines | ||
| 478 | 497 | export interface LocalDispatchSnapshot { |
| 479 | 498 | startedAt: string; |
| 480 | 499 | preparingWorktree: boolean; |
| 481 | − latestUserMessageId: ChatMessage["id"] | null; | |
| 482 | − latestTurnTurnId: TurnId | null; | |
| 483 | − latestTurnRequestedAt: string | null; | |
| 484 | − latestTurnStartedAt: string | null; | |
| 485 | − latestTurnCompletedAt: string | null; | |
| 486 | − sessionStatus: NonNullable<Thread["session"]>["status"] | null; | |
| 487 | − sessionUpdatedAt: string | null; | |
| 500 | + latestRunId: RunId | null; | |
| 501 | + latestRunRequestedAt: string | null; | |
| 502 | + latestRunStartedAt: string | null; | |
| 503 | + latestRunCompletedAt: string | null; | |
| 504 | + runtimeStatus: NonNullable<Thread["runtime"]>["status"] | null; | |
| 505 | + runtimeUpdatedAt: string | null; | |
| 488 | 506 | } |
| 489 | 507 | |
| 490 | 508 | export function createLocalDispatchSnapshot( |
| 491 | 509 | activeThread: Thread | undefined, |
| 492 | 510 | options?: { preparingWorktree?: boolean }, |
| 493 | 511 | ): LocalDispatchSnapshot { |
| 494 | − const latestTurn = activeThread?.latestTurn ?? null; | |
| 495 | − const session = activeThread?.session ?? null; | |
| 496 | − const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user"); | |
| 512 | + const latestRun = activeThread?.latestRun ?? null; | |
| 513 | + const runtime = activeThread?.runtime ?? null; | |
| 497 | 514 | return { |
| 498 | 515 | startedAt: new Date().toISOString(), |
| 499 | 516 | preparingWorktree: Boolean(options?.preparingWorktree), |
| 500 | − latestUserMessageId: latestUserMessage?.id ?? null, | |
| 501 | − latestTurnTurnId: latestTurn?.turnId ?? null, | |
| 502 | − latestTurnRequestedAt: latestTurn?.requestedAt ?? null, | |
| 503 | − latestTurnStartedAt: latestTurn?.startedAt ?? null, | |
| 504 | − latestTurnCompletedAt: latestTurn?.completedAt ?? null, | |
| 505 | − sessionStatus: session?.status ?? null, | |
| 506 | − sessionUpdatedAt: session?.updatedAt ?? null, | |
| 517 | + latestRunId: latestRun?.runId ?? null, | |
| 518 | + latestRunRequestedAt: latestRun?.requestedAt ?? null, | |
| 519 | + latestRunStartedAt: latestRun?.startedAt ?? null, | |
| 520 | + latestRunCompletedAt: latestRun?.completedAt ?? null, | |
| 521 | + runtimeStatus: runtime?.status ?? null, | |
| 522 | + runtimeUpdatedAt: runtime?.updatedAt ?? null, | |
| 507 | 523 | }; |
| 508 | 524 | } |
| 509 | 525 | |
| 510 | 526 | export function hasServerAcknowledgedLocalDispatch(input: { |
| 511 | 527 | localDispatch: LocalDispatchSnapshot | null; |
| 512 | 528 | phase: SessionPhase; |
| 513 | − latestTurn: Thread["latestTurn"] | null; | |
| 514 | − latestUserMessageId: ChatMessage["id"] | null; | |
| 515 | − session: Thread["session"] | null; | |
| 529 | + latestRun: Thread["latestRun"] | null; | |
| 530 | + runtime: Thread["runtime"] | null; | |
| 516 | 531 | hasPendingApproval: boolean; |
| 517 | 532 | hasPendingUserInput: boolean; |
| 518 | 533 | threadError: string | null | undefined; |
| @@ -524,34 +539,25 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 5 unmodified lines | ||
| 524 | 539 | return true; |
| 525 | 540 | } |
| 526 | 541 | |
| 527 | − const latestTurn = input.latestTurn ?? null; | |
| 528 | − const session = input.session ?? null; | |
| 529 | − const latestUserMessageChanged = | |
| 530 | − input.localDispatch.latestUserMessageId !== input.latestUserMessageId; | |
| 531 | − const latestTurnChanged = | |
| 532 | − input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || | |
| 533 | − input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || | |
| 534 | − input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || | |
| 535 | − input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); | |
| 542 | + const latestRun = input.latestRun ?? null; | |
| 543 | + const runtime = input.runtime ?? null; | |
| 544 | + const latestRunChanged = | |
| 545 | + input.localDispatch.latestRunId !== (latestRun?.runId ?? null) || | |
| 546 | + input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) || | |
| 547 | + input.localDispatch.latestRunStartedAt !== (latestRun?.startedAt ?? null) || | |
| 548 | + input.localDispatch.latestRunCompletedAt !== (latestRun?.completedAt ?? null); | |
| 536 | 549 | |
| 537 | 550 | if (input.phase === "running") { |
| 538 | − // Steering adds a user message to the current running turn without | |
| 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) { | |
| 551 | + if (!latestRunChanged) { | |
| 546 | 552 | return false; |
| 547 | 553 | } |
| 548 | − if (latestTurn?.startedAt === null || latestTurn === null) { | |
| 554 | + if (latestRun?.startedAt === null || latestRun === null) { | |
| 549 | 555 | return false; |
| 550 | 556 | } |
| 551 | 557 | if ( |
| 552 | − session?.activeTurnId !== null && | |
| 553 | − session?.activeTurnId !== undefined && | |
| 554 | − latestTurn?.turnId !== session.activeTurnId | |
| 558 | + runtime?.activeRunId !== null && | |
| 559 | + runtime?.activeRunId !== undefined && | |
| 560 | + latestRun?.runId !== runtime.activeRunId | |
| 555 | 561 | ) { |
| 556 | 562 | return false; |
| 557 | 563 | } |
| @@ -559,8 +565,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 1 unmodified line | ||
| 559 | 565 | } |
| 560 | 566 | |
| 561 | 567 | return ( |
| 562 | − latestTurnChanged || | |
| 563 | − input.localDispatch.sessionStatus !== (session?.status ?? null) || | |
| 564 | − input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) | |
| 568 | + latestRunChanged || | |
| 569 | + input.localDispatch.runtimeStatus !== (runtime?.status ?? null) || | |
| 570 | + input.localDispatch.runtimeUpdatedAt !== (runtime?.updatedAt ?? null) | |
| 565 | 571 | ); |
| 566 | 572 | } |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { |
| 2 | − type ApprovalRequestId, | |
| 3 | 2 | DEFAULT_MODEL, |
| 4 | 3 | defaultInstanceIdForDriver, |
| 5 | 4 | type EnvironmentId, |
| @@ -13,14 +12,15 @@ import { · 7 unmodified lines | ||
| 13 | 12 | type ResolvedKeybindingsConfig, |
| 14 | 13 | type ScopedThreadRef, |
| 15 | 14 | type ThreadId, |
| 16 | − type TurnId, | |
| 15 | + type RunId, | |
| 16 | + type RuntimeRequestId, | |
| 17 | 17 | type KeybindingCommand, |
| 18 | − OrchestrationThreadActivity, | |
| 19 | 18 | ProviderInteractionMode, |
| 20 | 19 | ProviderDriverKind, |
| 21 | 20 | RuntimeMode, |
| 22 | 21 | TerminalOpenInput, |
| 23 | 22 | } from "@t3tools/contracts"; |
| 23 | +import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell"; | |
| 24 | 24 | import { |
| 25 | 25 | connectionStatusTitle, |
| 26 | 26 | type EnvironmentConnectionPresentation, |
| @@ -78,13 +78,14 @@ import { · 51 unmodified lines | ||
| 78 | 78 | derivePendingUserInputs, |
| 79 | 79 | derivePhase, |
| 80 | 80 | deriveTimelineEntries, |
| 81 | + deriveTimelineEntriesFromVisibleTurnItems, | |
| 81 | 82 | deriveActiveWorkStartedAt, |
| 82 | 83 | deriveActivePlanState, |
| 83 | 84 | findSidebarProposedPlan, |
| 84 | 85 | findLatestProposedPlan, |
| 85 | 86 | deriveWorkLogEntries, |
| 86 | 87 | hasActionableProposedPlan, |
| 87 | − isLatestTurnSettled, | |
| 88 | + isLatestRunSettled, | |
| 88 | 89 | } from "../session-logic"; |
| 89 | 90 | import { type LegendListRef } from "@legendapp/list/react"; |
| 90 | 91 | import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; |
| @@ -217,7 +218,7 @@ import { · 126 unmodified lines | ||
| 217 | 218 | useThread, |
| 218 | 219 | useThreadProposedPlans, |
| 219 | 220 | useThreadRefs, |
| 220 | − useThreadShell, | |
| 221 | + useThreadVisibleTurnItems, | |
| 221 | 222 | } from "../state/entities"; |
| 222 | 223 | import { environmentShell } from "../state/shell"; |
| 223 | 224 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| @@ -309,7 +310,7 @@ import { useAssetUrls } from "../assets/assetUrls"; · 85 unmodified lines | ||
| 309 | 310 | |
| 310 | 311 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 311 | 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 | 314 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 314 | 315 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 315 | 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 | 496 | function useLocalDispatchState(input: { |
| 496 | 497 | activeThread: Thread | undefined; |
| 497 | − activeLatestTurn: Thread["latestTurn"] | null; | |
| 498 | + activeLatestRun: Thread["latestRun"] | null; | |
| 498 | 499 | phase: SessionPhase; |
| 499 | − activePendingApproval: ApprovalRequestId | null; | |
| 500 | − activePendingUserInput: ApprovalRequestId | null; | |
| 500 | + activePendingApproval: RuntimeRequestId | null; | |
| 501 | + activePendingUserInput: RuntimeRequestId | null; | |
| 501 | 502 | threadError: string | null | undefined; |
| 502 | 503 | }) { |
| 503 | 504 | const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null); |
| @@ -513,18 +514,17 @@ function useLocalDispatchState(input: { · 9 unmodified lines | ||
| 513 | 514 | hasServerAcknowledgedLocalDispatch({ |
| 514 | 515 | localDispatch, |
| 515 | 516 | phase: input.phase, |
| 516 | − latestTurn: input.activeLatestTurn, | |
| 517 | − latestUserMessageId, | |
| 518 | − session: input.activeThread?.session ?? null, | |
| 517 | + latestRun: input.activeLatestRun, | |
| 518 | + runtime: input.activeThread?.runtime ?? null, | |
| 519 | 519 | hasPendingApproval: input.activePendingApproval !== null, |
| 520 | 520 | hasPendingUserInput: input.activePendingUserInput !== null, |
| 521 | 521 | threadError: input.threadError, |
| 522 | 522 | }), |
| 523 | 523 | [ |
| 524 | − input.activeLatestTurn, | |
| 524 | + input.activeLatestRun, | |
| 525 | 525 | input.activePendingApproval, |
| 526 | 526 | input.activePendingUserInput, |
| 527 | − input.activeThread?.session, | |
| 527 | + input.activeThread?.runtime, | |
| 528 | 528 | input.phase, |
| 529 | 529 | input.threadError, |
| 530 | 530 | latestUserMessageId, |
| @@ -1202,23 +1202,10 @@ function ChatViewContent(props: ChatViewProps) { · 671 unmodified lines | ||
| 1202 | 1202 | ); |
| 1203 | 1203 | const composerDraftTarget: ScopedThreadRef | DraftId = |
| 1204 | 1204 | routeKind === "server" ? routeThreadRef : props.draftId; |
| 1205 | − const draftThread = useComposerDraftStore((store) => | |
| 1206 | − routeKind === "server" | |
| 1207 | − ? store.getDraftSessionByRef(routeThreadRef) | |
| 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], | |
| 1205 | + const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); | |
| 1206 | + const serverVisibleTurnItems = useThreadVisibleTurnItems( | |
| 1207 | + routeKind === "server" ? routeThreadRef : null, | |
| 1220 | 1208 | ); |
| 1221 | − const activeServerThread = serverThread ?? loadingServerThread; | |
| 1222 | 1209 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 1223 | 1210 | const activeThreadLastVisitedAt = useUiStateStore( |
| 1224 | 1211 | (store) => store.threadLastVisitedAtById[routeThreadKey], |
| @@ -1293,9 +1280,9 @@ function ChatViewContent(props: ChatViewProps) { · 68 unmodified lines | ||
| 1293 | 1280 | const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>( |
| 1294 | 1281 | null, |
| 1295 | 1282 | ); |
| 1296 | − const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); | |
| 1283 | + const [respondingRequestIds, setRespondingRequestIds] = useState<RuntimeRequestId[]>([]); | |
| 1297 | 1284 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< |
| 1298 | − ApprovalRequestId[] | |
| 1285 | + RuntimeRequestId[] | |
| 1299 | 1286 | >([]); |
| 1300 | 1287 | const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< |
| 1301 | 1288 | Record<string, Record<string, PendingUserInputDraftAnswer>> |
| @@ -1454,7 +1441,7 @@ function ChatViewContent(props: ChatViewProps) { · 152 unmodified lines | ||
| 1454 | 1441 | const isServerThread = activeServerThread !== null; |
| 1455 | 1442 | const activeThread = activeServerThread ?? localDraftThread; |
| 1456 | 1443 | const threadError = isServerThread |
| 1457 | − ? (localServerError ?? activeServerThread?.session?.lastError ?? null) | |
| 1444 | + ? (localServerError ?? serverThread?.runtime?.lastError ?? null) | |
| 1458 | 1445 | : localDraftError; |
| 1459 | 1446 | const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 1460 | 1447 | const interactionMode = |
| @@ -1576,14 +1563,14 @@ function ChatViewContent(props: ChatViewProps) { · 115 unmodified lines | ||
| 1576 | 1563 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1577 | 1564 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| 1578 | 1565 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); |
| 1579 | − const activeLatestTurn = activeThread?.latestTurn ?? null; | |
| 1566 | + const activeLatestRun = activeThread?.latestRun ?? null; | |
| 1580 | 1567 | const sourcePlanThreadRef = useMemo(() => { |
| 1581 | − const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; | |
| 1568 | + const sourceThreadId = activeLatestRun?.sourcePlanRef?.threadId; | |
| 1582 | 1569 | if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { |
| 1583 | 1570 | return null; |
| 1584 | 1571 | } |
| 1585 | 1572 | return scopeThreadRef(activeThread.environmentId, sourceThreadId); |
| 1586 | − }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); | |
| 1573 | + }, [activeLatestRun?.sourcePlanRef?.threadId, activeThread]); | |
| 1587 | 1574 | const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); |
| 1588 | 1575 | const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => { |
| 1589 | 1576 | if (!activeThread) { |
| @@ -1615,7 +1602,7 @@ function ChatViewContent(props: ChatViewProps) { · 25 unmodified lines | ||
| 1615 | 1602 | : nextThreadIds; |
| 1616 | 1603 | }); |
| 1617 | 1604 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); |
| 1618 | − const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | |
| 1605 | + const latestRunSettled = isLatestRunSettled(activeLatestRun, activeThread?.runtime ?? null); | |
| 1619 | 1606 | const activeProjectRef = activeThread |
| 1620 | 1607 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| 1621 | 1608 | : null; |
| @@ -2047,16 +2034,19 @@ function ChatViewContent(props: ChatViewProps) { · 425 unmodified lines | ||
| 2047 | 2034 | selectedProviderByThreadId ?? threadProvider, |
| 2048 | 2035 | ); |
| 2049 | 2036 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; |
| 2050 | − const phase = derivePhase(activeThread?.session ?? null); | |
| 2051 | − const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; | |
| 2052 | − const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); | |
| 2037 | + const phase = derivePhase(activeThread?.runtime ?? null); | |
| 2038 | + const threadWorkEntries = activeThread?.workEntries ?? EMPTY_WORK_ENTRIES; | |
| 2039 | + const workLogEntries = useMemo( | |
| 2040 | + () => deriveWorkLogEntries(threadWorkEntries), | |
| 2041 | + [threadWorkEntries], | |
| 2042 | + ); | |
| 2053 | 2043 | const pendingApprovals = useMemo( |
| 2054 | − () => derivePendingApprovals(threadActivities), | |
| 2055 | − [threadActivities], | |
| 2044 | + () => derivePendingApprovals(activeThread?.pendingApprovals ?? []), | |
| 2045 | + [activeThread?.pendingApprovals], | |
| 2056 | 2046 | ); |
| 2057 | 2047 | const pendingUserInputs = useMemo( |
| 2058 | − () => derivePendingUserInputs(threadActivities), | |
| 2059 | − [threadActivities], | |
| 2048 | + () => derivePendingUserInputs(activeThread?.pendingUserInputs ?? []), | |
| 2049 | + [activeThread?.pendingUserInputs], | |
| 2060 | 2050 | ); |
| 2061 | 2051 | const activePendingUserInput = pendingUserInputs[0] ?? null; |
| 2062 | 2052 | const activePendingDraftAnswers = useMemo( |
| @@ -2089,36 +2079,37 @@ function ChatViewContent(props: ChatViewProps) { · 26 unmodified lines | ||
| 2089 | 2079 | [activePendingDraftAnswers, activePendingUserInput], |
| 2090 | 2080 | ); |
| 2091 | 2081 | const activePendingIsResponding = activePendingUserInput |
| 2092 | − ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) | |
| 2082 | + ? activePendingUserInput.responseCapability !== "live" || | |
| 2083 | + respondingUserInputRequestIds.includes(activePendingUserInput.requestId) | |
| 2093 | 2084 | : false; |
| 2094 | 2085 | const activeProposedPlan = useMemo(() => { |
| 2095 | − if (!latestTurnSettled) { | |
| 2086 | + if (!latestRunSettled) { | |
| 2096 | 2087 | return null; |
| 2097 | 2088 | } |
| 2098 | 2089 | return findLatestProposedPlan( |
| 2099 | 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 | 2094 | const sidebarProposedPlan = useMemo( |
| 2104 | 2095 | () => |
| 2105 | 2096 | findSidebarProposedPlan({ |
| 2106 | 2097 | threads: threadPlanCatalog, |
| 2107 | − latestTurn: activeLatestTurn, | |
| 2108 | − latestTurnSettled, | |
| 2098 | + latestRun: activeLatestRun, | |
| 2099 | + latestRunSettled, | |
| 2109 | 2100 | threadId: activeThread?.id ?? null, |
| 2110 | 2101 | }), |
| 2111 | − [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], | |
| 2102 | + [activeLatestRun, activeThread?.id, latestRunSettled, threadPlanCatalog], | |
| 2112 | 2103 | ); |
| 2113 | 2104 | const activePlan = useMemo( |
| 2114 | − () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), | |
| 2115 | − [activeLatestTurn?.turnId, threadActivities], | |
| 2105 | + () => deriveActivePlanState(activeThread?.todoPlans ?? [], activeLatestRun?.runId ?? undefined), | |
| 2106 | + [activeLatestRun?.runId, activeThread?.todoPlans], | |
| 2116 | 2107 | ); |
| 2117 | 2108 | const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; |
| 2118 | 2109 | const showPlanFollowUpPrompt = |
| 2119 | 2110 | pendingUserInputs.length === 0 && |
| 2120 | 2111 | interactionMode === "plan" && |
| 2121 | − latestTurnSettled && | |
| 2112 | + latestRunSettled && | |
| 2122 | 2113 | hasActionableProposedPlan(activeProposedPlan); |
| 2123 | 2114 | const activePendingApproval = pendingApprovals[0] ?? null; |
| 2124 | 2115 | const { |
| @@ -2129,7 +2120,7 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines | ||
| 2129 | 2120 | isSendBusy, |
| 2130 | 2121 | } = useLocalDispatchState({ |
| 2131 | 2122 | activeThread, |
| 2132 | − activeLatestTurn, | |
| 2123 | + activeLatestRun, | |
| 2133 | 2124 | phase, |
| 2134 | 2125 | activePendingApproval: activePendingApproval?.requestId ?? null, |
| 2135 | 2126 | activePendingUserInput: activePendingUserInput?.requestId ?? null, |
| @@ -2137,8 +2128,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 2137 | 2128 | }); |
| 2138 | 2129 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; |
| 2139 | 2130 | const activeWorkStartedAt = deriveActiveWorkStartedAt( |
| 2140 | − activeLatestTurn, | |
| 2141 | − activeThread?.session ?? null, | |
| 2131 | + activeLatestRun, | |
| 2132 | + activeThread?.runtime ?? null, | |
| 2142 | 2133 | localDispatchStartedAt, |
| 2143 | 2134 | ); |
| 2144 | 2135 | useEffect(() => { |
| @@ -2202,7 +2193,17 @@ function ChatViewContent(props: ChatViewProps) { · 57 unmodified lines | ||
| 2202 | 2193 | }); |
| 2203 | 2194 | }, []); |
| 2204 | 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 | 2207 | const attachmentIds = new Set<string>(); |
| 2207 | 2208 | for (const message of serverMessages ?? []) { |
| 2208 | 2209 | for (const attachment of message.attachments ?? []) { |
| @@ -2211,6 +2212,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines | ||
| 2211 | 2212 | } |
| 2212 | 2213 | return [...attachmentIds]; |
| 2213 | 2214 | }, [serverMessages]); |
| 2215 | + const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : draftAttachmentIds; | |
| 2214 | 2216 | const serverAttachmentResources = useMemo( |
| 2215 | 2217 | () => |
| 2216 | 2218 | serverAttachmentIds.map((attachmentId) => ({ |
| @@ -2381,22 +2383,22 @@ function ChatViewContent(props: ChatViewProps) { · 164 unmodified lines | ||
| 2381 | 2383 | } |
| 2382 | 2384 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 2383 | 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 | 2397 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| 2387 | 2398 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], |
| 2388 | 2399 | ); |
| 2389 | − const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); | |
| 2390 | − const draftHeroDockRequested = | |
| 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 | + const timelineEntries = isServerThread ? serverTimelineEntries : draftTimelineEntries; | |
| 2401 | + const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } = | |
| 2400 | 2402 | useTurnDiffSummaries(activeThread); |
| 2401 | 2403 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 2402 | 2404 | const byMessageId = new Map<MessageId, TurnDiffSummary>(); |
| @@ -2427,7 +2429,7 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines | ||
| 2427 | 2429 | continue; |
| 2428 | 2430 | } |
| 2429 | 2431 | const turnCount = |
| 2430 | − summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 2432 | + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[summary.runId]; | |
| 2431 | 2433 | if (typeof turnCount !== "number") { |
| 2432 | 2434 | break; |
| 2433 | 2435 | } |
| @@ -2437,7 +2439,7 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | ||
| 2437 | 2439 | } |
| 2438 | 2440 | |
| 2439 | 2441 | return byUserMessageId; |
| 2440 | − }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); | |
| 2442 | + }, [inferredCheckpointTurnCountByRunId, timelineEntries, turnDiffSummaryByAssistantMessageId]); | |
| 2441 | 2443 | |
| 2442 | 2444 | const gitCwd = activeProject |
| 2443 | 2445 | ? projectScriptCwd({ |
| @@ -2465,7 +2467,7 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines | ||
| 2465 | 2467 | ?.instanceId ?? null; |
| 2466 | 2468 | const activeProviderInstanceId = |
| 2467 | 2469 | selectedProviderInstanceId ?? |
| 2468 | − activeThread?.session?.providerInstanceId ?? | |
| 2470 | + activeThread?.runtime?.providerInstanceId ?? | |
| 2469 | 2471 | activeThread?.modelSelection.instanceId ?? |
| 2470 | 2472 | activeProject?.defaultModelSelection?.instanceId ?? |
| 2471 | 2473 | null; |
| @@ -2544,9 +2546,7 @@ function ChatViewContent(props: ChatViewProps) { · 72 unmodified lines | ||
| 2544 | 2546 | }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); |
| 2545 | 2547 | |
| 2546 | 2548 | const envLocked = Boolean( |
| 2547 | − activeThread && | |
| 2548 | − (activeThread.messages.length > 0 || | |
| 2549 | − (activeThread.session !== null && activeThread.session.status !== "stopped")), | |
| 2549 | + activeThread && (activeThread.messages.length > 0 || activeThread.runtime !== null), | |
| 2550 | 2550 | ); |
| 2551 | 2551 | |
| 2552 | 2552 | // Handle environment change for draft threads. When the user picks a |
| @@ -3092,8 +3092,8 @@ function ChatViewContent(props: ChatViewProps) { · 539 unmodified lines | ||
| 3092 | 3092 | }, [handleInteractionModeChange, interactionMode]); |
| 3093 | 3093 | const dismissPlanSidebarForCurrentTurn = useCallback(() => { |
| 3094 | 3094 | planSidebarDismissedForTurnRef.current = |
| 3095 | − activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 3096 | − }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | |
| 3095 | + activePlan?.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__"; | |
| 3096 | + }, [activePlan?.runId, sidebarProposedPlan?.runId]); | |
| 3097 | 3097 | const togglePlanSidebar = useCallback(() => { |
| 3098 | 3098 | if (!activeThreadRef) return; |
| 3099 | 3099 | if (planSidebarOpen) { |
| @@ -3842,20 +3842,20 @@ function ChatViewContent(props: ChatViewProps) { · 742 unmodified lines | ||
| 3842 | 3842 | if (!autoOpenPlanSidebar) return; |
| 3843 | 3843 | if (!activePlan) return; |
| 3844 | 3844 | if (planSidebarOpen) return; |
| 3845 | − const latestTurnId = activeLatestTurn?.turnId ?? null; | |
| 3846 | − if (latestTurnId && activePlan.turnId !== latestTurnId) return; | |
| 3847 | − const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 3845 | + const latestRunId = activeLatestRun?.runId ?? null; | |
| 3846 | + if (latestRunId && activePlan.runId !== latestRunId) return; | |
| 3847 | + const turnKey = activePlan.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__"; | |
| 3848 | 3848 | if (planSidebarDismissedForTurnRef.current === turnKey) return; |
| 3849 | 3849 | if (activeThreadRef) { |
| 3850 | 3850 | useRightPanelStore.getState().open(activeThreadRef, "plan"); |
| 3851 | 3851 | } |
| 3852 | 3852 | }, [ |
| 3853 | 3853 | activePlan, |
| 3854 | − activeLatestTurn?.turnId, | |
| 3854 | + activeLatestRun?.runId, | |
| 3855 | 3855 | activeThreadRef, |
| 3856 | 3856 | autoOpenPlanSidebar, |
| 3857 | 3857 | planSidebarOpen, |
| 3858 | − sidebarProposedPlan?.turnId, | |
| 3858 | + sidebarProposedPlan?.runId, | |
| 3859 | 3859 | ]); |
| 3860 | 3860 | |
| 3861 | 3861 | useEffect(() => { |
| @@ -4753,7 +4753,7 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines | ||
| 4753 | 4753 | role: "user", |
| 4754 | 4754 | text: outgoingMessageText, |
| 4755 | 4755 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), |
| 4756 | − turnId: null, | |
| 4756 | + runId: null, | |
| 4757 | 4757 | createdAt: messageCreatedAt, |
| 4758 | 4758 | updatedAt: messageCreatedAt, |
| 4759 | 4759 | streaming: false, |
| @@ -4966,8 +4966,13 @@ function ChatViewContent(props: ChatViewProps) { · 206 unmodified lines | ||
| 4966 | 4966 | }; |
| 4967 | 4967 | |
| 4968 | 4968 | const onRespondToApproval = useCallback( |
| 4969 | − async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | |
| 4969 | + async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { | |
| 4970 | 4970 | if (!activeThreadId) return; |
| 4971 | + if ( | |
| 4972 | + pendingApprovals.find((approval) => approval.requestId === requestId) | |
| 4973 | + ?.responseCapability !== "live" | |
| 4974 | + ) | |
| 4975 | + return; | |
| 4971 | 4976 | |
| 4972 | 4977 | setRespondingRequestIds((existing) => |
| 4973 | 4978 | existing.includes(requestId) ? existing : [...existing, requestId], |
| @@ -4990,12 +4995,17 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines | ||
| 4990 | 4995 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 4991 | 4996 | return result; |
| 4992 | 4997 | }, |
| 4993 | − [activeThreadId, environmentId, respondToThreadApproval, setThreadError], | |
| 4998 | + [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError], | |
| 4994 | 4999 | ); |
| 4995 | 5000 | |
| 4996 | 5001 | const onRespondToUserInput = useCallback( |
| 4997 | − async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { | |
| 5002 | + async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => { | |
| 4998 | 5003 | if (!activeThreadId) return; |
| 5004 | + if ( | |
| 5005 | + pendingUserInputs.find((input) => input.requestId === requestId)?.responseCapability !== | |
| 5006 | + "live" | |
| 5007 | + ) | |
| 5008 | + return; | |
| 4999 | 5009 | |
| 5000 | 5010 | setRespondingUserInputRequestIds((existing) => |
| 5001 | 5011 | existing.includes(requestId) ? existing : [...existing, requestId], |
| @@ -5018,7 +5028,7 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines | ||
| 5018 | 5028 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 5019 | 5029 | return result; |
| 5020 | 5030 | }, |
| 5021 | − [activeThreadId, environmentId, respondToThreadUserInput, setThreadError], | |
| 5031 | + [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError], | |
| 5022 | 5032 | ); |
| 5023 | 5033 | |
| 5024 | 5034 | const setActivePendingUserInputQuestionIndex = useCallback( |
| @@ -5102,7 +5112,11 @@ function ChatViewContent(props: ChatViewProps) { · 77 unmodified lines | ||
| 5102 | 5112 | ); |
| 5103 | 5113 | |
| 5104 | 5114 | const onAdvanceActivePendingUserInput = useCallback(() => { |
| 5105 | − if (!activePendingUserInput || !activePendingProgress) { | |
| 5115 | + if ( | |
| 5116 | + !activePendingUserInput || | |
| 5117 | + activePendingUserInput.responseCapability !== "live" || | |
| 5118 | + !activePendingProgress | |
| 5119 | + ) { | |
| 5106 | 5120 | return; |
| 5107 | 5121 | } |
| 5108 | 5122 | if (activePendingProgress.isLastQuestion) { |
| @@ -5196,7 +5210,7 @@ function ChatViewContent(props: ChatViewProps) { · 87 unmodified lines | ||
| 5196 | 5210 | id: messageIdForSend, |
| 5197 | 5211 | role: "user", |
| 5198 | 5212 | text: outgoingMessageText, |
| 5199 | − turnId: null, | |
| 5213 | + runId: null, | |
| 5200 | 5214 | createdAt: messageCreatedAt, |
| 5201 | 5215 | updatedAt: messageCreatedAt, |
| 5202 | 5216 | streaming: false, |
| @@ -5466,9 +5480,9 @@ function ChatViewContent(props: ChatViewProps) { · 263 unmodified lines | ||
| 5466 | 5480 | } |
| 5467 | 5481 | const reason = getStartedThreadModelChangeBlockReason({ |
| 5468 | 5482 | providers: providerStatuses, |
| 5469 | − hasStartedSession: activeThread.session !== null, | |
| 5483 | + hasStartedSession: activeThread.runtime !== null, | |
| 5470 | 5484 | currentModelSelection: activeThread.modelSelection, |
| 5471 | − currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, | |
| 5485 | + currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null, | |
| 5472 | 5486 | nextModelSelection: { instanceId, model }, |
| 5473 | 5487 | }); |
| 5474 | 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 | 5506 | scheduleComposerFocus(); |
| 5493 | 5507 | return; |
| 5494 | 5508 | } |
| 5495 | − if (lockedProvider !== null && activeThread.session?.providerInstanceId) { | |
| 5509 | + if (lockedProvider !== null && activeThread.runtime?.providerInstanceId) { | |
| 5496 | 5510 | const currentEntry = providerStatuses.find( |
| 5497 | − (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, | |
| 5511 | + (snapshot) => snapshot.instanceId === activeThread.runtime?.providerInstanceId, | |
| 5498 | 5512 | ); |
| 5499 | 5513 | if ( |
| 5500 | 5514 | currentEntry?.continuation?.groupKey && |
| @@ -5521,9 +5535,9 @@ function ChatViewContent(props: ChatViewProps) { · 20 unmodified lines | ||
| 5521 | 5535 | }; |
| 5522 | 5536 | const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ |
| 5523 | 5537 | providers: providerStatuses, |
| 5524 | − hasStartedSession: activeThread.session !== null, | |
| 5538 | + hasStartedSession: activeThread.runtime !== null, | |
| 5525 | 5539 | currentModelSelection: activeThread.modelSelection, |
| 5526 | − currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, | |
| 5540 | + currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null, | |
| 5527 | 5541 | nextModelSelection, |
| 5528 | 5542 | }); |
| 5529 | 5543 | if (modelChangeBlockReason) { |
| @@ -5603,9 +5617,9 @@ function ChatViewContent(props: ChatViewProps) { · 73 unmodified lines | ||
| 5603 | 5617 | setExpandedImage(preview); |
| 5604 | 5618 | }, []); |
| 5605 | 5619 | const onOpenTurnDiff = useCallback( |
| 5606 | − (turnId: TurnId, filePath?: string) => { | |
| 5620 | + (runId: RunId, filePath?: string) => { | |
| 5607 | 5621 | if (!isServerThread || !activeThreadRef) return; |
| 5608 | − useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); | |
| 5622 | + useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); | |
| 5609 | 5623 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 5610 | 5624 | onDiffPanelOpen?.(); |
| 5611 | 5625 | }, |
| @@ -5800,16 +5814,11 @@ function ChatViewContent(props: ChatViewProps) { · 188 unmodified lines | ||
| 5800 | 5814 | <MessagesTimeline |
| 5801 | 5815 | key={activeThread.id} |
| 5802 | 5816 | isWorking={isWorking} |
| 5803 | − activeTurnInProgress={isWorking || !latestTurnSettled} | |
| 5817 | + activeTurnInProgress={isWorking || !latestRunSettled} | |
| 5804 | 5818 | activeTurnStartedAt={activeWorkStartedAt} |
| 5805 | 5819 | listRef={legendListRef} |
| 5806 | 5820 | timelineEntries={timelineEntries} |
| 5807 | − latestTurn={activeLatestTurn} | |
| 5808 | − runningTurnId={ | |
| 5809 | − activeThread.session?.status === "running" | |
| 5810 | − ? activeThread.session.activeTurnId | |
| 5811 | − : null | |
| 5812 | − } | |
| 5821 | + latestRun={activeLatestRun} | |
| 5813 | 5822 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 5814 | 5823 | activeThreadEnvironmentId={activeThread.environmentId} |
| 5815 | 5824 | routeThreadKey={routeThreadKey} |
| @@ -5867,168 +5876,86 @@ function ChatViewContent(props: ChatViewProps) { · 51 unmodified lines | ||
| 5867 | 5876 | ref={attachDraftHeroTransitionGroupRef} |
| 5868 | 5877 | className="chat-composer-horizontal-inset w-full" |
| 5869 | 5878 | > |
| 5870 | − <div className="pointer-events-auto relative z-10"> | |
| 5871 | − {isDraftHeroState ? ( | |
| 5872 | − <div className="absolute inset-x-0 bottom-full z-0"> | |
| 5873 | − <div | |
| 5874 | − className="pb-8" | |
| 5875 | − style={ | |
| 5876 | − forceExpandedMobileComposer | |
| 5877 | − ? { | |
| 5878 | − viewTransitionName: MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, | |
| 5879 | − } | |
| 5880 | − : undefined | |
| 5881 | − } | |
| 5882 | − > | |
| 5883 | − <DraftHeroHeadline | |
| 5884 | − activeProjectRef={activeProjectRef} | |
| 5885 | − activeProjectTitle={activeProject?.title ?? null} | |
| 5886 | − /> | |
| 5887 | − </div> | |
| 5888 | − <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> | |
| 5889 | − </div> | |
| 5890 | − ) : ( | |
| 5891 | − <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> | |
| 5892 | − )} | |
| 5893 | − {threadSyncPhase && !activeEnvironmentUnavailable ? ( | |
| 5894 | − <ThreadSyncStatusPill phase={threadSyncPhase} /> | |
| 5895 | − ) : null} | |
| 5896 | − <div | |
| 5897 | − className="relative" | |
| 5898 | − style={ | |
| 5899 | − forceExpandedMobileComposer | |
| 5900 | − ? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME } | |
| 5901 | − : undefined | |
| 5902 | − } | |
| 5903 | − > | |
| 5904 | − <div | |
| 5905 | − className={cn( | |
| 5906 | − "chat-composer-glass-shell relative mx-auto w-full max-w-3xl", | |
| 5907 | − showComposerContextStrip && "chat-composer-glass-shell-with-context", | |
| 5908 | − )} | |
| 5909 | − > | |
| 5910 | − <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]"> | |
| 5911 | − <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10"> | |
| 5912 | − <ChatComposer | |
| 5913 | − composerRef={composerRef} | |
| 5914 | − composerDraftTarget={composerDraftTarget} | |
| 5915 | − environmentId={environmentId} | |
| 5916 | − routeKind={routeKind} | |
| 5917 | − routeThreadRef={routeThreadRef} | |
| 5918 | − draftId={draftId} | |
| 5919 | − activeThreadId={activeThreadId} | |
| 5920 | − activeThreadEnvironmentId={activeThread?.environmentId} | |
| 5921 | − activeThread={activeThread} | |
| 5922 | − isServerThread={isServerThread} | |
| 5923 | − isLocalDraftThread={isLocalDraftThread} | |
| 5924 | − forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} | |
| 5925 | − projectSelectionRequired={isLocalDraftThread && activeProject === null} | |
| 5926 | − phase={phase} | |
| 5927 | − isConnecting={isConnecting} | |
| 5928 | − isSendBusy={isSendBusy} | |
| 5929 | − sendDisabledReason={threadDetailLoading ? "Messages loading" : null} | |
| 5930 | − isPreparingWorktree={isPreparingWorktree} | |
| 5931 | − environmentUnavailable={activeEnvironmentUnavailableState} | |
| 5932 | − activePendingApproval={activePendingApproval} | |
| 5933 | − pendingApprovals={pendingApprovals} | |
| 5934 | − pendingUserInputs={pendingUserInputs} | |
| 5935 | − activePendingProgress={activePendingProgress} | |
| 5936 | − activePendingResolvedAnswers={activePendingResolvedAnswers} | |
| 5937 | − activePendingIsResponding={activePendingIsResponding} | |
| 5938 | − activePendingDraftAnswers={activePendingDraftAnswers} | |
| 5939 | − activePendingQuestionIndex={activePendingQuestionIndex} | |
| 5940 | − respondingRequestIds={respondingRequestIds} | |
| 5941 | − showPlanFollowUpPrompt={showPlanFollowUpPrompt} | |
| 5942 | − activeProposedPlan={activeProposedPlan} | |
| 5943 | − activePlan={activePlan as { turnId?: TurnId } | null} | |
| 5944 | − sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | |
| 5945 | − planSidebarLabel={planSidebarLabel} | |
| 5946 | − planSidebarOpen={planSidebarOpen} | |
| 5947 | − runtimeMode={runtimeMode} | |
| 5948 | − interactionMode={interactionMode} | |
| 5949 | − lockedProvider={lockedProvider} | |
| 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)]" | |
| 5879 | + <div className="relative mx-auto h-full w-full max-w-208 overflow-clip rounded-t-[20px]"> | |
| 5880 | + <div className="chat-composer-shared-blur absolute -inset-8" /> | |
| 5881 | + </div> | |
| 5882 | + </div> | |
| 5883 | + <div className="chat-composer-horizontal-inset"> | |
| 5884 | + <div className="pointer-events-auto relative z-10 isolate"> | |
| 5885 | + <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> | |
| 5886 | + <div className="relative z-10"> | |
| 5887 | + <ChatComposer | |
| 5888 | + composerRef={composerRef} | |
| 5889 | + composerDraftTarget={composerDraftTarget} | |
| 5890 | + environmentId={environmentId} | |
| 5891 | + routeKind={routeKind} | |
| 5892 | + routeThreadRef={routeThreadRef} | |
| 5893 | + draftId={draftId} | |
| 5894 | + activeThreadId={activeThreadId} | |
| 5895 | + activeThreadEnvironmentId={activeThread?.environmentId} | |
| 5896 | + activeThread={activeThread} | |
| 5897 | + isServerThread={isServerThread} | |
| 5898 | + isLocalDraftThread={isLocalDraftThread} | |
| 5899 | + phase={phase} | |
| 5900 | + isConnecting={isConnecting} | |
| 5901 | + isSendBusy={isSendBusy} | |
| 5902 | + isPreparingWorktree={isPreparingWorktree} | |
| 5903 | + environmentUnavailable={activeEnvironmentUnavailableState} | |
| 5904 | + activePendingApproval={activePendingApproval} | |
| 5905 | + pendingApprovals={pendingApprovals} | |
| 5906 | + pendingUserInputs={pendingUserInputs} | |
| 5907 | + activePendingProgress={activePendingProgress} | |
| 5908 | + activePendingResolvedAnswers={activePendingResolvedAnswers} | |
| 5909 | + activePendingIsResponding={activePendingIsResponding} | |
| 5910 | + activePendingDraftAnswers={activePendingDraftAnswers} | |
| 5911 | + activePendingQuestionIndex={activePendingQuestionIndex} | |
| 5912 | + respondingRequestIds={respondingRequestIds} | |
| 5913 | + showPlanFollowUpPrompt={showPlanFollowUpPrompt} | |
| 5914 | + activeProposedPlan={activeProposedPlan} | |
| 5915 | + activePlan={activePlan} | |
| 5916 | + sidebarProposedPlan={sidebarProposedPlan} | |
| 5917 | + planSidebarLabel={planSidebarLabel} | |
| 5918 | + planSidebarOpen={planSidebarOpen} | |
| 5919 | + runtimeMode={runtimeMode} | |
| 5920 | + interactionMode={interactionMode} | |
| 5921 | + lockedProvider={lockedProvider} | |
| 5922 | + providerStatuses={providerStatuses as ServerProvider[]} | |
| 5923 | + activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} | |
| 5924 | + activeThreadModelSelection={activeThread?.modelSelection} | |
| 5925 | + activeThreadWorkEntries={activeThread?.workEntries} | |
| 5926 | + resolvedTheme={resolvedTheme} | |
| 5927 | + settings={settings} | |
| 5928 | + keybindings={keybindings} | |
| 5929 | + terminalOpen={Boolean(terminalUiState.terminalOpen)} | |
| 5930 | + gitCwd={gitCwd} | |
| 5931 | + promptRef={promptRef} | |
| 5932 | + composerImagesRef={composerImagesRef} | |
| 5933 | + composerTerminalContextsRef={composerTerminalContextsRef} | |
| 5934 | + composerElementContextsRef={composerElementContextsRef} | |
| 5935 | + shouldAutoScrollRef={isAtEndRef} | |
| 5936 | + scheduleStickToBottom={scrollToEnd} | |
| 5937 | + onSend={onSend} | |
| 5938 | + onInterrupt={onInterrupt} | |
| 5939 | + onImplementPlanInNewThread={onImplementPlanInNewThread} | |
| 5940 | + onRespondToApproval={onRespondToApproval} | |
| 5941 | + onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} | |
| 5942 | + onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} | |
| 5943 | + onPreviousActivePendingUserInputQuestion={ | |
| 5944 | + onPreviousActivePendingUserInputQuestion | |
| 5945 | + } | |
| 5946 | + onChangeActivePendingUserInputCustomAnswer={ | |
| 5947 | + onChangeActivePendingUserInputCustomAnswer | |
| 5948 | + } | |
| 5949 | + onProviderModelSelect={onProviderModelSelect} | |
| 5950 | + getModelDisabledReason={getModelDisabledReason} | |
| 5951 | + toggleInteractionMode={toggleInteractionMode} | |
| 5952 | + handleRuntimeModeChange={handleRuntimeModeChange} | |
| 5953 | + handleInteractionModeChange={handleInteractionModeChange} | |
| 5954 | + togglePlanSidebar={togglePlanSidebar} | |
| 5955 | + focusComposer={focusComposer} | |
| 5956 | + scheduleComposerFocus={scheduleComposerFocus} | |
| 5957 | + setThreadError={setThreadError} | |
| 5958 | + onExpandImage={onExpandTimelineImage} | |
| 6032 | 5959 | /> |
| 6033 | 5960 | </div> |
| 6034 | 5961 | </div> |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 3 | 3 | import type { Thread } from "../types"; |
| 4 | +import { makeThreadFixture } from "../test-fixtures"; | |
| 4 | 5 | import { |
| 5 | 6 | buildBrowseGroups, |
| 6 | 7 | buildThreadActionItems, |
| @@ -112,7 +113,7 @@ const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); · 105 unmodified lines | ||
| 112 | 113 | const PROJECT_ID = ProjectId.make("project-1"); |
| 113 | 114 | |
| 114 | 115 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 115 | − return { | |
| 116 | + return makeThreadFixture({ | |
| 116 | 117 | id: ThreadId.make("thread-1"), |
| 117 | 118 | environmentId: LOCAL_ENVIRONMENT_ID, |
| 118 | 119 | projectId: PROJECT_ID, |
| @@ -120,7 +121,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line | ||
| 120 | 121 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 121 | 122 | runtimeMode: "full-access", |
| 122 | 123 | interactionMode: "default", |
| 123 | − session: null, | |
| 124 | + runtime: null, | |
| 124 | 125 | messages: [], |
| 125 | 126 | proposedPlans: [], |
| 126 | 127 | createdAt: "2026-03-01T00:00:00.000Z", |
| @@ -129,13 +130,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines | ||
| 129 | 130 | settledAt: null, |
| 130 | 131 | deletedAt: null, |
| 131 | 132 | updatedAt: "2026-03-01T00:00:00.000Z", |
| 132 | − latestTurn: null, | |
| 133 | + latestRun: null, | |
| 133 | 134 | branch: null, |
| 134 | 135 | worktreePath: null, |
| 135 | − checkpoints: [], | |
| 136 | − activities: [], | |
| 137 | 136 | ...overrides, |
| 138 | − }; | |
| 137 | + }); | |
| 139 | 138 | } |
| 140 | 139 | |
| 141 | 140 | describe("buildThreadActionItems", () => { |
| @@ -5,7 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | squashAtomCommandFailure, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | 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 | 9 | import { |
| 10 | 10 | ArrowRightIcon, |
| 11 | 11 | CheckIcon, |
| @@ -246,55 +246,55 @@ export default function DiffPanel({ · 234 unmodified lines | ||
| 246 | 246 | ), |
| 247 | 247 | ); |
| 248 | 248 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 249 | − const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | |
| 249 | + const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } = | |
| 250 | 250 | useTurnDiffSummaries(activeThread); |
| 251 | 251 | const orderedTurnDiffSummaries = useMemo( |
| 252 | 252 | () => |
| 253 | 253 | [...turnDiffSummaries].toSorted((left, right) => { |
| 254 | 254 | const leftTurnCount = |
| 255 | − left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; | |
| 255 | + left.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[left.runId] ?? 0; | |
| 256 | 256 | const rightTurnCount = |
| 257 | − right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; | |
| 257 | + right.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[right.runId] ?? 0; | |
| 258 | 258 | if (leftTurnCount !== rightTurnCount) { |
| 259 | 259 | return rightTurnCount - leftTurnCount; |
| 260 | 260 | } |
| 261 | 261 | return right.completedAt.localeCompare(left.completedAt); |
| 262 | 262 | }), |
| 263 | − [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], | |
| 263 | + [inferredCheckpointTurnCountByRunId, turnDiffSummaries], | |
| 264 | 264 | ); |
| 265 | 265 | |
| 266 | 266 | useEffect(() => { |
| 267 | 267 | if (!routeThreadRef || diffSelection.kind !== "turn") return; |
| 268 | 268 | useDiffPanelStore.getState().reconcileTurnSelection( |
| 269 | 269 | routeThreadRef, |
| 270 | − orderedTurnDiffSummaries.map((summary) => summary.turnId), | |
| 270 | + orderedTurnDiffSummaries.map((summary) => summary.runId), | |
| 271 | 271 | ); |
| 272 | 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 | 275 | const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; |
| 276 | 276 | const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; |
| 277 | 277 | const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; |
| 278 | 278 | const selectedFileRevealRequestId = |
| 279 | 279 | diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; |
| 280 | 280 | const selectedTurn = |
| 281 | − selectedTurnId === null | |
| 281 | + selectedRunId === null | |
| 282 | 282 | ? undefined |
| 283 | − : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | |
| 283 | + : (orderedTurnDiffSummaries.find((summary) => summary.runId === selectedRunId) ?? | |
| 284 | 284 | orderedTurnDiffSummaries[0]); |
| 285 | 285 | const selectedCheckpointTurnCount = |
| 286 | 286 | selectedTurn && |
| 287 | − (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); | |
| 287 | + (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[selectedTurn.runId]); | |
| 288 | 288 | const latestTurn = orderedTurnDiffSummaries[0]; |
| 289 | 289 | const selectedScopeLabel = |
| 290 | − selectedTurnId === null | |
| 290 | + selectedRunId === null | |
| 291 | 291 | ? selectedGitScope === "unstaged" |
| 292 | 292 | ? "Working tree" |
| 293 | 293 | : "Branch changes" |
| 294 | − : selectedTurn?.turnId === latestTurn?.turnId | |
| 294 | + : selectedTurn?.runId === latestTurn?.runId | |
| 295 | 295 | ? "Latest turn" |
| 296 | 296 | : `Turn ${selectedCheckpointTurnCount ?? "?"}`; |
| 297 | − const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope; | |
| 297 | + const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope; | |
| 298 | 298 | const collapseScopeKey = routeThreadRef |
| 299 | 299 | ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` |
| 300 | 300 | : null; |
| @@ -324,12 +324,12 @@ export default function DiffPanel({ · 23 unmodified lines | ||
| 324 | 324 | fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, |
| 325 | 325 | toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, |
| 326 | 326 | ignoreWhitespace: diffIgnoreWhitespace, |
| 327 | − cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null, | |
| 327 | + cacheScope: selectedTurn ? `turn:${selectedTurn.runId}` : null, | |
| 328 | 328 | }, |
| 329 | 329 | { enabled: isGitRepo && selectedTurn !== undefined }, |
| 330 | 330 | ); |
| 331 | 331 | const primaryBranchDiffPreview = useEnvironmentQuery( |
| 332 | − selectedTurnId === null && activeThread && activeCwd | |
| 332 | + selectedRunId === null && activeThread && activeCwd | |
| 333 | 333 | ? reviewEnvironment.diffPreview({ |
| 334 | 334 | environmentId: activeThread.environmentId, |
| 335 | 335 | input: { |
| @@ -341,7 +341,7 @@ export default function DiffPanel({ · 5 unmodified lines | ||
| 341 | 341 | : null, |
| 342 | 342 | ); |
| 343 | 343 | const shouldRetryBranchDiffAtEnvironmentCwd = |
| 344 | − selectedTurnId === null && | |
| 344 | + selectedRunId === null && | |
| 345 | 345 | primaryBranchDiffPreview.error?.includes("configured workspace root") === true && |
| 346 | 346 | serverConfig?.cwd !== undefined && |
| 347 | 347 | serverConfig.cwd !== activeCwd; |
| @@ -364,7 +364,7 @@ export default function DiffPanel({ · 16 unmodified lines | ||
| 364 | 364 | (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), |
| 365 | 365 | ); |
| 366 | 366 | const localBranchRefs = useEnvironmentQuery( |
| 367 | − selectedTurnId === null && | |
| 367 | + selectedRunId === null && | |
| 368 | 368 | selectedGitScope === "branch" && |
| 369 | 369 | activeThread && |
| 370 | 370 | branchDiffPreview.data?.cwd |
| @@ -381,7 +381,7 @@ export default function DiffPanel({ · 10 unmodified lines | ||
| 381 | 381 | : null, |
| 382 | 382 | ); |
| 383 | 383 | const remoteBranchRefs = useEnvironmentQuery( |
| 384 | − selectedTurnId === null && | |
| 384 | + selectedRunId === null && | |
| 385 | 385 | selectedGitScope === "branch" && |
| 386 | 386 | activeThread && |
| 387 | 387 | branchDiffPreview.data?.cwd |
| @@ -424,9 +424,9 @@ export default function DiffPanel({ · 36 unmodified lines | ||
| 424 | 424 | const renderablePatch = useMemo( |
| 425 | 425 | () => |
| 426 | 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 | 431 | const renderableFiles = useMemo(() => { |
| 432 | 432 | if (!renderablePatch || renderablePatch.kind !== "files") { |
| @@ -505,21 +505,9 @@ export default function DiffPanel({ · 72 unmodified lines | ||
| 505 | 505 | [collapseScopeKey], |
| 506 | 506 | ); |
| 507 | 507 | |
| 508 | − const toggleDiffFileCollapse = useCallback(() => { | |
| 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) => { | |
| 508 | + const selectTurn = (runId: RunId) => { | |
| 521 | 509 | if (!routeThreadRef) return; |
| 522 | − useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); | |
| 510 | + useDiffPanelStore.getState().selectTurn(routeThreadRef, runId); | |
| 523 | 511 | }; |
| 524 | 512 | const selectGitScope = (scope: "branch" | "unstaged") => { |
| 525 | 513 | if (!routeThreadRef) return; |
| @@ -551,6 +539,15 @@ export default function DiffPanel({ · 25 unmodified lines | ||
| 551 | 539 | onClick={() => selectGitScope("unstaged")} |
| 552 | 540 | > |
| 553 | 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 | 551 | </DropdownMenuItem> |
| 555 | 552 | <DropdownMenuItem |
| 556 | 553 | className={ |
| @@ -569,10 +566,13 @@ export default function DiffPanel({ · 12 unmodified lines | ||
| 569 | 566 | : undefined |
| 570 | 567 | } |
| 571 | 568 | onClick={() => { |
| 572 | − if (latestTurn) selectTurn(latestTurn.turnId); | |
| 569 | + if (latestTurn) selectTurn(latestTurn.runId); | |
| 573 | 570 | }} |
| 574 | 571 | > |
| 575 | 572 | <span>Latest turn</span> |
| 573 | + {selectedRunId !== null && selectedTurn?.runId === latestTurn?.runId && ( | |
| 574 | + <CheckIcon className="ml-auto" /> | |
| 575 | + )} | |
| 576 | 576 | </DropdownMenuItem> |
| 577 | 577 | <DropdownMenuSub> |
| 578 | 578 | <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger> |
| @@ -580,20 +580,15 @@ export default function DiffPanel({ · 1 unmodified line | ||
| 580 | 580 | {orderedTurnDiffSummaries.map((summary) => { |
| 581 | 581 | const turnCount = |
| 582 | 582 | summary.checkpointTurnCount ?? |
| 583 | − inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | |
| 583 | + inferredCheckpointTurnCountByRunId[summary.runId] ?? | |
| 584 | 584 | "?"; |
| 585 | 585 | return ( |
| 586 | − <DropdownMenuItem | |
| 587 | − key={summary.turnId} | |
| 588 | − className={ | |
| 589 | − summary.turnId === selectedTurn?.turnId ? "bg-foreground/[0.08]" : undefined | |
| 590 | − } | |
| 591 | − onClick={() => selectTurn(summary.turnId)} | |
| 592 | − > | |
| 586 | + <DropdownMenuItem key={summary.runId} onClick={() => selectTurn(summary.runId)}> | |
| 593 | 587 | <span>Turn {turnCount}</span> |
| 594 | 588 | <span className="ml-auto text-xs tabular-nums text-muted-foreground"> |
| 595 | 589 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} |
| 596 | 590 | </span> |
| 591 | + {summary.runId === selectedTurn?.runId && <CheckIcon className="ml-1" />} | |
| 597 | 592 | </DropdownMenuItem> |
| 598 | 593 | ); |
| 599 | 594 | })} |
| @@ -601,7 +596,7 @@ export default function DiffPanel({ · 1 unmodified line | ||
| 601 | 596 | </DropdownMenuSub> |
| 602 | 597 | </DropdownMenuContent> |
| 603 | 598 | </DropdownMenu> |
| 604 | − {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( | |
| 599 | + {selectedRunId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( | |
| 605 | 600 | <div |
| 606 | 601 | className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground" |
| 607 | 602 | title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} |
| @@ -823,7 +818,7 @@ export default function DiffPanel({ · 215 unmodified lines | ||
| 823 | 818 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 824 | 819 | Turn diffs are unavailable because this project is not a git repository. |
| 825 | 820 | </div> |
| 826 | − ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? ( | |
| 821 | + ) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? ( | |
| 827 | 822 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 828 | 823 | No completed turns yet. |
| 829 | 824 | </div> |
| @@ -31,20 +31,14 @@ import { · 30 unmodified lines | ||
| 31 | 31 | sortScopedProjectsForSidebar, |
| 32 | 32 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 33 | 33 | } from "./Sidebar.logic"; |
| 34 | −import { | |
| 35 | − EnvironmentId, | |
| 36 | − OrchestrationLatestTurn, | |
| 37 | − ProjectId, | |
| 38 | − ProviderInstanceId, | |
| 39 | − ThreadId, | |
| 40 | −} from "@t3tools/contracts"; | |
| 41 | − | |
| 34 | +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 42 | 35 | import { |
| 43 | 36 | DEFAULT_INTERACTION_MODE, |
| 44 | 37 | DEFAULT_RUNTIME_MODE, |
| 45 | 38 | type Project, |
| 46 | 39 | type Thread, |
| 47 | 40 | } from "../types"; |
| 41 | +import { makeThreadFixture } from "../test-fixtures"; | |
| 48 | 42 | |
| 49 | 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 | 237 | completedAt?: string | null; |
| 244 | 238 | startedAt?: string | null; |
| 245 | −}): OrchestrationLatestTurn { | |
| 239 | +}): NonNullable<Thread["latestRun"]> { | |
| 246 | 240 | return { |
| 247 | − turnId: "turn-1" as never, | |
| 248 | − state: "completed", | |
| 241 | + runId: "turn-1" as never, | |
| 242 | + status: "completed", | |
| 249 | 243 | assistantMessageId: null, |
| 250 | 244 | requestedAt: "2026-03-09T10:00:00.000Z", |
| 251 | 245 | startedAt: |
| @@ -263,9 +257,9 @@ describe("hasUnseenCompletion", () => { · 11 unmodified lines | ||
| 263 | 257 | hasPendingApprovals: false, |
| 264 | 258 | hasPendingUserInput: false, |
| 265 | 259 | interactionMode: "default", |
| 266 | − latestTurn: makeLatestTurn(), | |
| 260 | + latestRun: makeLatestRun(), | |
| 267 | 261 | lastVisitedAt: "2026-03-09T10:04:00.000Z", |
| 268 | − session: null, | |
| 262 | + runtime: null, | |
| 269 | 263 | }), |
| 270 | 264 | ).toBe(true); |
| 271 | 265 | }); |
| @@ -277,9 +271,9 @@ describe("hasUnseenCompletion", () => { · 5 unmodified lines | ||
| 277 | 271 | hasPendingApprovals: false, |
| 278 | 272 | hasPendingUserInput: false, |
| 279 | 273 | interactionMode: "default", |
| 280 | − latestTurn: makeLatestTurn(), | |
| 274 | + latestRun: makeLatestRun(), | |
| 281 | 275 | lastVisitedAt: undefined, |
| 282 | − session: null, | |
| 276 | + runtime: null, | |
| 283 | 277 | }), |
| 284 | 278 | ).toBe(false); |
| 285 | 279 | }); |
| @@ -879,15 +873,13 @@ describe("resolveThreadStatusPill", () => { · 593 unmodified lines | ||
| 879 | 873 | hasPendingApprovals: false, |
| 880 | 874 | hasPendingUserInput: false, |
| 881 | 875 | interactionMode: "plan" as const, |
| 882 | − latestTurn: null, | |
| 876 | + latestRun: null, | |
| 883 | 877 | lastVisitedAt: undefined, |
| 884 | − session: { | |
| 885 | − threadId: ThreadId.make("thread-1"), | |
| 878 | + runtime: { | |
| 886 | 879 | status: "running" as const, |
| 887 | 880 | providerName: "Codex", |
| 888 | 881 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 889 | − runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 890 | − activeTurnId: "turn-1" as never, | |
| 882 | + activeRunId: "turn-1" as never, | |
| 891 | 883 | lastError: null, |
| 892 | 884 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 893 | 885 | }, |
| @@ -930,11 +922,11 @@ describe("resolveThreadStatusPill", () => { · 36 unmodified lines | ||
| 930 | 922 | thread: { |
| 931 | 923 | ...baseThread, |
| 932 | 924 | hasActionableProposedPlan: true, |
| 933 | − latestTurn: makeLatestTurn(), | |
| 934 | − session: { | |
| 935 | − ...baseThread.session, | |
| 936 | − status: "ready", | |
| 937 | − activeTurnId: null, | |
| 925 | + latestRun: makeLatestRun(), | |
| 926 | + runtime: { | |
| 927 | + ...baseThread.runtime, | |
| 928 | + status: "completed", | |
| 929 | + activeRunId: null, | |
| 938 | 930 | }, |
| 939 | 931 | }, |
| 940 | 932 | }), |
| @@ -946,11 +938,11 @@ describe("resolveThreadStatusPill", () => { · 5 unmodified lines | ||
| 946 | 938 | resolveThreadStatusPill({ |
| 947 | 939 | thread: { |
| 948 | 940 | ...baseThread, |
| 949 | − latestTurn: makeLatestTurn(), | |
| 950 | − session: { | |
| 951 | − ...baseThread.session, | |
| 952 | − status: "ready", | |
| 953 | − activeTurnId: null, | |
| 941 | + latestRun: makeLatestRun(), | |
| 942 | + runtime: { | |
| 943 | + ...baseThread.runtime, | |
| 944 | + status: "completed", | |
| 945 | + activeRunId: null, | |
| 954 | 946 | }, |
| 955 | 947 | }, |
| 956 | 948 | }), |
| @@ -963,12 +955,12 @@ describe("resolveThreadStatusPill", () => { · 6 unmodified lines | ||
| 963 | 955 | thread: { |
| 964 | 956 | ...baseThread, |
| 965 | 957 | interactionMode: "default", |
| 966 | − latestTurn: makeLatestTurn(), | |
| 958 | + latestRun: makeLatestRun(), | |
| 967 | 959 | lastVisitedAt: "2026-03-09T10:04:00.000Z", |
| 968 | − session: { | |
| 969 | − ...baseThread.session, | |
| 970 | − status: "ready", | |
| 971 | − activeTurnId: null, | |
| 960 | + runtime: { | |
| 961 | + ...baseThread.runtime, | |
| 962 | + status: "completed", | |
| 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 | 1114 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 1123 | − return { | |
| 1115 | + return makeThreadFixture({ | |
| 1124 | 1116 | id: ThreadId.make("thread-1"), |
| 1125 | 1117 | environmentId: localEnvironmentId, |
| 1126 | 1118 | projectId: ProjectId.make("project-1"), |
| @@ -1132,7 +1124,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 5 unmodified lines | ||
| 1132 | 1124 | }, |
| 1133 | 1125 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 1134 | 1126 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 1135 | − session: null, | |
| 1127 | + runtime: null, | |
| 1136 | 1128 | messages: [], |
| 1137 | 1129 | proposedPlans: [], |
| 1138 | 1130 | createdAt: "2026-03-09T10:00:00.000Z", |
| @@ -1141,13 +1133,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines | ||
| 1141 | 1133 | settledAt: null, |
| 1142 | 1134 | deletedAt: null, |
| 1143 | 1135 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 1144 | − latestTurn: null, | |
| 1136 | + latestRun: null, | |
| 1145 | 1137 | branch: null, |
| 1146 | 1138 | worktreePath: null, |
| 1147 | − checkpoints: [], | |
| 1148 | − activities: [], | |
| 1149 | 1139 | ...overrides, |
| 1150 | − }; | |
| 1140 | + }); | |
| 1151 | 1141 | } |
| 1152 | 1142 | |
| 1153 | 1143 | describe("getFallbackThreadIdAfterDelete", () => { |
| @@ -1231,7 +1221,7 @@ describe("sortProjectsForSidebar", () => { · 77 unmodified lines | ||
| 1231 | 1221 | id: "message-1" as never, |
| 1232 | 1222 | role: "user", |
| 1233 | 1223 | text: "older project user message", |
| 1234 | − turnId: null, | |
| 1224 | + runId: null, | |
| 1235 | 1225 | createdAt: "2026-03-09T10:01:00.000Z", |
| 1236 | 1226 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 1237 | 1227 | streaming: false, |
| @@ -1247,7 +1237,7 @@ describe("sortProjectsForSidebar", () => { · 9 unmodified lines | ||
| 1247 | 1237 | id: "message-2" as never, |
| 1248 | 1238 | role: "user", |
| 1249 | 1239 | text: "newer project user message", |
| 1250 | − turnId: null, | |
| 1240 | + runId: null, | |
| 1251 | 1241 | createdAt: "2026-03-09T10:05:00.000Z", |
| 1252 | 1242 | updatedAt: "2026-03-09T10:05:00.000Z", |
| 1253 | 1243 | streaming: false, |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | import type { SidebarThreadSummary, Thread } from "../types"; |
| 11 | 11 | import type { ThreadRouteTarget } from "../threadRoutes"; |
| 12 | 12 | import { cn } from "../lib/utils"; |
| 13 | −import { isLatestTurnSettled } from "../session-logic"; | |
| 13 | +import { isLatestRunSettled } from "../session-logic"; | |
| 14 | 14 | import { resolveServerBackedAppStageLabel } from "../branding.logic"; |
| 15 | 15 | |
| 16 | 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 | 141 | | "hasPendingApprovals" |
| 142 | 142 | | "hasPendingUserInput" |
| 143 | 143 | | "interactionMode" |
| 144 | − | "latestTurn" | |
| 145 | − | "session" | |
| 144 | + | "latestRun" | |
| 145 | + | "runtime" | |
| 146 | 146 | > & { |
| 147 | 147 | lastVisitedAt?: string | undefined; |
| 148 | 148 | }; |
| @@ -240,8 +240,8 @@ export function useThreadJumpHintVisibility(): { · 91 unmodified lines | ||
| 240 | 240 | } |
| 241 | 241 | |
| 242 | 242 | export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { |
| 243 | − if (!thread.latestTurn?.completedAt) return false; | |
| 244 | − const completedAt = Date.parse(thread.latestTurn.completedAt); | |
| 243 | + if (!thread.latestRun?.completedAt) return false; | |
| 244 | + const completedAt = Date.parse(thread.latestRun.completedAt); | |
| 245 | 245 | if (Number.isNaN(completedAt)) return false; |
| 246 | 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 | 598 | return { |
| 599 | 599 | label: "Working", |
| 600 | 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 | 607 | return { |
| 608 | 608 | label: "Connecting", |
| 609 | 609 | colorClass: "text-sky-600 dark:text-sky-300/80", |
| @@ -615,7 +615,7 @@ export function resolveThreadStatusPill(input: { · 5 unmodified lines | ||
| 615 | 615 | const hasPlanReadyPrompt = |
| 616 | 616 | !thread.hasPendingUserInput && |
| 617 | 617 | thread.interactionMode === "plan" && |
| 618 | − isLatestTurnSettled(thread.latestTurn, thread.session) && | |
| 618 | + isLatestRunSettled(thread.latestRun, thread.runtime) && | |
| 619 | 619 | thread.hasActionableProposedPlan; |
| 620 | 620 | if (hasPlanReadyPrompt) { |
| 621 | 621 | return { |
| @@ -442,7 +442,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 441 unmodified lines | ||
| 442 | 442 | [discoveredPorts, navigateToThread, openPreview, threadRef], |
| 443 | 443 | ); |
| 444 | 444 | const isThreadRunning = |
| 445 | − thread.session?.status === "running" && thread.session.activeTurnId != null; | |
| 445 | + thread.runtime?.status === "running" && thread.runtime.activeRunId != null; | |
| 446 | 446 | const threadStatus = resolveThreadStatusPill({ |
| 447 | 447 | thread: { |
| 448 | 448 | ...thread, |
| @@ -1777,8 +1777,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1328 unmodified lines | ||
| 1777 | 1777 | ); |
| 1778 | 1778 | |
| 1779 | 1779 | if (clicked === "mark-unread") { |
| 1780 | − for (const { threadKey, thread } of selectedThreadEntries) { | |
| 1781 | − markThreadUnread(threadKey, thread.latestTurn?.completedAt); | |
| 1780 | + for (const threadKey of threadKeys) { | |
| 1781 | + const thread = sidebarThreadByKeyRef.current.get(threadKey); | |
| 1782 | + markThreadUnread(threadKey, thread?.latestRun?.completedAt); | |
| 1782 | 1783 | } |
| 1783 | 1784 | clearSelection(); |
| 1784 | 1785 | return; |
| @@ -2155,7 +2156,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 370 unmodified lines | ||
| 2155 | 2156 | } |
| 2156 | 2157 | |
| 2157 | 2158 | if (clicked === "mark-unread") { |
| 2158 | − markThreadUnread(threadKey, thread.latestTurn?.completedAt); | |
| 2159 | + markThreadUnread(threadKey, thread.latestRun?.completedAt); | |
| 2159 | 2160 | return; |
| 2160 | 2161 | } |
| 2161 | 2162 | if (clicked === "copy-path") { |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { TurnId } from "@t3tools/contracts"; | |
| 1 | +import { RunId } from "@t3tools/contracts"; | |
| 2 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| @@ -142,7 +142,7 @@ describe("ChangedFilesTree", () => { · 137 unmodified lines | ||
| 142 | 142 | ({ files, visibleLabels, hiddenLabels }) => { |
| 143 | 143 | const markup = renderToStaticMarkup( |
| 144 | 144 | <ChangedFilesTree |
| 145 | − turnId={TurnId.make("turn-1")} | |
| 145 | + runId={RunId.make("turn-1")} | |
| 146 | 146 | files={files} |
| 147 | 147 | allDirectoriesExpanded={false} |
| 148 | 148 | resolvedTheme="light" |
| @@ -218,7 +218,7 @@ describe("ChangedFilesTree", () => { · 69 unmodified lines | ||
| 218 | 218 | ({ files, visibleLabels }) => { |
| 219 | 219 | const markup = renderToStaticMarkup( |
| 220 | 220 | <ChangedFilesTree |
| 221 | − turnId={TurnId.make("turn-1")} | |
| 221 | + runId={RunId.make("turn-1")} | |
| 222 | 222 | files={files} |
| 223 | 223 | allDirectoriesExpanded |
| 224 | 224 | resolvedTheme="light" |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { type TurnId } from "@t3tools/contracts"; | |
| 1 | +import { type RunId } from "@t3tools/contracts"; | |
| 2 | 2 | import { memo, useCallback, useMemo, useState } from "react"; |
| 3 | 3 | import { type TurnDiffFileChange } from "../../types"; |
| 4 | 4 | import { |
| @@ -28,7 +28,7 @@ import { · 23 unmodified lines | ||
| 28 | 28 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 29 | 29 | |
| 30 | 30 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 31 | − turnId: TurnId; | |
| 31 | + runId: RunId; | |
| 32 | 32 | files: ReadonlyArray<TurnDiffFileChange>; |
| 33 | 33 | expanded: boolean; |
| 34 | 34 | showCompactPreview: boolean; |
| @@ -36,10 +36,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 1 unmodified line | ||
| 36 | 36 | resolvedTheme: "light" | "dark"; |
| 37 | 37 | onExpandedChange: (expanded: boolean) => void; |
| 38 | 38 | onToggleAllDirectories: () => void; |
| 39 | − onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 39 | + onOpenTurnDiff: (runId: RunId, filePath?: string) => void; | |
| 40 | 40 | }) { |
| 41 | 41 | const { |
| 42 | − turnId, | |
| 42 | + runId, | |
| 43 | 43 | files, |
| 44 | 44 | expanded, |
| 45 | 45 | showCompactPreview, |
| @@ -100,57 +100,29 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 54 unmodified lines | ||
| 100 | 100 | </span> |
| 101 | 101 | </button> |
| 102 | 102 | <div className="flex items-center gap-1.5"> |
| 103 | − {expanded ? ( | |
| 104 | − <Tooltip> | |
| 105 | − <TooltipTrigger | |
| 106 | − render={ | |
| 107 | − <Button | |
| 108 | − type="button" | |
| 109 | − size="icon-xs" | |
| 110 | − variant="outline" | |
| 111 | − className="!size-[22px]" | |
| 112 | − aria-label={ | |
| 113 | − allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" | |
| 114 | − } | |
| 115 | − data-scroll-anchor-ignore | |
| 116 | − onClick={onToggleAllDirectories} | |
| 117 | − /> | |
| 118 | − } | |
| 119 | − > | |
| 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> | |
| 103 | + <Button | |
| 104 | + type="button" | |
| 105 | + size="xs" | |
| 106 | + variant="outline" | |
| 107 | + data-scroll-anchor-ignore | |
| 108 | + onClick={onToggleAllDirectories} | |
| 109 | + > | |
| 110 | + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | |
| 111 | + </Button> | |
| 112 | + <Button | |
| 113 | + type="button" | |
| 114 | + size="xs" | |
| 115 | + variant="outline" | |
| 116 | + onClick={() => onOpenTurnDiff(runId, files[0]?.path)} | |
| 117 | + > | |
| 118 | + View diff | |
| 119 | + </Button> | |
| 148 | 120 | </div> |
| 149 | 121 | </div> |
| 150 | 122 | {expanded ? ( |
| 151 | 123 | <ChangedFilesTree |
| 152 | − key={`changed-files-tree:${turnId}`} | |
| 153 | − turnId={turnId} | |
| 124 | + key={`changed-files-tree:${runId}`} | |
| 125 | + runId={runId} | |
| 154 | 126 | files={files} |
| 155 | 127 | allDirectoriesExpanded={allDirectoriesExpanded} |
| 156 | 128 | resolvedTheme={resolvedTheme} |
| @@ -202,13 +174,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 45 unmodified lines | ||
| 202 | 174 | }); |
| 203 | 175 | |
| 204 | 176 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 205 | − turnId: TurnId; | |
| 177 | + runId: RunId; | |
| 206 | 178 | files: ReadonlyArray<TurnDiffFileChange>; |
| 207 | 179 | allDirectoriesExpanded: boolean; |
| 208 | 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 | 184 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); |
| 213 | 185 | const directoryPathsKey = useMemo( |
| 214 | 186 | () => collectDirectoryPaths(treeNodes).join("\u0000"), |
| @@ -293,7 +265,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 78 unmodified lines | ||
| 293 | 265 | type="button" |
| 294 | 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 | 267 | style={{ paddingLeft: `${leftPadding}px` }} |
| 296 | − onClick={() => onOpenTurnDiff(turnId, node.path)} | |
| 268 | + onClick={() => onOpenTurnDiff(runId, node.path)} | |
| 297 | 269 | > |
| 298 | 270 | {hasDirectoryNodes || depth > 0 ? ( |
| 299 | 271 | <span aria-hidden="true" className="size-3.5 shrink-0" /> |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import type { |
| 2 | − ApprovalRequestId, | |
| 3 | 2 | EnvironmentId, |
| 4 | 3 | ModelSelection, |
| 5 | 4 | PreviewAnnotationPayload, |
| @@ -10,7 +9,8 @@ import type { · 4 unmodified lines | ||
| 10 | 9 | ScopedThreadRef, |
| 11 | 10 | ServerProvider, |
| 12 | 11 | ThreadId, |
| 13 | − TurnId, | |
| 12 | + RunId, | |
| 13 | + RuntimeRequestId, | |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import { |
| 16 | 16 | ProviderDriverKind, |
| @@ -548,13 +548,13 @@ export interface ChatComposerProps { · 531 unmodified lines | ||
| 548 | 548 | activePendingIsResponding: boolean; |
| 549 | 549 | activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>; |
| 550 | 550 | activePendingQuestionIndex: number; |
| 551 | − respondingRequestIds: ApprovalRequestId[]; | |
| 551 | + respondingRequestIds: RuntimeRequestId[]; | |
| 552 | 552 | |
| 553 | 553 | // Plan |
| 554 | 554 | showPlanFollowUpPrompt: boolean; |
| 555 | 555 | activeProposedPlan: Thread["proposedPlans"][number] | null; |
| 556 | − activePlan: { turnId?: TurnId } | null; | |
| 557 | − sidebarProposedPlan: { turnId?: TurnId } | null; | |
| 556 | + activePlan: { runId?: RunId | null } | null; | |
| 557 | + sidebarProposedPlan: { runId?: RunId | null } | null; | |
| 558 | 558 | planSidebarLabel: string; |
| 559 | 559 | planSidebarOpen: boolean; |
| 560 | 560 | |
| @@ -569,7 +569,7 @@ export interface ChatComposerProps { · 8 unmodified lines | ||
| 569 | 569 | activeThreadModelSelection: ModelSelection | null | undefined; |
| 570 | 570 | |
| 571 | 571 | // Context window |
| 572 | − activeThreadActivities: Thread["activities"] | undefined; | |
| 572 | + activeThreadWorkEntries: Thread["workEntries"] | undefined; | |
| 573 | 573 | |
| 574 | 574 | // Misc |
| 575 | 575 | resolvedTheme: "light" | "dark"; |
| @@ -585,12 +585,16 @@ export interface ChatComposerProps { · 9 unmodified lines | ||
| 585 | 585 | composerElementContextsRef: React.RefObject<ElementContextDraft[]>; |
| 586 | 586 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| 587 | 587 | |
| 588 | + // Scroll | |
| 589 | + shouldAutoScrollRef: React.RefObject<boolean>; | |
| 590 | + scheduleStickToBottom: () => void; | |
| 591 | + | |
| 588 | 592 | // Callbacks |
| 589 | 593 | onSend: (e?: { preventDefault: () => void }) => void; |
| 590 | 594 | onInterrupt: () => void; |
| 591 | 595 | onImplementPlanInNewThread: () => void; |
| 592 | 596 | onRespondToApproval: ( |
| 593 | − requestId: ApprovalRequestId, | |
| 597 | + requestId: RuntimeRequestId, | |
| 594 | 598 | decision: ProviderApprovalDecision, |
| 595 | 599 | ) => Promise<unknown>; |
| 596 | 600 | onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; |
| @@ -662,7 +666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 65 unmodified lines | ||
| 662 | 666 | providerStatuses, |
| 663 | 667 | activeProjectDefaultModelSelection, |
| 664 | 668 | activeThreadModelSelection, |
| 665 | − activeThreadActivities, | |
| 669 | + activeThreadWorkEntries, | |
| 666 | 670 | resolvedTheme, |
| 667 | 671 | settings, |
| 668 | 672 | keybindings, |
| @@ -673,6 +677,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 4 unmodified lines | ||
| 673 | 677 | composerImagesRef, |
| 674 | 678 | composerTerminalContextsRef, |
| 675 | 679 | composerElementContextsRef, |
| 680 | + shouldAutoScrollRef, | |
| 681 | + scheduleStickToBottom, | |
| 676 | 682 | onSend, |
| 677 | 683 | onInterrupt, |
| 678 | 684 | onImplementPlanInNewThread, |
| @@ -754,7 +760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 75 unmodified lines | ||
| 754 | 760 | ); |
| 755 | 761 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 756 | 762 | const threadProvider = |
| 757 | − activeThread?.session?.providerInstanceId ?? | |
| 763 | + activeThread?.runtime?.providerInstanceId ?? | |
| 758 | 764 | activeThreadModelSelection?.instanceId ?? |
| 759 | 765 | activeProjectDefaultModelSelection?.instanceId ?? |
| 760 | 766 | null; |
| @@ -772,7 +778,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines | ||
| 772 | 778 | const lockedContinuationGroupKey = useMemo((): string | null => { |
| 773 | 779 | if (!lockedProvider || !activeThread) return null; |
| 774 | 780 | const lockedInstanceId = |
| 775 | − activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; | |
| 781 | + activeThread.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId; | |
| 776 | 782 | if (!lockedInstanceId) return null; |
| 777 | 783 | return ( |
| 778 | 784 | providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) |
| @@ -798,7 +804,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines | ||
| 798 | 804 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { |
| 799 | 805 | const candidates: Array<string | null | undefined> = [ |
| 800 | 806 | composerDraft.activeProvider, |
| 801 | − activeThread?.session?.providerInstanceId, | |
| 807 | + activeThread?.runtime?.providerInstanceId, | |
| 802 | 808 | activeThreadModelSelection?.instanceId, |
| 803 | 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 | 843 | activeProjectDefaultModelSelection?.instanceId, |
| 838 | − activeThread?.session?.providerInstanceId, | |
| 844 | + activeThread?.runtime?.providerInstanceId, | |
| 839 | 845 | activeThreadModelSelection?.instanceId, |
| 840 | 846 | composerDraft.activeProvider, |
| 841 | 847 | lockedContinuationGroupKey, |
| @@ -939,8 +945,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 97 unmodified lines | ||
| 939 | 945 | // Context window |
| 940 | 946 | // ------------------------------------------------------------------ |
| 941 | 947 | const activeContextWindow = useMemo( |
| 942 | − () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), | |
| 943 | − [activeThreadActivities], | |
| 948 | + () => deriveLatestContextWindowSnapshot(activeThreadWorkEntries ?? []), | |
| 949 | + [activeThreadWorkEntries], | |
| 944 | 950 | ); |
| 945 | 951 | const activeThreadProviderDisplayName = useMemo(() => { |
| 946 | 952 | if (!activeThreadModelSelection) return null; |
| @@ -987,6 +993,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 40 unmodified lines | ||
| 987 | 993 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 988 | 994 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 989 | 995 | const composerSurfaceRef = useRef<HTMLDivElement>(null); |
| 996 | + const composerFormHeightRef = useRef(0); | |
| 990 | 997 | const composerSelectLockRef = useRef(false); |
| 991 | 998 | const composerMenuOpenRef = useRef(false); |
| 992 | 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 | 1452 | const initialCompactness = measureFooterCompactness(); |
| 1445 | 1453 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); |
| 1446 | 1454 | setIsComposerFooterCompact(initialCompactness.footerCompact); |
| 1447 | 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 | 1460 | const nextCompactness = measureFooterCompactness(); |
| 1451 | 1461 | setIsComposerPrimaryActionsCompact((previous) => |
| 1452 | 1462 | previous === nextCompactness.primaryActionsCompact |
| @@ -1456,13 +1466,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines | ||
| 1456 | 1466 | setIsComposerFooterCompact((previous) => |
| 1457 | 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 | 1477 | observer.observe(composerForm); |
| 1462 | 1478 | return () => { |
| 1463 | 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 | 1490 | // Image persist effect |
| @@ -2752,6 +2774,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1283 unmodified lines | ||
| 2752 | 2774 | <ComposerPendingApprovalActions |
| 2753 | 2775 | requestId={activePendingApproval.requestId} |
| 2754 | 2776 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| 2777 | + canRespond={activePendingApproval.responseCapability === "live"} | |
| 2755 | 2778 | onRespondToApproval={onRespondToApproval} |
| 2756 | 2779 | /> |
| 2757 | 2780 | </div> |
| @@ -3108,6 +3131,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 350 unmodified lines | ||
| 3108 | 3131 | <ComposerPendingApprovalActions |
| 3109 | 3132 | requestId={activePendingApproval.requestId} |
| 3110 | 3133 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| 3134 | + canRespond={activePendingApproval.responseCapability === "live"} | |
| 3111 | 3135 | onRespondToApproval={onRespondToApproval} |
| 3112 | 3136 | /> |
| 3113 | 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 | 2 | import { memo } from "react"; |
| 3 | 3 | import { Button } from "../ui/button"; |
| 4 | 4 | |
| 5 | 5 | interface ComposerPendingApprovalActionsProps { |
| 6 | − requestId: ApprovalRequestId; | |
| 6 | + requestId: RuntimeRequestId; | |
| 7 | 7 | isResponding: boolean; |
| 8 | + canRespond: boolean; | |
| 8 | 9 | onRespondToApproval: ( |
| 9 | − requestId: ApprovalRequestId, | |
| 10 | + requestId: RuntimeRequestId, | |
| 10 | 11 | decision: ProviderApprovalDecision, |
| 11 | 12 | ) => Promise<unknown>; |
| 12 | 13 | } |
| @@ -14,6 +15,7 @@ interface ComposerPendingApprovalActionsProps { · 1 unmodified line | ||
| 14 | 15 | export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ |
| 15 | 16 | requestId, |
| 16 | 17 | isResponding, |
| 18 | + canRespond, | |
| 17 | 19 | onRespondToApproval, |
| 18 | 20 | }: ComposerPendingApprovalActionsProps) { |
| 19 | 21 | return ( |
| @@ -21,7 +23,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | ||
| 21 | 23 | <Button |
| 22 | 24 | size="sm" |
| 23 | 25 | variant="ghost" |
| 24 | − disabled={isResponding} | |
| 26 | + disabled={isResponding || !canRespond} | |
| 25 | 27 | onClick={() => void onRespondToApproval(requestId, "cancel")} |
| 26 | 28 | > |
| 27 | 29 | Cancel turn |
| @@ -29,7 +31,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | ||
| 29 | 31 | <Button |
| 30 | 32 | size="sm" |
| 31 | 33 | variant="destructive-outline" |
| 32 | − disabled={isResponding} | |
| 34 | + disabled={isResponding || !canRespond} | |
| 33 | 35 | onClick={() => void onRespondToApproval(requestId, "decline")} |
| 34 | 36 | > |
| 35 | 37 | Decline |
| @@ -37,7 +39,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | ||
| 37 | 39 | <Button |
| 38 | 40 | size="sm" |
| 39 | 41 | variant="outline" |
| 40 | − disabled={isResponding} | |
| 42 | + disabled={isResponding || !canRespond} | |
| 41 | 43 | onClick={() => void onRespondToApproval(requestId, "acceptForSession")} |
| 42 | 44 | > |
| 43 | 45 | Always allow this session |
| @@ -45,7 +47,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line | ||
| 45 | 47 | <Button |
| 46 | 48 | size="sm" |
| 47 | 49 | variant="default" |
| 48 | − disabled={isResponding} | |
| 50 | + disabled={isResponding || !canRespond} | |
| 49 | 51 | onClick={() => void onRespondToApproval(requestId, "accept")} |
| 50 | 52 | > |
| 51 | 53 | Approve once |
| @@ -32,17 +32,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova · 31 unmodified lines | ||
| 32 | 32 | <span className="text-xs text-muted-foreground">1/{pendingCount}</span> |
| 33 | 33 | ) : null} |
| 34 | 34 | </div> |
| 35 | − {approval.detail ? ( | |
| 36 | − <div className="mt-3 rounded-lg border border-border/65 bg-background/70 p-3"> | |
| 37 | − <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p> | |
| 38 | − <pre | |
| 39 | − aria-label={detailLabel} | |
| 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> | |
| 35 | + {approval.responseCapability === "not_resumable" ? ( | |
| 36 | + <p className="mt-2 text-xs text-muted-foreground"> | |
| 37 | + This request belonged to a provider process that is no longer available. Interrupt or | |
| 38 | + restart the run to continue. | |
| 39 | + </p> | |
| 46 | 40 | ) : null} |
| 47 | 41 | </div> |
| 48 | 42 | ); |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { type ApprovalRequestId } from "@t3tools/contracts"; | |
| 1 | +import { type RuntimeRequestId } from "@t3tools/contracts"; | |
| 2 | 2 | import { memo, useEffect, useEffectEvent, useRef, useState } from "react"; |
| 3 | 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | 4 | import { |
| @@ -10,7 +10,7 @@ import { cn } from "~/lib/utils"; · 5 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | interface PendingUserInputPanelProps { |
| 12 | 12 | pendingUserInputs: PendingUserInput[]; |
| 13 | − respondingRequestIds: ApprovalRequestId[]; | |
| 13 | + respondingRequestIds: RuntimeRequestId[]; | |
| 14 | 14 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 15 | 15 | questionIndex: number; |
| 16 | 16 | onToggleOption: (questionId: string, optionLabel: string) => void; |
| @@ -59,6 +59,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 42 unmodified lines | ||
| 59 | 59 | }) { |
| 60 | 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 61 | 61 | const activeQuestion = progress.activeQuestion; |
| 62 | + const canRespond = prompt.responseCapability === "live"; | |
| 63 | + const responseDisabled = isResponding || !canRespond; | |
| 62 | 64 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| 63 | 65 | const onAdvanceRef = useRef(onAdvance); |
| 64 | 66 | const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ |
| @@ -120,7 +122,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 55 unmodified lines | ||
| 120 | 122 | // outside editable fields. Multi-select prompts toggle options in place; single- |
| 121 | 123 | // select prompts keep the existing auto-advance behavior. |
| 122 | 124 | useEffect(() => { |
| 123 | − if (!activeQuestion || isResponding) return; | |
| 125 | + if (!activeQuestion || responseDisabled) return; | |
| 124 | 126 | const handler = (event: globalThis.KeyboardEvent) => { |
| 125 | 127 | if (event.metaKey || event.ctrlKey || event.altKey) return; |
| 126 | 128 | const target = event.target; |
| @@ -144,7 +146,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 17 unmodified lines | ||
| 144 | 146 | }; |
| 145 | 147 | document.addEventListener("keydown", handler); |
| 146 | 148 | return () => document.removeEventListener("keydown", handler); |
| 147 | − }, [activeQuestion, isResponding]); | |
| 149 | + }, [activeQuestion, responseDisabled]); | |
| 148 | 150 | |
| 149 | 151 | if (!activeQuestion) { |
| 150 | 152 | return null; |
| @@ -165,6 +167,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 14 unmodified lines | ||
| 165 | 167 | ) : null} |
| 166 | 168 | </div> |
| 167 | 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 | 176 | {activeQuestion.multiSelect ? ( |
| 169 | 177 | <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p> |
| 170 | 178 | ) : null} |
| @@ -182,8 +190,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 11 unmodified lines | ||
| 182 | 190 | isSelected |
| 183 | 191 | ? "border-primary/30 bg-primary/8 text-foreground" |
| 184 | 192 | : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", |
| 185 | − isResponding && "opacity-50 cursor-not-allowed", | |
| 186 | − !isResponding && "cursor-pointer", | |
| 193 | + responseDisabled && "opacity-50 cursor-not-allowed", | |
| 194 | + !responseDisabled && "cursor-pointer", | |
| 187 | 195 | ); |
| 188 | 196 | const content = ( |
| 189 | 197 | <> |
| @@ -211,8 +219,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 21 unmodified lines | ||
| 211 | 219 | <button |
| 212 | 220 | key={`${activeQuestion.id}:${option.label}`} |
| 213 | 221 | type="button" |
| 214 | − disabled={isResponding} | |
| 222 | + disabled={responseDisabled} | |
| 223 | + aria-disabled={responseDisabled} | |
| 215 | 224 | onClick={() => { |
| 225 | + if (responseDisabled) return; | |
| 216 | 226 | handleOptionSelection(activeQuestion.id, option.label); |
| 217 | 227 | }} |
| 218 | 228 | className={className} |
| @@ -272,7 +272,7 @@ describe("deriveMessagesTimelineRows", () => { · 271 unmodified lines | ||
| 272 | 272 | id: "user-1" as never, |
| 273 | 273 | role: "user", |
| 274 | 274 | text: "Write a poem", |
| 275 | − turnId: null, | |
| 275 | + runId: null, | |
| 276 | 276 | createdAt: "2026-01-01T00:00:00Z", |
| 277 | 277 | updatedAt: "2026-01-01T00:00:00Z", |
| 278 | 278 | streaming: false, |
| @@ -286,7 +286,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 286 | 286 | id: "assistant-thought" as never, |
| 287 | 287 | role: "assistant", |
| 288 | 288 | text: "I should ground this first.", |
| 289 | − turnId: "turn-1" as never, | |
| 289 | + runId: "turn-1" as never, | |
| 290 | 290 | createdAt: "2026-01-01T00:00:10Z", |
| 291 | 291 | updatedAt: "2026-01-01T00:00:11Z", |
| 292 | 292 | streaming: false, |
| @@ -300,14 +300,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 300 | 300 | id: "assistant-final" as never, |
| 301 | 301 | role: "assistant", |
| 302 | 302 | text: "Here is the poem.", |
| 303 | − turnId: "turn-1" as never, | |
| 303 | + runId: "turn-1" as never, | |
| 304 | 304 | createdAt: "2026-01-01T00:00:20Z", |
| 305 | 305 | updatedAt: "2026-01-01T00:00:30Z", |
| 306 | 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 | 311 | isWorking: false, |
| 312 | 312 | activeTurnStartedAt: null, |
| 313 | 313 | turnDiffSummaryByAssistantMessageId: new Map(), |
| @@ -335,7 +335,7 @@ describe("deriveMessagesTimelineRows", () => { · 21 unmodified lines | ||
| 335 | 335 | id: "assistant-one" as never, |
| 336 | 336 | role: "assistant", |
| 337 | 337 | text: "Earlier response.", |
| 338 | − turnId: "turn-1" as never, | |
| 338 | + runId: "turn-1" as never, | |
| 339 | 339 | createdAt: "2026-01-01T00:00:10Z", |
| 340 | 340 | updatedAt: "2026-01-01T00:00:11Z", |
| 341 | 341 | streaming: false, |
| @@ -349,16 +349,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 349 | 349 | id: "assistant-two" as never, |
| 350 | 350 | role: "assistant", |
| 351 | 351 | text: "Active response.", |
| 352 | − turnId: "turn-2" as never, | |
| 352 | + runId: "turn-2" as never, | |
| 353 | 353 | createdAt: "2026-01-01T00:00:20Z", |
| 354 | 354 | updatedAt: "2026-01-01T00:00:30Z", |
| 355 | 355 | streaming: false, |
| 356 | 356 | }, |
| 357 | 357 | }, |
| 358 | 358 | ], |
| 359 | − latestTurn: { | |
| 360 | − turnId: "turn-2" as never, | |
| 361 | − state: "running", | |
| 359 | + latestRun: { | |
| 360 | + runId: "turn-2" as never, | |
| 361 | + status: "running", | |
| 362 | 362 | startedAt: "2026-01-01T00:00:19Z", |
| 363 | 363 | completedAt: null, |
| 364 | 364 | }, |
| @@ -379,7 +379,7 @@ describe("deriveMessagesTimelineRows", () => { · 14 unmodified lines | ||
| 379 | 379 | |
| 380 | 380 | it("projects assistant diff summaries and user revert counts onto the affected rows", () => { |
| 381 | 381 | const assistantTurnDiffSummary = { |
| 382 | − turnId: "turn-1" as never, | |
| 382 | + runId: "turn-1" as never, | |
| 383 | 383 | completedAt: "2026-01-01T00:00:30Z", |
| 384 | 384 | assistantMessageId: "assistant-1" as never, |
| 385 | 385 | checkpointTurnCount: 2, |
| @@ -398,7 +398,7 @@ describe("deriveMessagesTimelineRows", () => { · 12 unmodified lines | ||
| 398 | 398 | id: "user-1" as never, |
| 399 | 399 | role: "user", |
| 400 | 400 | text: "Do the thing", |
| 401 | − turnId: null, | |
| 401 | + runId: null, | |
| 402 | 402 | createdAt: "2026-01-01T00:00:00Z", |
| 403 | 403 | updatedAt: "2026-01-01T00:00:00Z", |
| 404 | 404 | streaming: false, |
| @@ -412,7 +412,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 412 | 412 | id: "assistant-1" as never, |
| 413 | 413 | role: "assistant", |
| 414 | 414 | text: "Done", |
| 415 | − turnId: "turn-1" as never, | |
| 415 | + runId: "turn-1" as never, | |
| 416 | 416 | createdAt: "2026-01-01T00:00:20Z", |
| 417 | 417 | updatedAt: "2026-01-01T00:00:30Z", |
| 418 | 418 | streaming: false, |
| @@ -450,7 +450,7 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines | ||
| 450 | 450 | id: "user-1" as never, |
| 451 | 451 | role: "user" as const, |
| 452 | 452 | text: "Build it", |
| 453 | − turnId: null, | |
| 453 | + runId: null, | |
| 454 | 454 | createdAt: "2026-01-01T00:00:00Z", |
| 455 | 455 | updatedAt: "2026-01-01T00:00:00Z", |
| 456 | 456 | streaming: false, |
| @@ -464,7 +464,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 464 | 464 | id: "assistant-thought" as never, |
| 465 | 465 | role: "assistant" as const, |
| 466 | 466 | text: "Looking around first.", |
| 467 | − turnId: "turn-1" as never, | |
| 467 | + runId: "turn-1" as never, | |
| 468 | 468 | createdAt: "2026-01-01T00:00:05Z", |
| 469 | 469 | updatedAt: "2026-01-01T00:00:06Z", |
| 470 | 470 | streaming: false, |
| @@ -477,7 +477,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | ||
| 477 | 477 | entry: { |
| 478 | 478 | id: "work-1", |
| 479 | 479 | createdAt: "2026-01-01T00:00:08Z", |
| 480 | − turnId: "turn-1" as never, | |
| 480 | + runId: "turn-1" as never, | |
| 481 | 481 | label: "Ran command", |
| 482 | 482 | tone: "tool" as const, |
| 483 | 483 | }, |
| @@ -490,7 +490,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | ||
| 490 | 490 | id: "assistant-final" as never, |
| 491 | 491 | role: "assistant" as const, |
| 492 | 492 | text: "Done", |
| 493 | − turnId: "turn-1" as never, | |
| 493 | + runId: "turn-1" as never, | |
| 494 | 494 | createdAt: "2026-01-01T00:00:20Z", |
| 495 | 495 | updatedAt: "2026-01-01T00:00:22Z", |
| 496 | 496 | streaming: false, |
| @@ -510,7 +510,7 @@ describe("deriveMessagesTimelineRows", () => { · 13 unmodified lines | ||
| 510 | 510 | (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> => |
| 511 | 511 | row.kind === "turn-fold", |
| 512 | 512 | ); |
| 513 | − expect(foldRow?.turnId).toBe("turn-1"); | |
| 513 | + expect(foldRow?.runId).toBe("turn-1"); | |
| 514 | 514 | expect(foldRow?.expanded).toBe(false); |
| 515 | 515 | // User message boundary (00:00:00) → terminal message updatedAt (00:00:22). |
| 516 | 516 | expect(foldRow?.label).toBe("Worked for 22s"); |
| @@ -522,7 +522,7 @@ describe("deriveMessagesTimelineRows", () => { · 5 unmodified lines | ||
| 522 | 522 | |
| 523 | 523 | const expandedRows = deriveMessagesTimelineRows({ |
| 524 | 524 | timelineEntries, |
| 525 | − expandedTurnIds: new Set(["turn-1" as never]), | |
| 525 | + expandedRunIds: new Set(["turn-1" as never]), | |
| 526 | 526 | isWorking: false, |
| 527 | 527 | activeTurnStartedAt: null, |
| 528 | 528 | turnDiffSummaryByAssistantMessageId: new Map(), |
| @@ -556,7 +556,7 @@ describe("deriveMessagesTimelineRows", () => { · 27 unmodified lines | ||
| 556 | 556 | id: "user-1" as never, |
| 557 | 557 | role: "user" as const, |
| 558 | 558 | text: "do it once more", |
| 559 | − turnId: null, | |
| 559 | + runId: null, | |
| 560 | 560 | createdAt: "2026-01-01T00:00:00Z", |
| 561 | 561 | updatedAt: "2026-01-01T00:00:00Z", |
| 562 | 562 | streaming: false, |
| @@ -570,7 +570,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 570 | 570 | id: "assistant-commentary" as never, |
| 571 | 571 | role: "assistant" as const, |
| 572 | 572 | text: "Kicking off call 1.", |
| 573 | − turnId: "turn-1" as never, | |
| 573 | + runId: "turn-1" as never, | |
| 574 | 574 | createdAt: "2026-01-01T00:00:09Z", |
| 575 | 575 | updatedAt: "2026-01-01T00:00:09Z", |
| 576 | 576 | streaming: false, |
| @@ -583,7 +583,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | ||
| 583 | 583 | entry: { |
| 584 | 584 | id: "work-1", |
| 585 | 585 | createdAt: "2026-01-01T00:00:12Z", |
| 586 | − turnId: "turn-1" as never, | |
| 586 | + runId: "turn-1" as never, | |
| 587 | 587 | label: "Ran command", |
| 588 | 588 | tone: "tool" as const, |
| 589 | 589 | }, |
| @@ -596,7 +596,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | ||
| 596 | 596 | id: "user-2" as never, |
| 597 | 597 | role: "user" as const, |
| 598 | 598 | text: "actually do 15", |
| 599 | − turnId: null, | |
| 599 | + runId: null, | |
| 600 | 600 | createdAt: "2026-01-01T00:00:14Z", |
| 601 | 601 | updatedAt: "2026-01-01T00:00:14Z", |
| 602 | 602 | streaming: false, |
| @@ -610,16 +610,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 610 | 610 | id: "assistant-next" as never, |
| 611 | 611 | role: "assistant" as const, |
| 612 | 612 | text: "One down — adjusting.", |
| 613 | − turnId: "turn-2" as never, | |
| 613 | + runId: "turn-2" as never, | |
| 614 | 614 | createdAt: "2026-01-01T00:00:17Z", |
| 615 | 615 | updatedAt: "2026-01-01T00:00:17Z", |
| 616 | 616 | streaming: true, |
| 617 | 617 | }, |
| 618 | 618 | }, |
| 619 | 619 | ], |
| 620 | − latestTurn: { | |
| 621 | − turnId: "turn-2" as never, | |
| 622 | − state: "running", | |
| 620 | + latestRun: { | |
| 621 | + runId: "turn-2" as never, | |
| 622 | + status: "running", | |
| 623 | 623 | startedAt: "2026-01-01T00:00:14Z", |
| 624 | 624 | completedAt: null, |
| 625 | 625 | }, |
| @@ -634,7 +634,7 @@ describe("deriveMessagesTimelineRows", () => { · 8 unmodified lines | ||
| 634 | 634 | row.kind === "turn-fold", |
| 635 | 635 | ); |
| 636 | 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 | 638 | expect(foldRow?.label).toBe("Worked for 12s"); |
| 639 | 639 | }); |
| 640 | 640 | |
| @@ -648,15 +648,15 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 648 | 648 | entry: { |
| 649 | 649 | id: "work-1", |
| 650 | 650 | createdAt: "2026-01-01T00:00:05Z", |
| 651 | − turnId: "turn-1" as never, | |
| 651 | + runId: "turn-1" as never, | |
| 652 | 652 | label: "Ran command", |
| 653 | 653 | tone: "tool" as const, |
| 654 | 654 | }, |
| 655 | 655 | }, |
| 656 | 656 | ], |
| 657 | − latestTurn: { | |
| 658 | − turnId: "turn-1" as never, | |
| 659 | − state: "interrupted", | |
| 657 | + latestRun: { | |
| 658 | + runId: "turn-1" as never, | |
| 659 | + status: "interrupted", | |
| 660 | 660 | startedAt: "2026-01-01T00:00:00Z", |
| 661 | 661 | completedAt: "2026-01-01T00:00:47Z", |
| 662 | 662 | }, |
| @@ -669,15 +669,88 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | ||
| 669 | 669 | expect(rows).toEqual([ |
| 670 | 670 | expect.objectContaining({ |
| 671 | 671 | kind: "turn-fold", |
| 672 | − turnId: "turn-1", | |
| 672 | + runId: "turn-1", | |
| 673 | 673 | label: "You stopped after 47s", |
| 674 | 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 | 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 | 754 | // previous, settled turn — it must stay folded through that window. |
| 682 | 755 | const rows = deriveMessagesTimelineRows({ |
| 683 | 756 | timelineEntries: [ |
| @@ -688,7 +761,7 @@ describe("deriveMessagesTimelineRows", () => { · 4 unmodified lines | ||
| 688 | 761 | entry: { |
| 689 | 762 | id: "work-1", |
| 690 | 763 | createdAt: "2026-01-01T00:00:05Z", |
| 691 | − turnId: "turn-1" as never, | |
| 764 | + runId: "turn-1" as never, | |
| 692 | 765 | label: "Ran command", |
| 693 | 766 | tone: "tool" as const, |
| 694 | 767 | }, |
| @@ -701,7 +774,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | ||
| 701 | 774 | id: "assistant-final" as never, |
| 702 | 775 | role: "assistant", |
| 703 | 776 | text: "Done", |
| 704 | − turnId: "turn-1" as never, | |
| 777 | + runId: "turn-1" as never, | |
| 705 | 778 | createdAt: "2026-01-01T00:00:20Z", |
| 706 | 779 | updatedAt: "2026-01-01T00:00:22Z", |
| 707 | 780 | streaming: false, |
| @@ -715,16 +788,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 715 | 788 | id: "user-followup" as never, |
| 716 | 789 | role: "user", |
| 717 | 790 | text: "yooo", |
| 718 | − turnId: null, | |
| 791 | + runId: null, | |
| 719 | 792 | createdAt: "2026-01-01T00:01:00Z", |
| 720 | 793 | updatedAt: "2026-01-01T00:01:00Z", |
| 721 | 794 | streaming: false, |
| 722 | 795 | }, |
| 723 | 796 | }, |
| 724 | 797 | ], |
| 725 | − latestTurn: { | |
| 726 | − turnId: "turn-1" as never, | |
| 727 | − state: "completed", | |
| 798 | + latestRun: { | |
| 799 | + runId: "turn-1" as never, | |
| 800 | + status: "completed", | |
| 728 | 801 | startedAt: "2026-01-01T00:00:00Z", |
| 729 | 802 | completedAt: "2026-01-01T00:00:22Z", |
| 730 | 803 | }, |
| @@ -755,7 +828,7 @@ describe("deriveMessagesTimelineRows", () => { · 24 unmodified lines | ||
| 755 | 828 | id: "assistant-thought" as never, |
| 756 | 829 | role: "assistant", |
| 757 | 830 | text: "Working on it.", |
| 758 | − turnId: "turn-1" as never, | |
| 831 | + runId: "turn-1" as never, | |
| 759 | 832 | createdAt: "2026-01-01T00:00:05Z", |
| 760 | 833 | updatedAt: "2026-01-01T00:00:06Z", |
| 761 | 834 | streaming: false, |
| @@ -768,15 +841,15 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines | ||
| 768 | 841 | entry: { |
| 769 | 842 | id: "work-1", |
| 770 | 843 | createdAt: "2026-01-01T00:00:08Z", |
| 771 | − turnId: "turn-1" as never, | |
| 844 | + runId: "turn-1" as never, | |
| 772 | 845 | label: "Ran command", |
| 773 | 846 | tone: "tool" as const, |
| 774 | 847 | }, |
| 775 | 848 | }, |
| 776 | 849 | ], |
| 777 | − latestTurn: { | |
| 778 | − turnId: "turn-1" as never, | |
| 779 | − state: "running", | |
| 850 | + latestRun: { | |
| 851 | + runId: "turn-1" as never, | |
| 852 | + status: "running", | |
| 780 | 853 | startedAt: "2026-01-01T00:00:00Z", |
| 781 | 854 | completedAt: null, |
| 782 | 855 | }, |
| @@ -866,7 +939,7 @@ describe("deriveMessagesTimelineRows", () => { · 83 unmodified lines | ||
| 866 | 939 | id: "assistant-thought" as never, |
| 867 | 940 | role: "assistant", |
| 868 | 941 | text: "Checking first.", |
| 869 | − turnId: "turn-1" as never, | |
| 942 | + runId: "turn-1" as never, | |
| 870 | 943 | createdAt: "2026-01-01T00:00:10Z", |
| 871 | 944 | updatedAt: "2026-01-01T00:00:11Z", |
| 872 | 945 | streaming: false, |
| @@ -880,14 +953,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines | ||
| 880 | 953 | id: "assistant-final" as never, |
| 881 | 954 | role: "assistant", |
| 882 | 955 | text: "Done.", |
| 883 | − turnId: "turn-1" as never, | |
| 956 | + runId: "turn-1" as never, | |
| 884 | 957 | createdAt: "2026-01-01T00:00:20Z", |
| 885 | 958 | updatedAt: "2026-01-01T00:00:30Z", |
| 886 | 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 | 964 | isWorking: false, |
| 892 | 965 | activeTurnStartedAt: null, |
| 893 | 966 | turnDiffSummaryByAssistantMessageId: new Map(), |
| @@ -913,16 +986,16 @@ describe("deriveMessagesTimelineRows", () => { · 19 unmodified lines | ||
| 913 | 986 | id: "assistant-thought" as never, |
| 914 | 987 | role: "assistant", |
| 915 | 988 | text: "Working on it.", |
| 916 | − turnId: "turn-1" as never, | |
| 989 | + runId: "turn-1" as never, | |
| 917 | 990 | createdAt: "2026-01-01T00:00:10Z", |
| 918 | 991 | updatedAt: "2026-01-01T00:00:11Z", |
| 919 | 992 | streaming: false, |
| 920 | 993 | }, |
| 921 | 994 | }, |
| 922 | 995 | ], |
| 923 | − latestTurn: { | |
| 924 | − turnId: "turn-1" as never, | |
| 925 | − state: "running", | |
| 996 | + latestRun: { | |
| 997 | + runId: "turn-1" as never, | |
| 998 | + status: "running", | |
| 926 | 999 | startedAt: "2026-01-01T00:00:00Z", |
| 927 | 1000 | completedAt: null, |
| 928 | 1001 | }, |
| @@ -1019,7 +1092,7 @@ describe("computeStableMessagesTimelineRows", () => { · 90 unmodified lines | ||
| 1019 | 1092 | id: "user-1" as never, |
| 1020 | 1093 | role: "user" as const, |
| 1021 | 1094 | text: "First", |
| 1022 | − turnId: null, | |
| 1095 | + runId: null, | |
| 1023 | 1096 | createdAt: "2026-01-01T00:00:00Z", |
| 1024 | 1097 | updatedAt: "2026-01-01T00:00:00Z", |
| 1025 | 1098 | streaming: false, |
| @@ -1028,7 +1101,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines | ||
| 1028 | 1101 | id: "user-2" as never, |
| 1029 | 1102 | role: "user" as const, |
| 1030 | 1103 | text: "Second", |
| 1031 | − turnId: null, | |
| 1104 | + runId: null, | |
| 1032 | 1105 | createdAt: "2026-01-01T00:00:10Z", |
| 1033 | 1106 | updatedAt: "2026-01-01T00:00:10Z", |
| 1034 | 1107 | streaming: false, |
| @@ -1124,7 +1197,7 @@ describe("computeStableMessagesTimelineRows", () => { · 89 unmodified lines | ||
| 1124 | 1197 | id: "user-1" as never, |
| 1125 | 1198 | role: "user" as const, |
| 1126 | 1199 | text: "First", |
| 1127 | − turnId: null, | |
| 1200 | + runId: null, | |
| 1128 | 1201 | createdAt: "2026-01-01T00:00:00Z", |
| 1129 | 1202 | updatedAt: "2026-01-01T00:00:00Z", |
| 1130 | 1203 | streaming: false, |
| @@ -1133,7 +1206,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines | ||
| 1133 | 1206 | id: "user-2" as never, |
| 1134 | 1207 | role: "user" as const, |
| 1135 | 1208 | text: "Second", |
| 1136 | − turnId: null, | |
| 1209 | + runId: null, | |
| 1137 | 1210 | createdAt: "2026-01-01T00:00:10Z", |
| 1138 | 1211 | updatedAt: "2026-01-01T00:00:10Z", |
| 1139 | 1212 | streaming: false, |
| @@ -7,7 +7,12 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type WorkLogEntry, |
| 8 | 8 | } from "../../session-logic"; |
| 9 | 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 | 17 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; |
| 13 | 18 | export const TIMELINE_MINIMAP_ITEM_SPACING = 8; |
| @@ -128,9 +133,9 @@ export interface TimelineDurationMessage { · 114 unmodified lines | ||
| 128 | 133 | streaming: boolean; |
| 129 | 134 | } |
| 130 | 135 | |
| 131 | −export type TimelineLatestTurn = Pick< | |
| 132 | − OrchestrationLatestTurn, | |
| 133 | − "turnId" | "state" | "startedAt" | "completedAt" | |
| 136 | +export type TimelineLatestRun = Pick< | |
| 137 | + ThreadRunSummary, | |
| 138 | + "runId" | "status" | "startedAt" | "completedAt" | |
| 134 | 139 | >; |
| 135 | 140 | |
| 136 | 141 | export type MessagesTimelineRow = |
| @@ -153,7 +158,7 @@ export type MessagesTimelineRow = · 16 unmodified lines | ||
| 153 | 158 | kind: "turn-fold"; |
| 154 | 159 | id: string; |
| 155 | 160 | createdAt: string; |
| 156 | − turnId: TurnId; | |
| 161 | + runId: RunId; | |
| 157 | 162 | label: string; |
| 158 | 163 | expanded: boolean; |
| 159 | 164 | } |
| @@ -169,6 +174,12 @@ export type MessagesTimelineRow = · 9 unmodified lines | ||
| 169 | 174 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; |
| 170 | 175 | revertTurnCount?: number | undefined; |
| 171 | 176 | } |
| 177 | + | { | |
| 178 | + kind: "event"; | |
| 179 | + id: string; | |
| 180 | + createdAt: string; | |
| 181 | + projectedItem: OrchestrationV2ProjectedTurnItem; | |
| 182 | + } | |
| 172 | 183 | | { |
| 173 | 184 | kind: "proposed-plan"; |
| 174 | 185 | id: string; |
| @@ -238,8 +249,8 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli · 63 unmodified lines | ||
| 238 | 249 | continue; |
| 239 | 250 | } |
| 240 | 251 | |
| 241 | − const responseKey = message.turnId | |
| 242 | − ? `turn:${message.turnId}` | |
| 252 | + const responseKey = message.runId | |
| 253 | + ? `turn:${message.runId}` | |
| 243 | 254 | : `unkeyed:${nullTurnResponseIndex}`; |
| 244 | 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 | 261 | interface TurnFold { |
| 251 | − turnId: TurnId; | |
| 262 | + runId: RunId; | |
| 252 | 263 | anchorEntryId: string; |
| 253 | 264 | createdAt: string; |
| 254 | 265 | hiddenEntryIds: ReadonlySet<string>; |
| @@ -263,18 +274,16 @@ interface TurnFold { · 8 unmodified lines | ||
| 263 | 274 | * user sends a message, the previous turn is still the "active" one until the |
| 264 | 275 | * server creates the new turn, and folding must not flicker through that window. |
| 265 | 276 | */ |
| 266 | −function deriveUnsettledTurnId( | |
| 267 | − latestTurn: TimelineLatestTurn | null, | |
| 268 | − runningTurnId: TurnId | null, | |
| 269 | −): TurnId | null { | |
| 270 | − if (runningTurnId !== null) { | |
| 271 | − return runningTurnId; | |
| 272 | − } | |
| 273 | − if (!latestTurn) { | |
| 277 | +function deriveUnsettledRunId(latestRun: TimelineLatestRun | null): RunId | null { | |
| 278 | + if (!latestRun) { | |
| 274 | 279 | return null; |
| 275 | 280 | } |
| 276 | − const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running"; | |
| 277 | − return isSettled ? null : latestTurn.turnId; | |
| 281 | + const isSettled = | |
| 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 | 294 | function deriveTurnFolds(input: { |
| 286 | 295 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 287 | 296 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 288 | − latestTurn: TimelineLatestTurn | null; | |
| 289 | − unsettledTurnId: TurnId | null; | |
| 297 | + latestRun: TimelineLatestRun | null; | |
| 298 | + unsettledRunId: RunId | null; | |
| 290 | 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 | 312 | interface TurnGroup { |
| 292 | 313 | entries: Array<TimelineEntry>; |
| 293 | 314 | terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null; |
| @@ -300,7 +321,7 @@ function deriveTurnFolds(input: { · 6 unmodified lines | ||
| 300 | 321 | */ |
| 301 | 322 | startBoundary: string | null; |
| 302 | 323 | } |
| 303 | − const groupsByTurnId = new Map<TurnId, TurnGroup>(); | |
| 324 | + const groupsByRunId = new Map<RunId, TurnGroup>(); | |
| 304 | 325 | |
| 305 | 326 | let pendingUserBoundary: string | null = null; |
| 306 | 327 | for (const entry of input.timelineEntries) { |
| @@ -308,16 +329,16 @@ function deriveTurnFolds(input: { · 1 unmodified line | ||
| 308 | 329 | pendingUserBoundary = entry.message.createdAt; |
| 309 | 330 | continue; |
| 310 | 331 | } |
| 311 | − const turnId = | |
| 332 | + const runId = | |
| 312 | 333 | entry.kind === "message" && entry.message.role === "assistant" |
| 313 | − ? (entry.message.turnId ?? null) | |
| 334 | + ? (entry.message.runId ?? null) | |
| 314 | 335 | : entry.kind === "work" |
| 315 | − ? (entry.entry.turnId ?? null) | |
| 336 | + ? (entry.entry.runId ?? null) | |
| 316 | 337 | : null; |
| 317 | − if (!turnId) { | |
| 338 | + if (!runId) { | |
| 318 | 339 | continue; |
| 319 | 340 | } |
| 320 | − let group = groupsByTurnId.get(turnId); | |
| 341 | + let group = groupsByRunId.get(runId); | |
| 321 | 342 | if (!group) { |
| 322 | 343 | group = { |
| 323 | 344 | entries: [], |
| @@ -329,7 +350,7 @@ function deriveTurnFolds(input: { · 5 unmodified lines | ||
| 329 | 350 | startBoundary: pendingUserBoundary, |
| 330 | 351 | }; |
| 331 | 352 | pendingUserBoundary = null; |
| 332 | − groupsByTurnId.set(turnId, group); | |
| 353 | + groupsByRunId.set(runId, group); | |
| 333 | 354 | } |
| 334 | 355 | group.entries.push(entry); |
| 335 | 356 | if (entry.kind === "message") { |
| @@ -343,8 +364,8 @@ function deriveTurnFolds(input: { · 7 unmodified lines | ||
| 343 | 364 | } |
| 344 | 365 | |
| 345 | 366 | const foldsByAnchorEntryId = new Map<string, TurnFold>(); |
| 346 | − for (const [turnId, group] of groupsByTurnId) { | |
| 347 | − if (turnId === input.unsettledTurnId) { | |
| 367 | + for (const [runId, group] of groupsByRunId) { | |
| 368 | + if (runId === input.unsettledRunId || interruptedRunIds.has(runId)) { | |
| 348 | 369 | continue; |
| 349 | 370 | } |
| 350 | 371 | if (group.hasStreamingMessage) { |
| @@ -367,16 +388,14 @@ function deriveTurnFolds(input: { · 16 unmodified lines | ||
| 367 | 388 | } |
| 368 | 389 | |
| 369 | 390 | const isLatestInterruptedTurn = |
| 370 | − input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; | |
| 391 | + input.latestRun?.runId === runId && input.latestRun.status === "interrupted"; | |
| 371 | 392 | // A turn cut short by a steer leaves trailing work entries behind its |
| 372 | 393 | // terminal message — take whichever ended last. |
| 373 | 394 | const lastEntryEnd = |
| 374 | 395 | lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; |
| 375 | 396 | const elapsedMs = |
| 376 | − input.latestTurn?.turnId === turnId && | |
| 377 | − input.latestTurn.startedAt && | |
| 378 | − input.latestTurn.completedAt | |
| 379 | − ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt) | |
| 397 | + input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt | |
| 398 | + ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) | |
| 380 | 399 | : computeElapsedMs( |
| 381 | 400 | group.startBoundary ?? firstEntry.createdAt, |
| 382 | 401 | maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? |
| @@ -392,7 +411,7 @@ function deriveTurnFolds(input: { · 9 unmodified lines | ||
| 392 | 411 | : "Worked"; |
| 393 | 412 | |
| 394 | 413 | foldsByAnchorEntryId.set(firstEntry.id, { |
| 395 | − turnId, | |
| 414 | + runId, | |
| 396 | 415 | anchorEntryId: firstEntry.id, |
| 397 | 416 | createdAt: firstEntry.createdAt, |
| 398 | 417 | hiddenEntryIds, |
| @@ -404,10 +423,8 @@ function deriveTurnFolds(input: { · 5 unmodified lines | ||
| 404 | 423 | |
| 405 | 424 | export function deriveMessagesTimelineRows(input: { |
| 406 | 425 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 407 | − latestTurn?: TimelineLatestTurn | null; | |
| 408 | − runningTurnId?: TurnId | null; | |
| 409 | − expandedTurnIds?: ReadonlySet<TurnId>; | |
| 410 | − expandedWorkGroupIds?: ReadonlySet<string>; | |
| 426 | + latestRun?: TimelineLatestRun | null; | |
| 427 | + expandedRunIds?: ReadonlySet<RunId>; | |
| 411 | 428 | isWorking: boolean; |
| 412 | 429 | activeTurnStartedAt: string | null; |
| 413 | 430 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; |
| @@ -418,19 +435,16 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines | ||
| 418 | 435 | input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), |
| 419 | 436 | ); |
| 420 | 437 | const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); |
| 421 | − const unsettledTurnId = deriveUnsettledTurnId( | |
| 422 | − input.latestTurn ?? null, | |
| 423 | − input.runningTurnId ?? null, | |
| 424 | − ); | |
| 438 | + const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null); | |
| 425 | 439 | const foldsByAnchorEntryId = deriveTurnFolds({ |
| 426 | 440 | timelineEntries: input.timelineEntries, |
| 427 | 441 | terminalAssistantMessageIds, |
| 428 | − latestTurn: input.latestTurn ?? null, | |
| 429 | − unsettledTurnId, | |
| 442 | + latestRun: input.latestRun ?? null, | |
| 443 | + unsettledRunId, | |
| 430 | 444 | }); |
| 431 | 445 | const collapsedEntryIds = new Set<string>(); |
| 432 | 446 | for (const fold of foldsByAnchorEntryId.values()) { |
| 433 | − if (!input.expandedTurnIds?.has(fold.turnId)) { | |
| 447 | + if (!input.expandedRunIds?.has(fold.runId)) { | |
| 434 | 448 | for (const entryId of fold.hiddenEntryIds) { |
| 435 | 449 | collapsedEntryIds.add(entryId); |
| 436 | 450 | } |
| @@ -447,11 +461,11 @@ export function deriveMessagesTimelineRows(input: { · 10 unmodified lines | ||
| 447 | 461 | if (turnFold) { |
| 448 | 462 | nextRows.push({ |
| 449 | 463 | kind: "turn-fold", |
| 450 | − id: `turn-fold:${turnFold.turnId}`, | |
| 464 | + id: `turn-fold:${turnFold.runId}`, | |
| 451 | 465 | createdAt: turnFold.createdAt, |
| 452 | − turnId: turnFold.turnId, | |
| 466 | + runId: turnFold.runId, | |
| 453 | 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 | 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 | 554 | const assistantTurnStillInProgress = |
| 531 | 555 | timelineEntry.message.role === "assistant" && |
| 532 | − unsettledTurnId !== null && | |
| 533 | − timelineEntry.message.turnId === unsettledTurnId; | |
| 556 | + unsettledRunId !== null && | |
| 557 | + timelineEntry.message.runId === unsettledRunId; | |
| 534 | 558 | |
| 535 | 559 | const durationStart = |
| 536 | 560 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt; |
| @@ -610,6 +634,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 73 unmodified lines | ||
| 610 | 634 | case "proposed-plan": |
| 611 | 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 | 640 | case "work": |
| 614 | 641 | return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); |
| 615 | 642 | |
| @@ -176,8 +176,7 @@ function buildProps() { · 175 unmodified lines | ||
| 176 | 176 | activeTurnInProgress: false, |
| 177 | 177 | activeTurnStartedAt: null, |
| 178 | 178 | listRef: createRef<LegendListRef | null>(), |
| 179 | − latestTurn: null, | |
| 180 | − runningTurnId: null, | |
| 179 | + latestRun: null, | |
| 181 | 180 | turnDiffSummaryByAssistantMessageId: new Map(), |
| 182 | 181 | routeThreadKey: "environment-local:thread-1", |
| 183 | 182 | onOpenTurnDiff: () => {}, |
| @@ -214,7 +213,7 @@ function buildUserTimelineEntry(text: string) { · 30 unmodified lines | ||
| 214 | 213 | id: MessageId.make("message-1"), |
| 215 | 214 | role: "user" as const, |
| 216 | 215 | text, |
| 217 | − turnId: null, | |
| 216 | + runId: null, | |
| 218 | 217 | createdAt: MESSAGE_CREATED_AT, |
| 219 | 218 | updatedAt: MESSAGE_CREATED_AT, |
| 220 | 219 | streaming: false, |
| @@ -527,7 +526,116 @@ describe("MessagesTimeline", () => { · 306 unmodified lines | ||
| 527 | 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 | 639 | const markup = renderToStaticMarkup( |
| 532 | 640 | <MessagesTimeline |
| 533 | 641 | {...buildProps()} |
| @@ -541,6 +649,8 @@ describe("MessagesTimeline", () => { · 7 unmodified lines | ||
| 541 | 649 | createdAt: "2026-03-17T19:12:28.000Z", |
| 542 | 650 | label: "Updated files", |
| 543 | 651 | tone: "tool", |
| 652 | + itemType: "file_change", | |
| 653 | + toolLifecycleStatus: "completed", | |
| 544 | 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 | 686 | "</review_comment>", |
| 577 | 687 | ].join("\n"), |
| 578 | − turnId: null, | |
| 688 | + runId: null, | |
| 579 | 689 | createdAt: "2026-03-17T19:12:28.000Z", |
| 580 | 690 | updatedAt: "2026-03-17T19:12:28.000Z", |
| 581 | 691 | streaming: false, |
| @@ -614,7 +724,7 @@ describe("MessagesTimeline", () => { · 32 unmodified lines | ||
| 614 | 724 | "```", |
| 615 | 725 | "</review_comment>", |
| 616 | 726 | ].join("\n"), |
| 617 | − turnId: null, | |
| 727 | + runId: null, | |
| 618 | 728 | createdAt: "2026-03-17T19:12:28.000Z", |
| 619 | 729 | updatedAt: "2026-03-17T19:12:28.000Z", |
| 620 | 730 | streaming: false, |
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type EnvironmentId, |
| 3 | 3 | type MessageId, |
| 4 | + type OrchestrationV2TurnItem, | |
| 4 | 5 | type ScopedThreadRef, |
| 5 | 6 | type ServerProviderSkill, |
| 6 | − type TurnId, | |
| 7 | + type RunId, | |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 9 | 10 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| @@ -78,8 +79,7 @@ import { · 68 unmodified lines | ||
| 78 | 79 | resolveTimelineMinimapTopPercent, |
| 79 | 80 | type StableMessagesTimelineRowsState, |
| 80 | 81 | type MessagesTimelineRow, |
| 81 | − TIMELINE_MINIMAP_MIN_ITEMS, | |
| 82 | − type TimelineLatestTurn, | |
| 82 | + type TimelineLatestRun, | |
| 83 | 83 | } from "./MessagesTimeline.logic"; |
| 84 | 84 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 85 | 85 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| @@ -132,9 +132,8 @@ interface TimelineRowSharedState { · 46 unmodified lines | ||
| 132 | 132 | activeThreadEnvironmentId: EnvironmentId; |
| 133 | 133 | onRevertUserMessage: (messageId: MessageId) => void; |
| 134 | 134 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 135 | − onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 136 | − onToggleTurnFold: (turnId: TurnId) => void; | |
| 137 | − onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; | |
| 135 | + onOpenTurnDiff: (runId: RunId, filePath?: string) => void; | |
| 136 | + onToggleTurnFold: (runId: RunId) => void; | |
| 138 | 137 | } |
| 139 | 138 | |
| 140 | 139 | interface TimelineRowActivityState { |
| @@ -161,11 +160,10 @@ interface MessagesTimelineProps { · 20 unmodified lines | ||
| 161 | 160 | activeTurnStartedAt: string | null; |
| 162 | 161 | listRef: React.RefObject<LegendListRef | null>; |
| 163 | 162 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 164 | − latestTurn: TimelineLatestTurn | null; | |
| 165 | − runningTurnId: TurnId | null; | |
| 163 | + latestRun: TimelineLatestRun | null; | |
| 166 | 164 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; |
| 167 | 165 | routeThreadKey: string; |
| 168 | − onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 166 | + onOpenTurnDiff: (runId: RunId, filePath?: string) => void; | |
| 169 | 167 | revertTurnCountByUserMessageId: Map<MessageId, number>; |
| 170 | 168 | onRevertUserMessage: (messageId: MessageId) => void; |
| 171 | 169 | isRevertingCheckpoint: boolean; |
| @@ -196,8 +194,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 24 unmodified lines | ||
| 196 | 194 | activeTurnStartedAt, |
| 197 | 195 | listRef, |
| 198 | 196 | timelineEntries, |
| 199 | − latestTurn, | |
| 200 | − runningTurnId, | |
| 197 | + latestRun, | |
| 201 | 198 | turnDiffSummaryByAssistantMessageId, |
| 202 | 199 | routeThreadKey, |
| 203 | 200 | onOpenTurnDiff, |
| @@ -220,17 +217,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines | ||
| 220 | 217 | hideEmptyPlaceholder = false, |
| 221 | 218 | topFadeEnabled = false, |
| 222 | 219 | }: MessagesTimelineProps) { |
| 223 | − const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); | |
| 224 | − const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); | |
| 225 | − const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); | |
| 226 | − | |
| 227 | − const onToggleTurnFold = useCallback((turnId: TurnId) => { | |
| 228 | − setExpandedTurnIds((existing) => { | |
| 220 | + const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set()); | |
| 221 | + | |
| 222 | + // Toggling a fold inserts/removes rows between the fold row and the final | |
| 223 | + // message — everything above the trigger is unchanged, so the trigger stays | |
| 224 | + // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do | |
| 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 | 232 | const next = new Set(existing); |
| 230 | − if (next.has(turnId)) { | |
| 231 | − next.delete(turnId); | |
| 233 | + if (next.has(runId)) { | |
| 234 | + next.delete(runId); | |
| 232 | 235 | } else { |
| 233 | − next.add(turnId); | |
| 236 | + next.add(runId); | |
| 234 | 237 | } |
| 235 | 238 | return next; |
| 236 | 239 | }); |
| @@ -271,41 +274,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 34 unmodified lines | ||
| 271 | 274 | |
| 272 | 275 | // An in-session interrupt leaves its turn expanded so the user keeps their |
| 273 | 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 | 278 | useEffect(() => { |
| 276 | − const previous = previousLatestTurnRef.current; | |
| 277 | − previousLatestTurnRef.current = latestTurn; | |
| 278 | − if (!latestTurn || previous?.turnId === undefined) { | |
| 279 | + const previous = previousLatestRunRef.current; | |
| 280 | + previousLatestRunRef.current = latestRun; | |
| 281 | + if (!latestRun || previous?.runId === undefined) { | |
| 279 | 282 | return; |
| 280 | 283 | } |
| 281 | − if (latestTurn.turnId === previous.turnId) { | |
| 282 | − if (previous.state === "running" && latestTurn.state === "interrupted") { | |
| 283 | − setExpandedTurnIds((existing) => { | |
| 284 | + if (latestRun.runId === previous.runId) { | |
| 285 | + if (previous.status === "running" && latestRun.status === "interrupted") { | |
| 286 | + setExpandedRunIds((existing) => { | |
| 284 | 287 | const next = new Set(existing); |
| 285 | − next.add(latestTurn.turnId); | |
| 288 | + next.add(latestRun.runId); | |
| 286 | 289 | return next; |
| 287 | 290 | }); |
| 288 | 291 | } |
| 289 | 292 | return; |
| 290 | 293 | } |
| 291 | − setExpandedTurnIds((existing) => { | |
| 292 | − if (!existing.has(previous.turnId)) { | |
| 294 | + setExpandedRunIds((existing) => { | |
| 295 | + if (!existing.has(previous.runId)) { | |
| 293 | 296 | return existing; |
| 294 | 297 | } |
| 295 | 298 | const next = new Set(existing); |
| 296 | − next.delete(previous.turnId); | |
| 299 | + next.delete(previous.runId); | |
| 297 | 300 | return next; |
| 298 | 301 | }); |
| 299 | − }, [latestTurn]); | |
| 302 | + }, [latestRun]); | |
| 300 | 303 | |
| 301 | 304 | const rawRows = useMemo( |
| 302 | 305 | () => |
| 303 | 306 | deriveMessagesTimelineRows({ |
| 304 | 307 | timelineEntries, |
| 305 | − latestTurn, | |
| 306 | − runningTurnId, | |
| 307 | − expandedTurnIds, | |
| 308 | − expandedWorkGroupIds, | |
| 308 | + latestRun, | |
| 309 | + expandedRunIds, | |
| 309 | 310 | isWorking, |
| 310 | 311 | activeTurnStartedAt, |
| 311 | 312 | turnDiffSummaryByAssistantMessageId, |
| @@ -313,10 +314,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line | ||
| 313 | 314 | }), |
| 314 | 315 | [ |
| 315 | 316 | timelineEntries, |
| 316 | − latestTurn, | |
| 317 | − runningTurnId, | |
| 318 | − expandedTurnIds, | |
| 319 | − expandedWorkGroupIds, | |
| 317 | + latestRun, | |
| 318 | + expandedRunIds, | |
| 320 | 319 | isWorking, |
| 321 | 320 | activeTurnStartedAt, |
| 322 | 321 | turnDiffSummaryByAssistantMessageId, |
| @@ -843,7 +842,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 520 unmodified lines | ||
| 843 | 842 | // they sit closer to the work that follows them. |
| 844 | 843 | (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || |
| 845 | 844 | row.kind === "work" || |
| 846 | − row.kind === "work-toggle" | |
| 845 | + row.kind === "event" | |
| 847 | 846 | ? "pb-2" |
| 848 | 847 | : "pb-4", |
| 849 | 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 | 860 | <AssistantTimelineRow row={row} /> |
| 862 | 861 | ) : null} |
| 863 | 862 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 863 | + {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null} | |
| 864 | 864 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 865 | 865 | </div> |
| 866 | 866 | ); |
| @@ -1005,7 +1005,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- · 138 unmodified lines | ||
| 1005 | 1005 | type="button" |
| 1006 | 1006 | aria-expanded={row.expanded} |
| 1007 | 1007 | data-scroll-anchor-ignore |
| 1008 | − onClick={() => ctx.onToggleTurnFold(row.turnId)} | |
| 1008 | + onClick={() => ctx.onToggleTurnFold(row.runId)} | |
| 1009 | 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 | 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 | 1261 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1095 | 1262 | return ( |
| 1096 | 1263 | <div className="py-0.5 pl-1.5"> |
| @@ -1244,7 +1411,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( · 147 unmodified lines | ||
| 1244 | 1411 | turnSummary: TurnDiffSummary | undefined; |
| 1245 | 1412 | routeThreadKey: string; |
| 1246 | 1413 | resolvedTheme: "light" | "dark"; |
| 1247 | − onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 1414 | + onOpenTurnDiff: (runId: RunId, filePath?: string) => void; | |
| 1248 | 1415 | }) { |
| 1249 | 1416 | if (!turnSummary) return null; |
| 1250 | 1417 | const checkpointFiles = turnSummary.files; |
| @@ -1274,12 +1441,10 @@ function AssistantChangedFilesSectionInner({ · 23 unmodified lines | ||
| 1274 | 1441 | checkpointFiles: TurnDiffSummary["files"]; |
| 1275 | 1442 | routeThreadKey: string; |
| 1276 | 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); | |
| 1280 | − const isLatestTurn = activity.latestTurnId === turnSummary.turnId; | |
| 1281 | − const persistedExpanded = useUiStateStore( | |
| 1282 | − (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], | |
| 1446 | + const allDirectoriesExpanded = useUiStateStore( | |
| 1447 | + (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.runId] ?? true, | |
| 1283 | 1448 | ); |
| 1284 | 1449 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); |
| 1285 | 1450 | const [autoExpanded] = useState(() => |
| @@ -1289,19 +1454,46 @@ function AssistantChangedFilesSectionInner({ · 3 unmodified lines | ||
| 1289 | 1454 | const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); |
| 1290 | 1455 | |
| 1291 | 1456 | return ( |
| 1292 | − <ChangedFilesCard | |
| 1293 | − turnId={turnSummary.turnId} | |
| 1294 | − files={checkpointFiles} | |
| 1295 | − expanded={expanded} | |
| 1296 | − showCompactPreview={isLatestTurn} | |
| 1297 | − allDirectoriesExpanded={allDirectoriesExpanded} | |
| 1298 | − resolvedTheme={resolvedTheme} | |
| 1299 | − onExpandedChange={(nextExpanded) => | |
| 1300 | − setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) | |
| 1301 | − } | |
| 1302 | − onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} | |
| 1303 | − onOpenTurnDiff={onOpenTurnDiff} | |
| 1304 | − /> | |
| 1457 | + <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | |
| 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-['']"> | |
| 1459 | + <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 1460 | + <span>Changed files ({changedFileCountLabel})</span> | |
| 1461 | + {hasNonZeroStat(summaryStat) && ( | |
| 1462 | + <> | |
| 1463 | + <span className="mx-1">•</span> | |
| 1464 | + <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} /> | |
| 1465 | + </> | |
| 1466 | + )} | |
| 1467 | + </p> | |
| 1468 | + <div className="flex items-center gap-1.5"> | |
| 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 | 2042 | workspaceRoot: string | undefined, |
| 1851 | 2043 | ): string | null { |
| 1852 | 2044 | const blocks: string[] = []; |
| 1853 | − if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { | |
| 1854 | − blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); | |
| 2045 | + if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) { | |
| 2046 | + blocks.push(`Tool call\n${JSON.stringify(workEntry.toolData, null, 2)}`); | |
| 1855 | 2047 | } |
| 1856 | 2048 | const raw = workEntryRawCommand(workEntry); |
| 1857 | 2049 | if (raw?.trim()) { |
| @@ -1870,14 +2062,25 @@ function buildToolCallExpandedBody( · 12 unmodified lines | ||
| 1870 | 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 | 2079 | return blocks.length > 0 ? blocks.join("\n\n") : null; |
| 1874 | 2080 | } |
| 1875 | 2081 | |
| 1876 | 2082 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 1877 | − if ( | |
| 1878 | − workEntry.sourceActivityKind === "user-input.requested" || | |
| 1879 | − workEntry.sourceActivityKind === "user-input.resolved" | |
| 1880 | − ) { | |
| 2083 | + if (workEntry.itemType === "user_input_request") { | |
| 1881 | 2084 | return "message-circle"; |
| 1882 | 2085 | } |
| 1883 | 2086 | if (workEntry.requestKind === "command") return "terminal"; |
| @@ -1891,13 +2094,12 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { · 7 unmodified lines | ||
| 1891 | 2094 | return "square-pen"; |
| 1892 | 2095 | } |
| 1893 | 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 | 2099 | switch (workEntry.itemType) { |
| 1897 | − case "mcp_tool_call": | |
| 2100 | + case "dynamic_tool": | |
| 1898 | 2101 | return "wrench"; |
| 1899 | − case "dynamic_tool_call": | |
| 1900 | − case "collab_agent_tool_call": | |
| 2102 | + case "subagent": | |
| 1901 | 2103 | return "hammer"; |
| 1902 | 2104 | } |
| 1903 | 2105 | |
| @@ -1929,7 +2131,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 25 unmodified lines | ||
| 1929 | 2131 | const activity = use(TimelineRowActivityCtx); |
| 1930 | 2132 | const [expanded, setExpanded] = useState(false); |
| 1931 | 2133 | const iconConfig = workToneIcon(workEntry.tone); |
| 1932 | − const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; | |
| 2134 | + const showWarningIndicator = false; | |
| 1933 | 2135 | const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); |
| 1934 | 2136 | const heading = toolWorkEntryHeading(workEntry); |
| 1935 | 2137 | const rawPreview = workEntryPreview(workEntry, workspaceRoot); |
| @@ -1943,9 +2145,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 7 unmodified lines | ||
| 1943 | 2145 | const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); |
| 1944 | 2146 | const canExpand = expandedBody !== null; |
| 1945 | 2147 | const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); |
| 1946 | − const showDestructiveRowStyle = | |
| 1947 | − showFailedIndicator && | |
| 1948 | − (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); | |
| 2148 | + const showDestructiveRowStyle = showFailedIndicator && !workLogEntryIsToolLike(workEntry); | |
| 1949 | 2149 | const iconWrapperClass = cn( |
| 1950 | 2150 | "flex size-5 shrink-0 items-center justify-center", |
| 1951 | 2151 | showWarningIndicator |
| @@ -1988,6 +2188,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 36 unmodified lines | ||
| 1988 | 2188 | canExpand && |
| 1989 | 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 | 2193 | {...rowToggleProps} |
| 1992 | 2194 | > |
| 1993 | 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 | 2203 | <div className="min-w-0 flex-1 overflow-hidden"> |
| 2002 | 2204 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> |
| 2003 | 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 | 2212 | {preview && ( |
| 2005 | 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 | 24 | type SidebarProjectGroupingMode, |
| 25 | 25 | } from "@t3tools/contracts"; |
| 26 | 26 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 27 | +import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 27 | 28 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 28 | 29 | import { |
| 29 | 30 | isAtomCommandInterrupted, |
| @@ -2245,10 +2246,7 @@ export function ArchivedThreadsPanel() { · 2215 unmodified lines | ||
| 2245 | 2246 | ), |
| 2246 | 2247 | ); |
| 2247 | 2248 | const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => |
| 2248 | − snapshot.threads.map((thread) => ({ | |
| 2249 | − ...thread, | |
| 2250 | − environmentId, | |
| 2251 | − })), | |
| 2249 | + snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)), | |
| 2252 | 2250 | ); |
| 2253 | 2251 | |
| 2254 | 2252 | const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); |
| @@ -6,6 +6,10 @@ import { · 5 unmodified lines | ||
| 6 | 6 | ConnectionTargetStore, |
| 7 | 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| 8 | 8 | EnvironmentCacheStore, |
| 9 | + ORCHESTRATION_CACHE_SCHEMA_VERSION, | |
| 10 | + StoredOrchestrationShellSnapshot, | |
| 11 | + StoredOrchestrationThreadSnapshot, | |
| 12 | + decodeOrDiscardOrchestrationCache, | |
| 9 | 13 | registerConnectionInCatalog, |
| 10 | 14 | removeCatalogValue, |
| 11 | 15 | removeConnectionFromCatalog, |
| @@ -17,14 +21,7 @@ import { · 5 unmodified lines | ||
| 17 | 21 | CredentialStore, |
| 18 | 22 | ProfileStore, |
| 19 | 23 | } from "@t3tools/client-runtime/connection"; |
| 20 | −import { | |
| 21 | − EnvironmentId, | |
| 22 | − OrchestrationShellSnapshot, | |
| 23 | − OrchestrationThreadDetailSnapshot, | |
| 24 | − ServerConfig, | |
| 25 | − ThreadId, | |
| 26 | − VcsListRefsResult, | |
| 27 | −} from "@t3tools/contracts"; | |
| 24 | +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 28 | 25 | import * as Context from "effect/Context"; |
| 29 | 26 | import * as Effect from "effect/Effect"; |
| 30 | 27 | import * as Layer from "effect/Layer"; |
| @@ -41,23 +38,9 @@ const THREAD_STORE_NAME = "thread"; · 10 unmodified lines | ||
| 41 | 38 | const SERVER_CONFIG_STORE_NAME = "server-config"; |
| 42 | 39 | const VCS_REFS_STORE_NAME = "vcs-refs"; |
| 43 | 40 | const CATALOG_KEY = "document"; |
| 44 | −const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | |
| 45 | − | |
| 46 | −const StoredShellSnapshot = Schema.Struct({ | |
| 47 | − schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | |
| 48 | − environmentId: EnvironmentId, | |
| 49 | − snapshot: OrchestrationShellSnapshot, | |
| 50 | −}); | |
| 41 | +const StoredShellSnapshot = StoredOrchestrationShellSnapshot; | |
| 51 | 42 | const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); |
| 52 | −// v2 stores the snapshot sequence alongside the thread so a warm cache can | |
| 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 | −}); | |
| 43 | +const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; | |
| 61 | 44 | const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); |
| 62 | 45 | const StoredServerConfig = Schema.Struct({ |
| 63 | 46 | schemaVersion: Schema.Literal(1), |
| @@ -469,25 +452,24 @@ export const connectionStorageLayer = Layer.effectContext( · 405 unmodified lines | ||
| 469 | 452 | if (typeof raw !== "string") { |
| 470 | 453 | return Effect.succeed(Option.none()); |
| 471 | 454 | } |
| 472 | − return decodeStoredShellSnapshot(raw).pipe( | |
| 473 | − Effect.mapError((cause) => persistenceError("load-shell", cause)), | |
| 474 | − Effect.map((stored) => | |
| 475 | − stored.environmentId === environmentId | |
| 476 | − ? Option.some(stored.snapshot) | |
| 477 | − : Option.none(), | |
| 455 | + return decodeOrDiscardOrchestrationCache( | |
| 456 | + decodeStoredShellSnapshot(raw).pipe( | |
| 457 | + Effect.mapError((cause) => persistenceError("load-shell", cause)), | |
| 458 | + Effect.map((stored) => | |
| 459 | + stored.environmentId === environmentId | |
| 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) => | |
| 482 | − cause._tag === "ConnectionPersistenceError" | |
| 483 | − ? cause | |
| 484 | − : persistenceError("load-shell", cause), | |
| 485 | − ), | |
| 467 | + Effect.mapError((cause) => persistenceError("load-shell", cause)), | |
| 486 | 468 | ), |
| 487 | 469 | saveShell: (environmentId, snapshot) => |
| 488 | 470 | Effect.gen(function* () { |
| 489 | 471 | const encoded = yield* encodeStoredShellSnapshot({ |
| 490 | − schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | |
| 472 | + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | |
| 491 | 473 | environmentId, |
| 492 | 474 | snapshot, |
| 493 | 475 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); |
| @@ -543,33 +525,36 @@ export const connectionStorageLayer = Layer.effectContext( · 49 unmodified lines | ||
| 543 | 525 | if (typeof raw !== "string") { |
| 544 | 526 | return Effect.succeed(Option.none()); |
| 545 | 527 | } |
| 546 | − return decodeStoredThreadSnapshot(raw).pipe( | |
| 547 | − Effect.mapError((cause) => persistenceError("load-thread", cause)), | |
| 548 | − Effect.map((stored) => | |
| 549 | − stored.environmentId === environmentId && stored.threadId === threadId | |
| 550 | − ? Option.some(stored.snapshot) | |
| 551 | − : Option.none(), | |
| 528 | + return decodeOrDiscardOrchestrationCache( | |
| 529 | + decodeStoredThreadSnapshot(raw).pipe( | |
| 530 | + Effect.mapError((cause) => persistenceError("load-thread", cause)), | |
| 531 | + Effect.map((stored) => | |
| 532 | + stored.environmentId === environmentId && stored.threadId === threadId | |
| 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) => | |
| 556 | − cause._tag === "ConnectionPersistenceError" | |
| 557 | − ? cause | |
| 558 | − : persistenceError("load-thread", cause), | |
| 559 | − ), | |
| 544 | + Effect.mapError((cause) => persistenceError("load-thread", cause)), | |
| 560 | 545 | ), |
| 561 | 546 | saveThread: (environmentId, snapshot) => |
| 562 | 547 | Effect.gen(function* () { |
| 563 | 548 | const encoded = yield* encodeStoredThreadSnapshot({ |
| 564 | − schemaVersion: 2, | |
| 549 | + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | |
| 565 | 550 | environmentId, |
| 566 | − threadId: snapshot.thread.id, | |
| 567 | − snapshot, | |
| 551 | + threadId: thread.thread.id, | |
| 552 | + thread, | |
| 568 | 553 | }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); |
| 569 | 554 | yield* writeDatabaseValue( |
| 570 | 555 | database, |
| 571 | 556 | THREAD_STORE_NAME, |
| 572 | − threadCacheKey(environmentId, snapshot.thread.id), | |
| 557 | + threadCacheKey(environmentId, thread.thread.id), | |
| 573 | 558 | encoded, |
| 574 | 559 | ); |
| 575 | 560 | }).pipe( |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | 5 | import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore"; |
| @@ -31,7 +31,7 @@ describe("diffPanelStore", () => { · 25 unmodified lines | ||
| 31 | 31 | |
| 32 | 32 | it("clears incompatible selection fields when changing scopes", () => { |
| 33 | 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 | 35 | store.selectGitScope(THREAD_REF, "unstaged"); |
| 36 | 36 | |
| 37 | 37 | expect( |
| @@ -45,7 +45,7 @@ describe("diffPanelStore", () => { · 7 unmodified lines | ||
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | 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 | 49 | useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); |
| 50 | 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 | 67 | it("reconciles a missing turn selection to the latest available turn", () => { |
| 68 | − const missingTurnId = TurnId.make("turn-missing"); | |
| 69 | − const latestTurnId = TurnId.make("turn-latest"); | |
| 68 | + const missingTurnId = RunId.make("turn-missing"); | |
| 69 | + const latestTurnId = RunId.make("turn-latest"); | |
| 70 | 70 | useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts"); |
| 71 | 71 | useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]); |
| 72 | 72 | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 3 | import { create } from "zustand"; |
| 4 | 4 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 5 | 5 | |
| @@ -8,7 +8,7 @@ import { resolveStorage } from "./lib/storage"; · 2 unmodified lines | ||
| 8 | 8 | export type DiffPanelSelection = |
| 9 | 9 | | { kind: "branch"; baseRef: string | null } |
| 10 | 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 | 13 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; |
| 14 | 14 | const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; |
| @@ -18,8 +18,8 @@ interface DiffPanelStoreState { · 3 unmodified lines | ||
| 18 | 18 | branchBaseRefByThreadKey: Record<string, string | null>; |
| 19 | 19 | selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; |
| 20 | 20 | selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; |
| 21 | − selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; | |
| 22 | − reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<TurnId>) => void; | |
| 21 | + selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; | |
| 22 | + reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<RunId>) => void; | |
| 23 | 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 | 121 | name: "t3code:diff-panel-state:v1", |
| 122 | − version: 1, | |
| 122 | + version: 2, | |
| 123 | 123 | storage: createJSONStorage(() => |
| 124 | 124 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), |
| 125 | 125 | ), |
| @@ -14,7 +14,7 @@ describe("buildBootstrapInput", () => { · 13 unmodified lines | ||
| 14 | 14 | role: "user", |
| 15 | 15 | text: "hello", |
| 16 | 16 | createdAt: "2026-02-09T00:00:00.000Z", |
| 17 | − turnId: null, | |
| 17 | + runId: null, | |
| 18 | 18 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 19 | 19 | streaming: false, |
| 20 | 20 | }, |
| @@ -23,7 +23,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines | ||
| 23 | 23 | role: "assistant", |
| 24 | 24 | text: "world", |
| 25 | 25 | createdAt: "2026-02-09T00:00:01.000Z", |
| 26 | − turnId: null, | |
| 26 | + runId: null, | |
| 27 | 27 | updatedAt: "2026-02-09T00:00:01.000Z", |
| 28 | 28 | streaming: false, |
| 29 | 29 | }, |
| @@ -49,7 +49,7 @@ describe("buildBootstrapInput", () => { · 19 unmodified lines | ||
| 49 | 49 | role: "user", |
| 50 | 50 | text: "first question with details", |
| 51 | 51 | createdAt: "2026-02-09T00:00:00.000Z", |
| 52 | − turnId: null, | |
| 52 | + runId: null, | |
| 53 | 53 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 54 | 54 | streaming: false, |
| 55 | 55 | }, |
| @@ -58,7 +58,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines | ||
| 58 | 58 | role: "assistant", |
| 59 | 59 | text: "first answer with details", |
| 60 | 60 | createdAt: "2026-02-09T00:00:01.000Z", |
| 61 | − turnId: null, | |
| 61 | + runId: null, | |
| 62 | 62 | updatedAt: "2026-02-09T00:00:01.000Z", |
| 63 | 63 | streaming: false, |
| 64 | 64 | }, |
| @@ -67,7 +67,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines | ||
| 67 | 67 | role: "user", |
| 68 | 68 | text: "second question with details", |
| 69 | 69 | createdAt: "2026-02-09T00:00:02.000Z", |
| 70 | − turnId: null, | |
| 70 | + runId: null, | |
| 71 | 71 | updatedAt: "2026-02-09T00:00:02.000Z", |
| 72 | 72 | streaming: false, |
| 73 | 73 | }, |
| @@ -92,7 +92,7 @@ describe("buildBootstrapInput", () => { · 18 unmodified lines | ||
| 92 | 92 | role: "user", |
| 93 | 93 | text: "old context", |
| 94 | 94 | createdAt: "2026-02-09T00:00:00.000Z", |
| 95 | − turnId: null, | |
| 95 | + runId: null, | |
| 96 | 96 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 97 | 97 | streaming: false, |
| 98 | 98 | }, |
| @@ -124,7 +124,7 @@ describe("buildBootstrapInput", () => { · 25 unmodified lines | ||
| 124 | 124 | }, |
| 125 | 125 | ], |
| 126 | 126 | createdAt: "2026-02-09T00:00:00.000Z", |
| 127 | − turnId: null, | |
| 127 | + runId: null, | |
| 128 | 128 | updatedAt: "2026-02-09T00:00:00.000Z", |
| 129 | 129 | streaming: false, |
| 130 | 130 | }, |
| @@ -160,7 +160,7 @@ export function useThreadActions() { · 159 unmodified lines | ||
| 160 | 160 | const resolved = resolveThreadTarget(target); |
| 161 | 161 | if (!resolved) return AsyncResult.success(undefined); |
| 162 | 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 | 164 | return AsyncResult.failure( |
| 165 | 165 | Cause.fail( |
| 166 | 166 | new ThreadArchiveBlockedError({ |
| @@ -277,7 +277,7 @@ export function useThreadActions() { · 110 unmodified lines | ||
| 277 | 277 | shouldDeleteWorktree = confirmationResult.value; |
| 278 | 278 | } |
| 279 | 279 | |
| 280 | − if (thread.session && thread.session.status !== "stopped") { | |
| 280 | + if (thread.runtime !== null) { | |
| 281 | 281 | await stopThreadSession({ |
| 282 | 282 | environmentId: threadRef.environmentId, |
| 283 | 283 | input: { threadId: threadRef.threadId }, |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { useMemo } from "react"; |
| 2 | −import { inferCheckpointTurnCountByTurnId } from "../session-logic"; | |
| 2 | +import { inferCheckpointTurnCountByRunId } from "../session-logic"; | |
| 3 | 3 | import type { Thread, TurnDiffSummary } from "../types"; |
| 4 | 4 | |
| 5 | 5 | export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { |
| @@ -10,10 +10,10 @@ export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { · 4 unmodified lines | ||
| 10 | 10 | return activeThread.checkpoints; |
| 11 | 11 | }, [activeThread]); |
| 12 | 12 | |
| 13 | − const inferredCheckpointTurnCountByTurnId = useMemo( | |
| 14 | − () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | |
| 13 | + const inferredCheckpointTurnCountByRunId = useMemo( | |
| 14 | + () => inferCheckpointTurnCountByRunId(turnDiffSummaries), | |
| 15 | 15 | [turnDiffSummaries], |
| 16 | 16 | ); |
| 17 | 17 | |
| 18 | − return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId }; | |
| 18 | + return { turnDiffSummaries, inferredCheckpointTurnCountByRunId }; | |
| 19 | 19 | } |
| @@ -1,84 +1,44 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | −import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; | |
| 3 | − | |
| 4 | 2 | import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; |
| 5 | 3 | |
| 6 | −function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity { | |
| 7 | − return { | |
| 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", () => { | |
| 4 | +describe("V2 context window presentation", () => { | |
| 5 | + it("uses retained compaction token data when available", () => { | |
| 48 | 6 | const snapshot = deriveLatestContextWindowSnapshot([ |
| 49 | − makeActivity("activity-1", "context-window.updated", { | |
| 50 | − usedTokens: 0, | |
| 51 | − maxTokens: 100_000, | |
| 52 | − }), | |
| 7 | + { | |
| 8 | + id: "compaction-1", | |
| 9 | + createdAt: "2026-06-20T00:00:00.000Z", | |
| 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 | − | |
| 55 | − expect(snapshot).toMatchObject({ | |
| 56 | − usedTokens: 0, | |
| 57 | − maxTokens: 100_000, | |
| 58 | − remainingTokens: 100_000, | |
| 59 | − usedPercentage: 0, | |
| 60 | − remainingPercentage: 100, | |
| 61 | − }); | |
| 37 | + expect(snapshot?.usedTokens).toBe(2_000); | |
| 38 | + expect(snapshot?.totalProcessedTokens).toBe(10_000); | |
| 62 | 39 | }); |
| 63 | 40 | |
| 64 | − it("formats compact token counts", () => { | |
| 65 | − expect(formatContextWindowTokens(999)).toBe("999"); | |
| 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); | |
| 41 | + it("formats compact token values", () => { | |
| 42 | + expect(formatContextWindowTokens(1_500)).toBe("1.5k"); | |
| 83 | 43 | }); |
| 84 | 44 | }); |
| @@ -1,17 +1,10 @@ | ||
| 1 | −import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; | |
| 2 | − | |
| 3 | −function asRecord(value: unknown): Record<string, unknown> | null { | |
| 4 | − return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | |
| 5 | −} | |
| 1 | +import type { ThreadTokenUsageSnapshot } from "@t3tools/contracts"; | |
| 2 | +import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell"; | |
| 6 | 3 | |
| 7 | 4 | function asFiniteNumber(value: unknown): number | null { |
| 8 | 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 | 8 | type NullableContextWindowUsage = { |
| 16 | 9 | readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] |
| 17 | 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 | 43 | export function deriveLatestContextWindowSnapshot( |
| 51 | − activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 44 | + entries: ReadonlyArray<ThreadWorkEntry>, | |
| 52 | 45 | ): ContextWindowSnapshot | null { |
| 53 | − for (let index = activities.length - 1; index >= 0; index -= 1) { | |
| 54 | − const activity = activities[index]; | |
| 55 | − if (!activity || activity.kind !== "context-window.updated") { | |
| 46 | + for (let index = entries.length - 1; index >= 0; index -= 1) { | |
| 47 | + const entry = entries[index]; | |
| 48 | + if (!entry || entry.structuredPayload.type !== "compaction") { | |
| 56 | 49 | continue; |
| 57 | 50 | } |
| 58 | − | |
| 59 | − const payload = asRecord(activity.payload); | |
| 60 | − const usedTokens = asFiniteNumber(payload?.usedTokens); | |
| 51 | + const payload = entry.structuredPayload; | |
| 52 | + const usedTokens = asFiniteNumber(payload.afterTokenCount); | |
| 61 | 53 | if (usedTokens === null || usedTokens < 0) { |
| 62 | 54 | continue; |
| 63 | 55 | } |
| 64 | 56 | |
| 65 | − const maxTokens = asFiniteNumber(payload?.maxTokens); | |
| 57 | + const maxTokens = null; | |
| 66 | 58 | const usedPercentage = |
| 67 | 59 | maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; |
| 68 | 60 | const remainingTokens = |
| @@ -71,24 +63,24 @@ export function deriveLatestContextWindowSnapshot( · 2 unmodified lines | ||
| 71 | 63 | |
| 72 | 64 | return { |
| 73 | 65 | usedTokens, |
| 74 | − totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens), | |
| 66 | + totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount), | |
| 75 | 67 | maxTokens, |
| 76 | 68 | remainingTokens, |
| 77 | 69 | usedPercentage, |
| 78 | 70 | remainingPercentage, |
| 79 | − inputTokens: asFiniteNumber(payload?.inputTokens), | |
| 80 | − cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens), | |
| 81 | − outputTokens: asFiniteNumber(payload?.outputTokens), | |
| 82 | − reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens), | |
| 83 | − lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens), | |
| 84 | − lastInputTokens: asFiniteNumber(payload?.lastInputTokens), | |
| 85 | − lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens), | |
| 86 | − lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens), | |
| 87 | − lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens), | |
| 88 | − toolUses: asFiniteNumber(payload?.toolUses), | |
| 89 | − durationMs: asFiniteNumber(payload?.durationMs), | |
| 90 | − compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, | |
| 91 | − updatedAt: activity.createdAt, | |
| 71 | + inputTokens: null, | |
| 72 | + cachedInputTokens: null, | |
| 73 | + outputTokens: null, | |
| 74 | + reasoningOutputTokens: null, | |
| 75 | + lastUsedTokens: null, | |
| 76 | + lastInputTokens: null, | |
| 77 | + lastCachedInputTokens: null, | |
| 78 | + lastOutputTokens: null, | |
| 79 | + lastReasoningOutputTokens: null, | |
| 80 | + toolUses: null, | |
| 81 | + durationMs: null, | |
| 82 | + compactsAutomatically: true, | |
| 83 | + updatedAt: entry.createdAt, | |
| 92 | 84 | }; |
| 93 | 85 | } |
| 94 | 86 | |
| @@ -7,13 +7,14 @@ import { · 6 unmodified lines | ||
| 7 | 7 | ThreadId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import type { Thread } from "../types"; |
| 10 | +import { makeThreadFixture } from "../test-fixtures"; | |
| 10 | 11 | import { getLatestThreadForProject, sortThreads } from "./threadSort"; |
| 11 | 12 | |
| 12 | 13 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 13 | 14 | const PROJECT_ID = ProjectId.make("project-1"); |
| 14 | 15 | |
| 15 | 16 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 16 | − return { | |
| 17 | + return makeThreadFixture({ | |
| 17 | 18 | id: ThreadId.make("thread-1"), |
| 18 | 19 | environmentId: LOCAL_ENVIRONMENT_ID, |
| 19 | 20 | projectId: PROJECT_ID, |
| @@ -21,7 +22,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line | ||
| 21 | 22 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 22 | 23 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 23 | 24 | interactionMode: "default", |
| 24 | − session: null, | |
| 25 | + runtime: null, | |
| 25 | 26 | messages: [], |
| 26 | 27 | proposedPlans: [], |
| 27 | 28 | createdAt: "2026-03-09T10:00:00.000Z", |
| @@ -30,13 +31,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines | ||
| 30 | 31 | settledAt: null, |
| 31 | 32 | deletedAt: null, |
| 32 | 33 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 33 | − latestTurn: null, | |
| 34 | + latestRun: null, | |
| 34 | 35 | branch: null, |
| 35 | 36 | worktreePath: null, |
| 36 | − checkpoints: [], | |
| 37 | − activities: [], | |
| 38 | 37 | ...overrides, |
| 39 | − }; | |
| 38 | + }); | |
| 40 | 39 | } |
| 41 | 40 | |
| 42 | 41 | describe("sortThreads", () => { |
| @@ -51,7 +50,7 @@ describe("sortThreads", () => { · 8 unmodified lines | ||
| 51 | 50 | id: "message-1" as never, |
| 52 | 51 | role: "user", |
| 53 | 52 | text: "older", |
| 54 | − turnId: null, | |
| 53 | + runId: null, | |
| 55 | 54 | createdAt: "2026-03-09T10:01:00.000Z", |
| 56 | 55 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 57 | 56 | streaming: false, |
| @@ -67,7 +66,7 @@ describe("sortThreads", () => { · 9 unmodified lines | ||
| 67 | 66 | id: "message-2" as never, |
| 68 | 67 | role: "user", |
| 69 | 68 | text: "newer", |
| 70 | − turnId: null, | |
| 69 | + runId: null, | |
| 71 | 70 | createdAt: "2026-03-09T10:06:00.000Z", |
| 72 | 71 | updatedAt: "2026-03-09T10:06:00.000Z", |
| 73 | 72 | streaming: false, |
| @@ -95,7 +94,7 @@ describe("sortThreads", () => { · 21 unmodified lines | ||
| 95 | 94 | id: "message-1" as never, |
| 96 | 95 | role: "assistant", |
| 97 | 96 | text: "assistant only", |
| 98 | − turnId: null, | |
| 97 | + runId: null, | |
| 99 | 98 | createdAt: "2026-03-09T10:02:00.000Z", |
| 100 | 99 | updatedAt: "2026-03-09T10:02:00.000Z", |
| 101 | 100 | streaming: false, |
| @@ -1,1688 +1,159 @@ | ||
| 1 | 1 | import { |
| 2 | − EventId, | |
| 3 | 2 | MessageId, |
| 3 | + PlanId, | |
| 4 | + RunId, | |
| 4 | 5 | ThreadId, |
| 5 | − TurnId, | |
| 6 | − type OrchestrationThreadActivity, | |
| 6 | + TurnItemId, | |
| 7 | + type OrchestrationV2ProjectedTurnItem, | |
| 8 | + type OrchestrationV2TurnItem, | |
| 7 | 9 | } from "@t3tools/contracts"; |
| 10 | +import * as DateTime from "effect/DateTime"; | |
| 8 | 11 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | 12 | |
| 10 | 13 | import { |
| 11 | − deriveActiveWorkStartedAt, | |
| 12 | − deriveActivePlanState, | |
| 13 | − derivePendingApprovals, | |
| 14 | − derivePendingUserInputs, | |
| 15 | 14 | deriveTimelineEntries, |
| 16 | − deriveWorkLogEntries, | |
| 15 | + deriveTimelineEntriesFromVisibleTurnItems, | |
| 17 | 16 | findLatestProposedPlan, |
| 18 | − findSidebarProposedPlan, | |
| 19 | − hasActionableProposedPlan, | |
| 20 | − isLatestTurnSettled, | |
| 21 | − workEntryIndicatesToolFailure, | |
| 22 | − workEntryIndicatesToolNeutralStatus, | |
| 23 | − workEntryIndicatesToolSuccess, | |
| 17 | + isLatestRunSettled, | |
| 24 | 18 | } from "./session-logic"; |
| 25 | 19 | |
| 26 | −let nextActivityId = 0; | |
| 27 | − | |
| 28 | −function makeActivity(overrides: { | |
| 29 | − id?: string; | |
| 30 | − createdAt?: string; | |
| 31 | − kind?: string; | |
| 32 | − summary?: string; | |
| 33 | − tone?: OrchestrationThreadActivity["tone"]; | |
| 34 | − payload?: Record<string, unknown>; | |
| 35 | − turnId?: string; | |
| 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" }], | |
| 20 | +describe("V2 session presentation", () => { | |
| 21 | + it("uses run status as the settlement boundary", () => { | |
| 22 | + const runId = RunId.make("run-1"); | |
| 23 | + expect( | |
| 24 | + isLatestRunSettled( | |
| 25 | + { | |
| 26 | + runId, | |
| 27 | + status: "completed", | |
| 28 | + startedAt: "2026-06-20T00:00:00.000Z", | |
| 29 | + completedAt: "2026-06-20T00:01:00.000Z", | |
| 360 | 30 | }, |
| 361 | − }), | |
| 362 | − ]; | |
| 363 | − | |
| 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", () => { | |
| 31 | + null, | |
| 32 | + ), | |
| 33 | + ).toBe(true); | |
| 376 | 34 | expect( |
| 377 | − findLatestProposedPlan( | |
| 378 | − [ | |
| 379 | − { | |
| 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"), | |
| 35 | + isLatestRunSettled( | |
| 36 | + { runId, status: "running", startedAt: null, completedAt: null }, | |
| 37 | + { status: "running", activeRunId: runId }, | |
| 408 | 38 | ), |
| 409 | − ).toEqual({ | |
| 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 | − }); | |
| 39 | + ).toBe(false); | |
| 418 | 40 | }); |
| 419 | 41 | |
| 420 | − it("falls back to the most recently updated proposed plan", () => { | |
| 421 | − const latestPlan = findLatestProposedPlan( | |
| 42 | + it("selects the latest proposed plan for a run", () => { | |
| 43 | + const runId = RunId.make("run-1"); | |
| 44 | + const plan = findLatestProposedPlan( | |
| 422 | 45 | [ |
| 423 | 46 | { |
| 424 | − id: "plan:thread-1:turn:turn-1", | |
| 425 | − turnId: TurnId.make("turn-1"), | |
| 426 | − planMarkdown: "# First", | |
| 47 | + id: PlanId.make("plan-1"), | |
| 48 | + runId, | |
| 49 | + planMarkdown: "Plan", | |
| 50 | + status: "active", | |
| 427 | 51 | implementedAt: null, |
| 428 | − implementationThreadId: null, | |
| 429 | − createdAt: "2026-02-23T00:00:01.000Z", | |
| 430 | − updatedAt: "2026-02-23T00: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", | |
| 52 | + implementationThreadId: ThreadId.make("thread-implementation"), | |
| 53 | + createdAt: "2026-06-20T00:00:00.000Z", | |
| 54 | + updatedAt: "2026-06-20T00:00:01.000Z", | |
| 440 | 55 | }, |
| 441 | 56 | ], |
| 442 | − null, | |
| 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()" .'`, | |
| 57 | + runId, | |
| 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", () => { | |
| 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", () => { | |
| 62 | + it("orders conversation and generic V2 work entries", () => { | |
| 1497 | 63 | const entries = deriveTimelineEntries( |
| 1498 | 64 | [ |
| 1499 | 65 | { |
| 1500 | − id: MessageId.make("message-1"), | |
| 1501 | − role: "assistant", | |
| 1502 | − text: "hello", | |
| 1503 | − createdAt: "2026-02-23T00:00:01.000Z", | |
| 1504 | − turnId: null, | |
| 1505 | − updatedAt: "2026-02-23T00:00:01.000Z", | |
| 66 | + id: "message-1" as never, | |
| 67 | + role: "user", | |
| 68 | + text: "Hello", | |
| 69 | + runId: null, | |
| 1506 | 70 | streaming: false, |
| 71 | + createdAt: "2026-06-20T00:00:00.000Z", | |
| 72 | + updatedAt: "2026-06-20T00:00:00.000Z", | |
| 1507 | 73 | }, |
| 1508 | 74 | ], |
| 1509 | − [ | |
| 1510 | − { | |
| 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 | − ], | |
| 75 | + [], | |
| 76 | + [], | |
| 1528 | 77 | ); |
| 1529 | − | |
| 1530 | − expect(entries.map((entry) => entry.kind)).toEqual(["message", "proposed-plan", "work"]); | |
| 1531 | − expect(entries[1]).toMatchObject({ | |
| 1532 | − kind: "proposed-plan", | |
| 1533 | − proposedPlan: { | |
| 1534 | − planMarkdown: "# Ship it", | |
| 1535 | − implementedAt: null, | |
| 1536 | − implementationThreadId: null, | |
| 1537 | − }, | |
| 78 | + expect(entries.map((entry) => entry.kind)).toEqual(["message"]); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it("uses visible turn item order and keeps interruption lifecycle entries standalone", () => { | |
| 82 | + const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); | |
| 83 | + const threadId = ThreadId.make("thread-visible"); | |
| 84 | + const runId = RunId.make("run-visible"); | |
| 85 | + const base = (id: string, ordinal: number) => ({ | |
| 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", () => { | |
| 1566 | − const entries = deriveWorkLogEntries([ | |
| 1567 | − makeActivity({ | |
| 1568 | − id: "compaction-1", | |
| 1569 | − turnId: "turn-1", | |
| 1570 | − kind: "context-compaction", | |
| 1571 | − summary: "Context compacted", | |
| 1572 | − tone: "info", | |
| 1573 | − }), | |
| 146 | + expect(entries.map((entry) => [entry.kind, entry.id])).toEqual([ | |
| 147 | + ["message", userItem.messageId], | |
| 148 | + ["event", requestItem.id], | |
| 149 | + ["work", commandItem.id], | |
| 150 | + ["event", resultItem.id], | |
| 1574 | 151 | ]); |
| 1575 | − | |
| 1576 | − expect(entries).toHaveLength(1); | |
| 1577 | − expect(entries[0]?.label).toBe("Context compacted"); | |
| 1578 | − }); | |
| 1579 | −}); | |
| 1580 | − | |
| 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"); | |
| 152 | + const commandEntry = entries[2]; | |
| 153 | + expect(commandEntry?.kind).toBe("work"); | |
| 154 | + if (commandEntry?.kind === "work") { | |
| 155 | + expect(commandEntry.entry.projectedItem).toBe(visibleTurnItems[2]); | |
| 156 | + expect(commandEntry.entry.structuredPayload).toBe(commandItem); | |
| 157 | + } | |
| 1687 | 158 | }); |
| 1688 | 159 | }); |
| @@ -1,26 +1,24 @@ | ||
| 1 | −import * as Option from "effect/Option"; | |
| 2 | −import * as Arr from "effect/Array"; | |
| 3 | 1 | import { |
| 4 | − ApprovalRequestId, | |
| 5 | − isToolLifecycleItemType, | |
| 6 | − type OrchestrationLatestTurn, | |
| 7 | − type OrchestrationThreadActivity, | |
| 8 | − type OrchestrationProposedPlanId, | |
| 9 | 2 | ProviderDriverKind, |
| 10 | − type ToolLifecycleItemType, | |
| 11 | − type UserInputQuestion, | |
| 3 | + type OrchestrationV2ProjectedTurnItem, | |
| 4 | + type OrchestrationV2TurnItem, | |
| 5 | + type PlanId, | |
| 6 | + type RunId, | |
| 12 | 7 | type ThreadId, |
| 13 | − type TurnId, | |
| 14 | 8 | } from "@t3tools/contracts"; |
| 15 | − | |
| 16 | 9 | import type { |
| 17 | − ChatMessage, | |
| 18 | − ProposedPlan, | |
| 19 | − SessionPhase, | |
| 20 | − Thread, | |
| 21 | − ThreadSession, | |
| 22 | − TurnDiffSummary, | |
| 23 | −} from "./types"; | |
| 10 | + ThreadCheckpointSummary, | |
| 11 | + ThreadPendingApproval, | |
| 12 | + ThreadPendingUserInput, | |
| 13 | + ThreadProposedPlan, | |
| 14 | + ThreadRunSummary, | |
| 15 | + ThreadRuntimeSummary, | |
| 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 | 23 | export type ProviderPickerKind = ProviderDriverKind; |
| 26 | 24 | |
| @@ -28,7 +26,6 @@ export const PROVIDER_OPTIONS: Array<{ · 1 unmodified line | ||
| 28 | 26 | value: ProviderPickerKind; |
| 29 | 27 | label: string; |
| 30 | 28 | available: boolean; |
| 31 | − /** Shown on the model picker sidebar when relevant */ | |
| 32 | 29 | pickerSidebarBadge?: "new" | "soon"; |
| 33 | 30 | }> = [ |
| 34 | 31 | { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, |
| @@ -45,221 +42,101 @@ export const PROVIDER_OPTIONS: Array<{ · 10 unmodified lines | ||
| 45 | 42 | available: true, |
| 46 | 43 | pickerSidebarBadge: "new", |
| 47 | 44 | }, |
| 48 | − { | |
| 49 | − value: ProviderDriverKind.make("grok"), | |
| 50 | − label: "Grok", | |
| 51 | − available: true, | |
| 52 | − pickerSidebarBadge: "new", | |
| 53 | − }, | |
| 45 | + { value: ProviderDriverKind.make("grok"), label: "Grok", available: true }, | |
| 54 | 46 | ]; |
| 55 | 47 | |
| 56 | −export type WorkLogToolLifecycleStatus = | |
| 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 | −} | |
| 48 | +export type WorkLogToolLifecycleStatus = ThreadWorkEntry["toolLifecycleStatus"]; | |
| 88 | 49 | |
| 89 | −export interface PendingApproval { | |
| 90 | − requestId: ApprovalRequestId; | |
| 91 | − requestKind: "command" | "file-read" | "file-change"; | |
| 92 | − createdAt: string; | |
| 93 | − detail?: string; | |
| 50 | +export interface WorkLogEntry extends Omit< | |
| 51 | + ThreadWorkEntry, | |
| 52 | + "structuredPayload" | "runId" | "itemType" | "toolLifecycleStatus" | |
| 53 | +> { | |
| 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 { | |
| 97 | − requestId: ApprovalRequestId; | |
| 98 | − createdAt: string; | |
| 99 | − questions: ReadonlyArray<UserInputQuestion>; | |
| 100 | −} | |
| 62 | +export type PendingApproval = ThreadPendingApproval; | |
| 63 | +export type PendingUserInput = ThreadPendingUserInput; | |
| 101 | 64 | |
| 102 | 65 | export interface ActivePlanState { |
| 103 | − createdAt: string; | |
| 104 | − turnId: TurnId | null; | |
| 105 | − explanation?: string | null; | |
| 106 | − steps: Array<{ | |
| 107 | − step: string; | |
| 108 | − status: "pending" | "inProgress" | "completed"; | |
| 66 | + readonly createdAt: string; | |
| 67 | + readonly runId: RunId | null; | |
| 68 | + readonly explanation?: string | null; | |
| 69 | + readonly steps: Array<{ | |
| 70 | + readonly step: string; | |
| 71 | + readonly status: "pending" | "inProgress" | "completed"; | |
| 109 | 72 | }>; |
| 110 | 73 | } |
| 111 | 74 | |
| 112 | 75 | export interface LatestProposedPlanState { |
| 113 | − id: OrchestrationProposedPlanId; | |
| 114 | − createdAt: string; | |
| 115 | − updatedAt: string; | |
| 116 | − turnId: TurnId | null; | |
| 117 | − planMarkdown: string; | |
| 118 | − implementedAt: string | null; | |
| 119 | − implementationThreadId: ThreadId | null; | |
| 76 | + readonly id: PlanId; | |
| 77 | + readonly createdAt: string; | |
| 78 | + readonly updatedAt: string; | |
| 79 | + readonly runId: RunId | null; | |
| 80 | + readonly planMarkdown: string; | |
| 81 | + readonly implementedAt: string | null; | |
| 82 | + readonly implementationThreadId: ThreadId | null; | |
| 83 | + readonly status: ThreadProposedPlan["status"]; | |
| 120 | 84 | } |
| 121 | 85 | |
| 122 | 86 | export type TimelineEntry = |
| 123 | 87 | | { |
| 124 | − id: string; | |
| 125 | − kind: "message"; | |
| 126 | − createdAt: string; | |
| 127 | − message: ChatMessage; | |
| 88 | + readonly id: string; | |
| 89 | + readonly kind: "message"; | |
| 90 | + readonly createdAt: string; | |
| 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; | |
| 131 | − kind: "proposed-plan"; | |
| 132 | − createdAt: string; | |
| 133 | − proposedPlan: ProposedPlan; | |
| 100 | + readonly id: string; | |
| 101 | + readonly kind: "work"; | |
| 102 | + readonly createdAt: string; | |
| 103 | + readonly entry: WorkLogEntry; | |
| 134 | 104 | } |
| 135 | 105 | | { |
| 136 | − id: string; | |
| 137 | − kind: "work"; | |
| 138 | − createdAt: string; | |
| 139 | − entry: WorkLogEntry; | |
| 106 | + readonly id: string; | |
| 107 | + readonly kind: "event"; | |
| 108 | + readonly createdAt: string; | |
| 109 | + readonly projectedItem: OrchestrationV2ProjectedTurnItem; | |
| 140 | 110 | }; |
| 141 | 111 | |
| 142 | 112 | export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { |
| 143 | − if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { | |
| 144 | − return true; | |
| 145 | − } | |
| 146 | − if (entry.command !== undefined && entry.command.trim().length > 0) { | |
| 147 | − return true; | |
| 148 | − } | |
| 149 | − if (entry.requestKind !== undefined) { | |
| 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; | |
| 113 | + return ( | |
| 114 | + entry.tone === "tool" || | |
| 115 | + entry.tone === "thinking" || | |
| 116 | + entry.tone === "error" || | |
| 117 | + entry.command !== undefined || | |
| 118 | + entry.requestKind !== undefined | |
| 119 | + ); | |
| 199 | 120 | } |
| 200 | 121 | |
| 201 | −/** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */ | |
| 202 | 122 | export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { |
| 203 | − if (entry.tone === "error") { | |
| 204 | − return true; | |
| 205 | − } | |
| 206 | − const ls = entry.toolLifecycleStatus; | |
| 207 | − if (ls === "failed" || ls === "declined") { | |
| 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); | |
| 123 | + return ( | |
| 124 | + entry.tone === "error" || | |
| 125 | + entry.toolLifecycleStatus === "failed" || | |
| 126 | + entry.toolLifecycleStatus === "declined" | |
| 127 | + ); | |
| 225 | 128 | } |
| 226 | 129 | |
| 227 | −/** Tool/command row completed without failure (blue check affordance). */ | |
| 228 | 130 | export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { |
| 229 | − if (!workLogEntryIsToolLike(entry)) { | |
| 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; | |
| 131 | + return workLogEntryIsToolLike(entry) && entry.toolLifecycleStatus === "completed"; | |
| 249 | 132 | } |
| 250 | 133 | |
| 251 | −/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ | |
| 252 | 134 | export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { |
| 253 | − if (!workLogEntryIsToolLike(entry)) { | |
| 254 | − return false; | |
| 255 | − } | |
| 256 | − if (workEntryIndicatesToolFailure(entry)) { | |
| 257 | − return false; | |
| 258 | − } | |
| 259 | − if (workEntryIndicatesToolSuccess(entry)) { | |
| 260 | − return false; | |
| 261 | − } | |
| 262 | − return true; | |
| 135 | + return ( | |
| 136 | + workLogEntryIsToolLike(entry) && | |
| 137 | + !workEntryIndicatesToolFailure(entry) && | |
| 138 | + !workEntryIndicatesToolSuccess(entry) | |
| 139 | + ); | |
| 263 | 140 | } |
| 264 | 141 | |
| 265 | 142 | export function formatDuration(durationMs: number): string { |
| @@ -267,7 +144,6 @@ export function formatDuration(durationMs: number): string { · 1 unmodified line | ||
| 267 | 144 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; |
| 268 | 145 | if (durationMs < 10_000) { |
| 269 | 146 | const tenths = Math.round(durationMs / 100) / 10; |
| 270 | − // 9.95s+ rounds up to the next bucket — render "10s", not "10.0s". | |
| 271 | 147 | return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`; |
| 272 | 148 | } |
| 273 | 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 | 158 | if (!endIso) return null; |
| 283 | 159 | const startedAt = Date.parse(startIso); |
| 284 | 160 | const endedAt = Date.parse(endIso); |
| 285 | − if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { | |
| 286 | − return null; | |
| 287 | − } | |
| 161 | + if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) return null; | |
| 288 | 162 | return formatDuration(endedAt - startedAt); |
| 289 | 163 | } |
| 290 | 164 | |
| 291 | −type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; | |
| 292 | −type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">; | |
| 293 | − | |
| 294 | −export function isLatestTurnSettled( | |
| 295 | − latestTurn: LatestTurnTiming | null, | |
| 296 | − session: SessionActivityState | null, | |
| 165 | +export function isLatestRunSettled( | |
| 166 | + latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null, | |
| 167 | + runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null, | |
| 297 | 168 | ): boolean { |
| 298 | − if (!latestTurn?.startedAt) return false; | |
| 299 | − if (!latestTurn.completedAt) return false; | |
| 300 | − if (!session) return true; | |
| 301 | − if (session.status === "running") return false; | |
| 302 | − return true; | |
| 169 | + if (latestRun === null) return false; | |
| 170 | + if ( | |
| 171 | + latestRun.status === "starting" || | |
| 172 | + latestRun.status === "running" || | |
| 173 | + latestRun.status === "waiting" | |
| 174 | + ) | |
| 175 | + return false; | |
| 176 | + return runtime?.activeRunId !== latestRun.runId; | |
| 303 | 177 | } |
| 304 | 178 | |
| 305 | 179 | export function deriveActiveWorkStartedAt( |
| 306 | − latestTurn: LatestTurnTiming | null, | |
| 307 | − session: SessionActivityState | null, | |
| 180 | + latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null, | |
| 181 | + runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null, | |
| 308 | 182 | sendStartedAt: string | null, |
| 309 | 183 | ): string | null { |
| 310 | − const runningTurnId = session?.status === "running" ? session.activeTurnId : null; | |
| 311 | − if (runningTurnId !== null) { | |
| 312 | − if (latestTurn?.turnId === runningTurnId) { | |
| 313 | − return latestTurn.startedAt ?? sendStartedAt; | |
| 314 | − } | |
| 315 | − return sendStartedAt; | |
| 184 | + if (runtime?.activeRunId !== null && runtime?.activeRunId !== undefined) { | |
| 185 | + return latestRun?.runId === runtime.activeRunId | |
| 186 | + ? (latestRun.startedAt ?? sendStartedAt) | |
| 187 | + : sendStartedAt; | |
| 316 | 188 | } |
| 317 | − if (!isLatestTurnSettled(latestTurn, session)) { | |
| 318 | − return latestTurn?.startedAt ?? sendStartedAt; | |
| 319 | − } | |
| 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 | − ); | |
| 189 | + return isLatestRunSettled(latestRun, runtime) | |
| 190 | + ? sendStartedAt | |
| 191 | + : (latestRun?.startedAt ?? sendStartedAt); | |
| 353 | 192 | } |
| 354 | 193 | |
| 355 | 194 | export function derivePendingApprovals( |
| 356 | − activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 357 | −): PendingApproval[] { | |
| 358 | − const openByRequestId = new Map<ApprovalRequestId, PendingApproval>(); | |
| 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; | |
| 195 | + approvals: ReadonlyArray<ThreadPendingApproval>, | |
| 196 | +): ThreadPendingApproval[] { | |
| 197 | + return [...approvals].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); | |
| 459 | 198 | } |
| 460 | 199 | |
| 461 | 200 | export function derivePendingUserInputs( |
| 462 | − activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 463 | −): PendingUserInput[] { | |
| 464 | − const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>(); | |
| 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 | − ); | |
| 201 | + inputs: ReadonlyArray<ThreadPendingUserInput>, | |
| 202 | +): ThreadPendingUserInput[] { | |
| 203 | + return [...inputs].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); | |
| 508 | 204 | } |
| 509 | 205 | |
| 510 | 206 | export function deriveActivePlanState( |
| 511 | − activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 512 | − latestTurnId: TurnId | undefined, | |
| 207 | + plans: ReadonlyArray<ThreadTodoPlan>, | |
| 208 | + latestRunId: RunId | undefined, | |
| 513 | 209 | ): ActivePlanState | null { |
| 514 | − const ordered = [...activities].toSorted(compareActivitiesByOrder); | |
| 515 | − const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); | |
| 516 | − // Prefer plan from the current turn; fall back to the most recent plan from any turn | |
| 517 | − // so that TodoWrite tasks persist across follow-up messages. | |
| 518 | − const latest = Option.firstSomeOf([ | |
| 519 | − ...(latestTurnId | |
| 520 | − ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) | |
| 521 | − : Option.none()), | |
| 522 | − Arr.last(allPlanActivities), | |
| 523 | − ]).pipe(Option.getOrNull); | |
| 524 | − if (!latest) { | |
| 525 | − return null; | |
| 526 | − } | |
| 527 | − const payload = | |
| 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 | − } | |
| 210 | + const plan = | |
| 211 | + [...plans].toReversed().find((candidate) => candidate.runId === latestRunId) ?? | |
| 212 | + plans.at(-1) ?? | |
| 213 | + null; | |
| 214 | + if (plan === null || plan.steps.length === 0) return null; | |
| 215 | + return { | |
| 216 | + createdAt: plan.updatedAt, | |
| 217 | + runId: plan.runId, | |
| 218 | + explanation: plan.explanation, | |
| 219 | + steps: plan.steps.map(({ step, status }) => ({ step, status })), | |
| 220 | + }; | |
| 221 | +} | |
| 222 | + | |
| 223 | +function toLatestProposedPlanState(plan: ThreadProposedPlan): LatestProposedPlanState { | |
| 557 | 224 | return { |
| 558 | − createdAt: latest.createdAt, | |
| 559 | − turnId: latest.turnId, | |
| 560 | − ...(payload && "explanation" in payload | |
| 561 | − ? { explanation: payload.explanation as string | null } | |
| 562 | − : {}), | |
| 563 | − steps, | |
| 225 | + id: plan.id, | |
| 226 | + createdAt: plan.createdAt, | |
| 227 | + updatedAt: plan.updatedAt, | |
| 228 | + runId: plan.runId, | |
| 229 | + planMarkdown: plan.planMarkdown, | |
| 230 | + implementedAt: plan.implementedAt, | |
| 231 | + implementationThreadId: plan.implementationThreadId, | |
| 232 | + status: plan.status, | |
| 564 | 233 | }; |
| 565 | 234 | } |
| 566 | 235 | |
| 567 | 236 | export function findLatestProposedPlan( |
| 568 | − proposedPlans: ReadonlyArray<ProposedPlan>, | |
| 569 | − latestTurnId: TurnId | string | null | undefined, | |
| 237 | + plans: ReadonlyArray<ThreadProposedPlan>, | |
| 238 | + latestRunId: RunId | string | null | undefined, | |
| 570 | 239 | ): LatestProposedPlanState | null { |
| 571 | − if (latestTurnId) { | |
| 572 | − const matchingTurnPlan = [...proposedPlans] | |
| 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] | |
| 240 | + const candidates = latestRunId ? plans.filter((plan) => plan.runId === latestRunId) : plans; | |
| 241 | + const plan = [...(candidates.length > 0 ? candidates : plans)] | |
| 585 | 242 | .toSorted( |
| 586 | 243 | (left, right) => |
| 587 | 244 | left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), |
| 588 | 245 | ) |
| 589 | 246 | .at(-1); |
| 590 | − if (!latestPlan) { | |
| 591 | − return null; | |
| 592 | − } | |
| 593 | − | |
| 594 | − return toLatestProposedPlanState(latestPlan); | |
| 247 | + return plan === undefined ? null : toLatestProposedPlanState(plan); | |
| 595 | 248 | } |
| 596 | 249 | |
| 597 | 250 | export function findSidebarProposedPlan(input: { |
| 598 | − threads: ReadonlyArray<Pick<Thread, "id" | "proposedPlans">>; | |
| 599 | − latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "sourceProposedPlan"> | null; | |
| 600 | − latestTurnSettled: boolean; | |
| 601 | − threadId: ThreadId | string | null | undefined; | |
| 251 | + readonly threads: ReadonlyArray< | |
| 252 | + Pick< | |
| 253 | + { readonly id: ThreadId; readonly proposedPlans: ReadonlyArray<ThreadProposedPlan> }, | |
| 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 | 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 | 269 | input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; |
| 605 | − | |
| 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); | |
| 270 | + return findLatestProposedPlan(activePlans, input.latestRun?.runId); | |
| 619 | 271 | } |
| 620 | 272 | |
| 621 | 273 | export function hasActionableProposedPlan( |
| 622 | − proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "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, | |
| 274 | + plan: LatestProposedPlanState | Pick<ThreadProposedPlan, "implementedAt"> | null, | |
| 784 | 275 | ): boolean { |
| 785 | − if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { | |
| 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 | − }; | |
| 276 | + return plan !== null && plan.implementedAt === null; | |
| 836 | 277 | } |
| 837 | 278 | |
| 838 | −function mergeChangedFiles( | |
| 839 | − previous: ReadonlyArray<string> | undefined, | |
| 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"); | |
| 279 | +export function deriveWorkLogEntries(entries: ReadonlyArray<ThreadWorkEntry>): WorkLogEntry[] { | |
| 280 | + return entries.map((entry) => ({ ...entry, sourceItemType: entry.itemType })); | |
| 863 | 281 | } |
| 864 | 282 | |
| 865 | −function normalizeCompactToolLabel(value: string): string { | |
| 866 | − return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | |
| 867 | −} | |
| 868 | − | |
| 869 | −function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPlanState { | |
| 870 | − return { | |
| 871 | − id: proposedPlan.id, | |
| 872 | − createdAt: proposedPlan.createdAt, | |
| 873 | − updatedAt: proposedPlan.updatedAt, | |
| 874 | − turnId: proposedPlan.turnId, | |
| 875 | − planMarkdown: proposedPlan.planMarkdown, | |
| 876 | − implementedAt: proposedPlan.implementedAt, | |
| 877 | − implementationThreadId: proposedPlan.implementationThreadId, | |
| 878 | − }; | |
| 879 | −} | |
| 880 | − | |
| 881 | −function asRecord(value: unknown): Record<string, unknown> | null { | |
| 882 | − return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | |
| 883 | −} | |
| 884 | − | |
| 885 | −function asTrimmedString(value: unknown): string | null { | |
| 886 | − if (typeof value !== "string") { | |
| 887 | − return null; | |
| 888 | − } | |
| 889 | − const trimmed = value.trim(); | |
| 890 | − return trimmed.length > 0 ? trimmed : null; | |
| 891 | −} | |
| 892 | − | |
| 893 | −function asNumber(value: unknown): number | null { | |
| 894 | − return typeof value === "number" && Number.isFinite(value) ? value : null; | |
| 895 | −} | |
| 896 | − | |
| 897 | −function trimMatchingOuterQuotes(value: string): string { | |
| 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), | |
| 283 | +export function deriveTimelineEntries( | |
| 284 | + messages: ReadonlyArray<ChatMessage>, | |
| 285 | + proposedPlans: ReadonlyArray<ThreadProposedPlan>, | |
| 286 | + workEntries: ReadonlyArray<WorkLogEntry>, | |
| 287 | +): TimelineEntry[] { | |
| 288 | + return [ | |
| 289 | + ...messages.map( | |
| 290 | + (message): TimelineEntry => ({ | |
| 291 | + id: message.id, | |
| 292 | + kind: "message", | |
| 293 | + createdAt: message.createdAt, | |
| 294 | + message, | |
| 295 | + }), | |
| 296 | + ), | |
| 297 | + ...proposedPlans.map( | |
| 298 | + (proposedPlan): TimelineEntry => ({ | |
| 299 | + id: proposedPlan.id, | |
| 300 | + kind: "proposed-plan", | |
| 301 | + createdAt: proposedPlan.createdAt, | |
| 302 | + proposedPlan, | |
| 303 | + }), | |
| 304 | + ), | |
| 305 | + ...workEntries.map( | |
| 306 | + (entry): TimelineEntry => ({ | |
| 307 | + id: entry.id, | |
| 308 | + kind: "work", | |
| 309 | + createdAt: entry.createdAt, | |
| 310 | + entry, | |
| 311 | + }), | |
| 312 | + ), | |
| 313 | + ].toSorted( | |
| 314 | + (left, right) => | |
| 315 | + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | |
| 970 | 316 | ); |
| 971 | 317 | } |
| 972 | 318 | |
| 973 | −function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { | |
| 974 | − const match = wrapperFlagPattern.exec(value); | |
| 975 | − if (!match) { | |
| 976 | − return null; | |
| 977 | − } | |
| 978 | − | |
| 979 | − const command = value.slice(match.index + match[0].length).trim(); | |
| 980 | − if (command.length === 0) { | |
| 981 | − return null; | |
| 982 | − } | |
| 983 | − | |
| 984 | − const unwrapped = trimMatchingOuterQuotes(command); | |
| 985 | − return unwrapped.length > 0 ? unwrapped : null; | |
| 986 | −} | |
| 987 | − | |
| 988 | −function unwrapKnownShellCommandWrapper(value: string): string { | |
| 989 | − const split = splitExecutableAndRest(value); | |
| 990 | − if (!split || split.rest.length === 0) { | |
| 991 | − return value; | |
| 992 | − } | |
| 993 | − | |
| 994 | − const shell = executableBasename(split.executable); | |
| 995 | − if (!shell) { | |
| 996 | − return value; | |
| 997 | − } | |
| 998 | − | |
| 999 | − const spec = findShellWrapperSpec(shell); | |
| 1000 | − if (!spec) { | |
| 1001 | − return value; | |
| 1002 | − } | |
| 1003 | − | |
| 1004 | − return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; | |
| 1005 | −} | |
| 1006 | − | |
| 1007 | −function formatCommandArrayPart(value: string): string { | |
| 1008 | − return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; | |
| 1009 | −} | |
| 1010 | − | |
| 1011 | −function formatCommandValue(value: unknown): string | null { | |
| 1012 | − const direct = asTrimmedString(value); | |
| 1013 | − if (direct) { | |
| 1014 | − return direct; | |
| 1015 | − } | |
| 1016 | − if (!Array.isArray(value)) { | |
| 1017 | − return null; | |
| 1018 | − } | |
| 1019 | − const parts: Array<string> = []; | |
| 1020 | − for (const entry of value) { | |
| 1021 | − const part = asTrimmedString(entry); | |
| 1022 | − if (part !== null) { | |
| 1023 | − parts.push(part); | |
| 1024 | − } | |
| 1025 | − } | |
| 1026 | − if (parts.length === 0) { | |
| 1027 | − return null; | |
| 1028 | − } | |
| 1029 | − return parts.map((part) => formatCommandArrayPart(part)).join(" "); | |
| 1030 | −} | |
| 1031 | − | |
| 1032 | −function normalizeCommandValue(value: unknown): string | null { | |
| 1033 | − const formatted = formatCommandValue(value); | |
| 1034 | − return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; | |
| 1035 | −} | |
| 1036 | − | |
| 1037 | −function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null { | |
| 1038 | − const formatted = formatCommandValue(value); | |
| 1039 | − if (!formatted || normalizedCommand === null) { | |
| 1040 | − return null; | |
| 319 | +const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"]["type"]>([ | |
| 320 | + "approval_request", | |
| 321 | + "compaction", | |
| 322 | + "fork", | |
| 323 | + "handoff", | |
| 324 | + "run_interrupt_request", | |
| 325 | + "run_interrupt_result", | |
| 326 | + "subagent", | |
| 327 | + "todo_list", | |
| 328 | + "user_input_request", | |
| 329 | +]); | |
| 330 | + | |
| 331 | +function projectedItemCreatedAt(row: OrchestrationV2ProjectedTurnItem): string { | |
| 332 | + return DateTime.formatIso(row.item.startedAt ?? row.item.updatedAt); | |
| 333 | +} | |
| 334 | + | |
| 335 | +function projectedWorkEntryStatus( | |
| 336 | + item: OrchestrationV2TurnItem, | |
| 337 | +): NonNullable<WorkLogEntry["toolLifecycleStatus"]> { | |
| 338 | + switch (item.status) { | |
| 339 | + case "pending": | |
| 340 | + case "running": | |
| 341 | + case "waiting": | |
| 342 | + return "inProgress"; | |
| 343 | + case "completed": | |
| 344 | + return "completed"; | |
| 345 | + case "failed": | |
| 346 | + return "failed"; | |
| 347 | + case "cancelled": | |
| 348 | + case "interrupted": | |
| 349 | + return "stopped"; | |
| 350 | + } | |
| 351 | +} | |
| 352 | + | |
| 353 | +function projectedWorkEntryTone(item: OrchestrationV2TurnItem): WorkLogEntry["tone"] { | |
| 354 | + if (item.status === "failed") return "error"; | |
| 355 | + if (item.type === "reasoning") return "thinking"; | |
| 356 | + switch (item.type) { | |
| 357 | + case "command_execution": | |
| 358 | + case "file_change": | |
| 359 | + case "file_search": | |
| 360 | + case "web_search": | |
| 361 | + case "dynamic_tool": | |
| 362 | + case "subagent": | |
| 363 | + return "tool"; | |
| 364 | + default: | |
| 365 | + return "info"; | |
| 366 | + } | |
| 367 | +} | |
| 368 | + | |
| 369 | +function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry { | |
| 370 | + const { item } = row; | |
| 371 | + const title = item.title?.trim() || null; | |
| 372 | + const common = { | |
| 373 | + id: item.id, | |
| 374 | + createdAt: projectedItemCreatedAt(row), | |
| 375 | + runId: item.runId, | |
| 376 | + tone: projectedWorkEntryTone(item), | |
| 377 | + itemType: item.type, | |
| 378 | + toolLifecycleStatus: projectedWorkEntryStatus(item), | |
| 379 | + structuredPayload: item, | |
| 380 | + projectedItem: row, | |
| 381 | + } as const; | |
| 382 | + | |
| 383 | + switch (item.type) { | |
| 384 | + case "reasoning": | |
| 385 | + return { | |
| 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): { | |
| 1046 | − command: string | null; | |
| 1047 | − rawCommand: string | null; | |
| 1048 | −} { | |
| 1049 | − const data = asRecord(payload?.data); | |
| 1050 | − const item = asRecord(data?.item); | |
| 1051 | − const itemResult = asRecord(item?.result); | |
| 1052 | − const itemInput = asRecord(item?.input); | |
| 1053 | − const itemType = asTrimmedString(payload?.itemType); | |
| 1054 | − const detail = asTrimmedString(payload?.detail); | |
| 1055 | − const candidates: unknown[] = [ | |
| 1056 | − item?.command, | |
| 1057 | − itemInput?.command, | |
| 1058 | − itemResult?.command, | |
| 1059 | − data?.command, | |
| 1060 | − itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, | |
| 1061 | − ]; | |
| 1062 | − | |
| 1063 | − for (const candidate of candidates) { | |
| 1064 | − const command = normalizeCommandValue(candidate); | |
| 1065 | − if (!command) { | |
| 448 | +/** | |
| 449 | + * Builds the web timeline in the exact order committed by `visibleTurnItems`. | |
| 450 | + * Committed rows are presented directly from their projected item. Optimistic | |
| 451 | + * messages are the only client-owned entries appended to that sequence. | |
| 452 | + */ | |
| 453 | +export function deriveTimelineEntriesFromVisibleTurnItems(input: { | |
| 454 | + readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>; | |
| 455 | + readonly optimisticMessages: ReadonlyArray<ChatMessage>; | |
| 456 | + readonly attachmentUrlById?: ReadonlyMap<string, string>; | |
| 457 | +}): TimelineEntry[] { | |
| 458 | + const committedMessageIds = new Set<string>(); | |
| 459 | + const entries: TimelineEntry[] = []; | |
| 460 | + | |
| 461 | + for (const row of input.visibleTurnItems) { | |
| 462 | + const { item } = row; | |
| 463 | + const createdAt = projectedItemCreatedAt(row); | |
| 464 | + if (item.type === "user_message" || item.type === "assistant_message") { | |
| 465 | + const message: ChatMessage = { | |
| 466 | + id: item.messageId, | |
| 467 | + role: item.type === "user_message" ? "user" : "assistant", | |
| 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 | 484 | continue; |
| 1067 | 485 | } |
| 1068 | − return { | |
| 1069 | − command, | |
| 1070 | − rawCommand: toRawToolCommand(candidate, command), | |
| 1071 | − }; | |
| 1072 | − } | |
| 1073 | 486 | |
| 1074 | − return { | |
| 1075 | − command: null, | |
| 1076 | − rawCommand: null, | |
| 1077 | − }; | |
| 1078 | −} | |
| 1079 | − | |
| 1080 | −function extractToolTitle(payload: Record<string, unknown> | null): string | null { | |
| 1081 | − return asTrimmedString(payload?.title); | |
| 1082 | −} | |
| 1083 | − | |
| 1084 | −function extractToolCallId(payload: Record<string, unknown> | null): string | null { | |
| 1085 | − const data = asRecord(payload?.data); | |
| 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)) { | |
| 487 | + if (item.type === "proposed_plan") { | |
| 488 | + const proposedPlan = { | |
| 489 | + id: item.planId, | |
| 490 | + runId: item.runId, | |
| 491 | + planMarkdown: item.markdown, | |
| 492 | + status: "active" as const, | |
| 493 | + implementedAt: null, | |
| 494 | + implementationThreadId: null, | |
| 495 | + createdAt, | |
| 496 | + updatedAt: DateTime.formatIso(item.updatedAt), | |
| 497 | + }; | |
| 498 | + entries.push({ id: item.id, kind: "proposed-plan", createdAt, proposedPlan }); | |
| 1283 | 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( | |
| 1300 | − left: OrchestrationThreadActivity, | |
| 1301 | − right: OrchestrationThreadActivity, | |
| 1302 | −): number { | |
| 1303 | − if (left.sequence !== undefined && right.sequence !== undefined) { | |
| 1304 | − if (left.sequence !== right.sequence) { | |
| 1305 | − return left.sequence - right.sequence; | |
| 502 | + if (STANDALONE_V2_ITEM_TYPES.has(item.type)) { | |
| 503 | + entries.push({ id: item.id, kind: "event", createdAt, projectedItem: row }); | |
| 504 | + continue; | |
| 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); | |
| 1314 | − if (createdAtComparison !== 0) { | |
| 1315 | − return createdAtComparison; | |
| 507 | + entries.push({ id: item.id, kind: "work", createdAt, entry: projectedWorkEntry(row) }); | |
| 1316 | 508 | } |
| 1317 | 509 | |
| 1318 | − const lifecycleRankComparison = | |
| 1319 | − compareActivityLifecycleRank(left.kind) - compareActivityLifecycleRank(right.kind); | |
| 1320 | − if (lifecycleRankComparison !== 0) { | |
| 1321 | − return lifecycleRankComparison; | |
| 510 | + for (const message of input.optimisticMessages) { | |
| 511 | + if (!committedMessageIds.has(message.id)) { | |
| 512 | + entries.push({ | |
| 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 { | |
| 1328 | − if (kind.endsWith(".started") || kind === "tool.started") { | |
| 1329 | − return 0; | |
| 1330 | − } | |
| 1331 | − if (kind.endsWith(".progress") || kind.endsWith(".updated")) { | |
| 1332 | − return 1; | |
| 1333 | − } | |
| 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), | |
| 524 | +export function inferCheckpointTurnCountByRunId( | |
| 525 | + summaries: ReadonlyArray<ThreadCheckpointSummary>, | |
| 526 | +): Record<string, number> { | |
| 527 | + return Object.fromEntries( | |
| 528 | + summaries.flatMap((summary) => | |
| 529 | + summary.runId === null ? [] : [[summary.runId, summary.checkpointTurnCount] as const], | |
| 530 | + ), | |
| 1365 | 531 | ); |
| 1366 | 532 | } |
| 1367 | 533 | |
| 1368 | −export function inferCheckpointTurnCountByTurnId( | |
| 1369 | − summaries: ReadonlyArray<TurnDiffSummary>, | |
| 1370 | −): Record<TurnId, number> { | |
| 1371 | − const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); | |
| 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"; | |
| 534 | +export function derivePhase(runtime: ThreadRuntimeSummary | null): SessionPhase { | |
| 535 | + if (runtime === null) return "disconnected"; | |
| 536 | + if (runtime.status === "starting" || runtime.status === "queued") return "connecting"; | |
| 537 | + if (runtime.status === "running" || runtime.status === "waiting") return "running"; | |
| 1392 | 538 | return "ready"; |
| 1393 | 539 | } |
| 540 | + | |
| 541 | +export type { TurnDiffSummary }; | |
| @@ -3,21 +3,14 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | EnvironmentProject, |
| 4 | 4 | EnvironmentThread, |
| 5 | 5 | EnvironmentThreadShell, |
| 6 | + ThreadConversationMessage, | |
| 7 | + ThreadProposedPlan, | |
| 8 | + ThreadRuntimeSummary, | |
| 9 | + ThreadWorkEntry, | |
| 6 | 10 | } from "@t3tools/client-runtime/state/shell"; |
| 7 | −import { | |
| 8 | − type EnvironmentThreadStatus, | |
| 9 | − mergeEnvironmentThread, | |
| 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"; | |
| 11 | +import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; | |
| 12 | +import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; | |
| 13 | +import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts"; | |
| 21 | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 22 | 15 | import { useMemo } from "react"; |
| 23 | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| @@ -28,9 +21,10 @@ import { environmentThreadDetails, environmentThreadShells } from "./threads"; · 4 unmodified lines | ||
| 28 | 21 | |
| 29 | 22 | const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]); |
| 30 | 23 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); |
| 31 | −const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]); | |
| 32 | −const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]); | |
| 33 | −const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]); | |
| 24 | +const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]); | |
| 25 | +const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]); | |
| 26 | +const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = Object.freeze([]); | |
| 27 | +const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]); | |
| 34 | 28 | |
| 35 | 29 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( |
| 36 | 30 | Atom.withLabel("web-project:empty"), |
| @@ -53,14 +47,17 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pip · 16 unmodified lines | ||
| 53 | 47 | const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( |
| 54 | 48 | Atom.withLabel("web-thread-messages:empty"), |
| 55 | 49 | ); |
| 56 | −const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( | |
| 57 | − Atom.withLabel("web-thread-activities:empty"), | |
| 50 | +const EMPTY_WORK_ENTRIES_ATOM = Atom.make(EMPTY_WORK_ENTRIES).pipe( | |
| 51 | + Atom.withLabel("web-thread-work-entries:empty"), | |
| 58 | 52 | ); |
| 59 | 53 | const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( |
| 60 | 54 | Atom.withLabel("web-thread-proposed-plans:empty"), |
| 61 | 55 | ); |
| 62 | −const EMPTY_SESSION_ATOM = Atom.make<OrchestrationSession | null>(null).pipe( | |
| 63 | − Atom.withLabel("web-thread-session:empty"), | |
| 56 | +const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe( | |
| 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 | 63 | export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe( |
| @@ -186,31 +183,39 @@ export function useThread( · 119 unmodified lines | ||
| 186 | 183 | |
| 187 | 184 | export function useThreadMessages( |
| 188 | 185 | ref: ScopedThreadRef | null, |
| 189 | −): ReadonlyArray<OrchestrationMessage> { | |
| 186 | +): ReadonlyArray<ThreadConversationMessage> { | |
| 190 | 187 | return useAtomValue( |
| 191 | 188 | ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), |
| 192 | 189 | ); |
| 193 | 190 | } |
| 194 | 191 | |
| 195 | −export function useThreadActivities( | |
| 196 | − ref: ScopedThreadRef | null, | |
| 197 | −): ReadonlyArray<OrchestrationThreadActivity> { | |
| 192 | +export function useThreadWorkEntries(ref: ScopedThreadRef | null): ReadonlyArray<ThreadWorkEntry> { | |
| 198 | 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 | 198 | export function useThreadProposedPlans( |
| 204 | 199 | ref: ScopedThreadRef | null, |
| 205 | −): ReadonlyArray<OrchestrationProposedPlan> { | |
| 200 | +): ReadonlyArray<ThreadProposedPlan> { | |
| 206 | 201 | return useAtomValue( |
| 207 | 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 | 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 | 9 | type EnvironmentThreadSearchMatch, |
| 10 | 10 | } from "@t3tools/client-runtime/state/thread-search"; |
| 11 | 11 | import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 12 | −import type { | |
| 13 | − EnvironmentId, | |
| 14 | − OrchestrationThread, | |
| 15 | − ProjectContentMatch, | |
| 16 | − ProjectEntryKind, | |
| 17 | − ThreadId, | |
| 18 | − VcsListRefsResult, | |
| 19 | − VcsRef, | |
| 20 | −} from "@t3tools/contracts"; | |
| 12 | +import type { EnvironmentId, ThreadId, VcsListRefsResult, VcsRef } from "@t3tools/contracts"; | |
| 13 | +import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; | |
| 21 | 14 | import * as Cause from "effect/Cause"; |
| 22 | 15 | import * as Option from "effect/Option"; |
| 23 | 16 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| @@ -56,7 +49,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 32 unmodified lines | ||
| 56 | 49 | }); |
| 57 | 50 | |
| 58 | 51 | export interface ThreadDetailView { |
| 59 | − readonly data: OrchestrationThread | null; | |
| 52 | + readonly data: EnvironmentThread | null; | |
| 60 | 53 | readonly error: string | null; |
| 61 | 54 | readonly isPending: boolean; |
| 62 | 55 | readonly isDeleted: boolean; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type EnvironmentThreadState, |
| 8 | 8 | createThreadEnvironmentAtoms, |
| 9 | 9 | } from "@t3tools/client-runtime/state/threads"; |
| 10 | +import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell"; | |
| 10 | 11 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 11 | 12 | import * as Option from "effect/Option"; |
| 12 | 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 | 33 | export function useEnvironmentThread( |
| 33 | 34 | environmentId: EnvironmentId | null, |
| 34 | 35 | threadId: ThreadId | null, |
| 35 | −): EnvironmentThreadState { | |
| 36 | +): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } { | |
| 36 | 37 | const result = useAtomValue( |
| 37 | 38 | environmentId !== null && threadId !== null |
| 38 | 39 | ? environmentThreads.stateAtom(environmentId, threadId) |
| 39 | 40 | : EMPTY_THREAD_STATE_ATOM, |
| 40 | 41 | ); |
| 41 | − return Option.getOrElse( | |
| 42 | + const state = Option.getOrElse( | |
| 42 | 43 | AsyncResult.value(result), |
| 43 | 44 | () => EMPTY_ENVIRONMENT_THREAD_STATE, |
| 44 | 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 | 1 | import type { |
| 2 | 2 | ChatImageAttachment as ContractChatImageAttachment, |
| 3 | − OrchestrationCheckpointFile, | |
| 4 | − OrchestrationCheckpointSummary, | |
| 5 | − OrchestrationLatestTurn, | |
| 6 | − OrchestrationMessage, | |
| 7 | − OrchestrationProposedPlan, | |
| 8 | − OrchestrationSession, | |
| 9 | 3 | ProjectScript as ContractProjectScript, |
| 10 | 4 | ProviderInteractionMode, |
| 11 | 5 | RuntimeMode, |
| @@ -14,6 +8,11 @@ import type { · 2 unmodified lines | ||
| 14 | 8 | EnvironmentProject, |
| 15 | 9 | EnvironmentThread, |
| 16 | 10 | EnvironmentThreadShell, |
| 11 | + ThreadCheckpointSummary, | |
| 12 | + ThreadConversationMessage, | |
| 13 | + ThreadProposedPlan, | |
| 14 | + ThreadRunSummary, | |
| 15 | + ThreadRuntimeSummary, | |
| 17 | 16 | } from "@t3tools/client-runtime/state/shell"; |
| 18 | 17 | |
| 19 | 18 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| @@ -37,21 +36,21 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { · 17 unmodified lines | ||
| 37 | 36 | |
| 38 | 37 | export type ChatAttachment = ChatImageAttachment; |
| 39 | 38 | |
| 40 | −export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { | |
| 39 | +export interface ChatMessage extends Omit<ThreadConversationMessage, "attachments"> { | |
| 41 | 40 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 42 | 41 | } |
| 43 | 42 | |
| 44 | −export type ProposedPlan = OrchestrationProposedPlan; | |
| 45 | −export type TurnDiffFileChange = OrchestrationCheckpointFile; | |
| 46 | −export type TurnDiffSummary = OrchestrationCheckpointSummary; | |
| 43 | +export type ProposedPlan = ThreadProposedPlan; | |
| 44 | +export type TurnDiffFileChange = ThreadCheckpointSummary["files"][number]; | |
| 45 | +export type TurnDiffSummary = ThreadCheckpointSummary; | |
| 47 | 46 | |
| 48 | 47 | export type Project = EnvironmentProject; |
| 49 | 48 | export type Thread = EnvironmentThread; |
| 50 | 49 | export type ThreadShell = EnvironmentThreadShell; |
| 51 | 50 | |
| 52 | 51 | export interface ThreadTurnState { |
| 53 | − latestTurn: OrchestrationLatestTurn | null; | |
| 52 | + latestRun: ThreadRunSummary | null; | |
| 54 | 53 | } |
| 55 | 54 | |
| 56 | 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 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 5 | +import { makeThreadFixture } from "./test-fixtures"; | |
| 5 | 6 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; |
| 6 | 7 | |
| 7 | 8 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 8 | 9 | |
| 9 | 10 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 10 | − return { | |
| 11 | + return makeThreadFixture({ | |
| 11 | 12 | id: ThreadId.make("thread-1"), |
| 12 | 13 | environmentId: localEnvironmentId, |
| 13 | 14 | projectId: ProjectId.make("project-1"), |
| @@ -18,10 +19,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines | ||
| 18 | 19 | }, |
| 19 | 20 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 20 | 21 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 21 | − session: null, | |
| 22 | + runtime: null, | |
| 22 | 23 | messages: [], |
| 23 | − checkpoints: [], | |
| 24 | − activities: [], | |
| 25 | 24 | proposedPlans: [], |
| 26 | 25 | createdAt: "2026-02-13T00:00:00.000Z", |
| 27 | 26 | updatedAt: "2026-02-13T00:00:00.000Z", |
| @@ -29,11 +28,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line | ||
| 29 | 28 | settledOverride: null, |
| 30 | 29 | settledAt: null, |
| 31 | 30 | deletedAt: null, |
| 32 | − latestTurn: null, | |
| 31 | + latestRun: null, | |
| 33 | 32 | branch: null, |
| 34 | 33 | worktreePath: null, |
| 35 | 34 | ...overrides, |
| 36 | − }; | |
| 35 | + }); | |
| 37 | 36 | } |
| 38 | 37 | |
| 39 | 38 | describe("getOrphanedWorktreePathForThread", () => { |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type DesktopSshEnvironmentTarget, |
| 3 | 3 | EnvironmentId, |
| 4 | − type OrchestrationShellSnapshot, | |
| 4 | + type OrchestrationV2ShellSnapshot, | |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | 7 | import * as Deferred from "effect/Deferred"; |
| @@ -46,6 +46,7 @@ import * as EnvironmentRegistry from "./registry.ts"; · 38 unmodified lines | ||
| 46 | 46 | import * as RpcSession from "../rpc/session.ts"; |
| 47 | 47 | import * as EnvironmentSupervisor from "./supervisor.ts"; |
| 48 | 48 | import * as ConnectionWakeups from "./wakeups.ts"; |
| 49 | +import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts"; | |
| 49 | 50 | |
| 50 | 51 | const TARGET = new PrimaryConnectionTarget({ |
| 51 | 52 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -112,11 +113,9 @@ const SSH_PROFILE = new SshConnectionProfile({ · 60 unmodified lines | ||
| 112 | 113 | target: SSH_TARGET, |
| 113 | 114 | }); |
| 114 | 115 | |
| 115 | −const CACHED_SNAPSHOT: OrchestrationShellSnapshot = { | |
| 116 | +const CACHED_SNAPSHOT: OrchestrationV2ShellSnapshot = { | |
| 117 | + ...v2ShellSnapshot, | |
| 116 | 118 | snapshotSequence: 1, |
| 117 | − projects: [], | |
| 118 | − threads: [], | |
| 119 | − updatedAt: "2026-06-06T00:00:00.000Z", | |
| 120 | 119 | }; |
| 121 | 120 | |
| 122 | 121 | interface SessionControl { |
| @@ -1,13 +1,22 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | CommandId, |
| 3 | 3 | EnvironmentId, |
| 4 | − ORCHESTRATION_WS_METHODS, | |
| 4 | + MessageId, | |
| 5 | + ORCHESTRATION_V2_WS_METHODS, | |
| 6 | + PlanId, | |
| 5 | 7 | ProjectId, |
| 8 | + ProviderInstanceId, | |
| 9 | + RunId, | |
| 6 | 10 | ThreadId, |
| 7 | − type ClientOrchestrationCommand, | |
| 11 | + WS_METHODS, | |
| 12 | + type OrchestrationV2Command, | |
| 13 | + type OrchestrationV2ThreadLaunchInput, | |
| 14 | + type OrchestrationV2ThreadProjection, | |
| 15 | + type ProjectMutation, | |
| 8 | 16 | } from "@t3tools/contracts"; |
| 9 | 17 | import { describe, expect, it } from "@effect/vitest"; |
| 10 | 18 | import * as Crypto from "effect/Crypto"; |
| 19 | +import * as DateTime from "effect/DateTime"; | |
| 11 | 20 | import * as Effect from "effect/Effect"; |
| 12 | 21 | import * as Layer from "effect/Layer"; |
| 13 | 22 | import * as Option from "effect/Option"; |
| @@ -21,13 +30,8 @@ import { · 7 unmodified lines | ||
| 21 | 30 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 22 | 31 | import * as RpcSession from "../rpc/session.ts"; |
| 23 | 32 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 24 | −import { | |
| 25 | − archiveThread, | |
| 26 | − createProject, | |
| 27 | − settleThread, | |
| 28 | − stopThreadSession, | |
| 29 | − unsettleThread, | |
| 30 | −} from "./commands.ts"; | |
| 33 | +import { v2Projection, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts"; | |
| 34 | +import { archiveThread, createProject, startThreadTurn } from "./commands.ts"; | |
| 31 | 35 | |
| 32 | 36 | const TEST_CRYPTO_LAYER = Layer.succeed( |
| 33 | 37 | Crypto.Crypto, |
| @@ -44,14 +48,45 @@ const TARGET = new PrimaryConnectionTarget({ · 10 unmodified lines | ||
| 44 | 48 | wsBaseUrl: "wss://environment.example.test", |
| 45 | 49 | }); |
| 46 | 50 | |
| 47 | −const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* ( | |
| 48 | − dispatched: ClientOrchestrationCommand[], | |
| 49 | −) { | |
| 51 | +const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (input: { | |
| 52 | + readonly commands: OrchestrationV2Command[]; | |
| 53 | + readonly projects: ProjectMutation[]; | |
| 54 | + readonly launches?: OrchestrationV2ThreadLaunchInput[]; | |
| 55 | + readonly projection?: OrchestrationV2ThreadProjection; | |
| 56 | +}) { | |
| 50 | 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 | 66 | Effect.sync(() => { |
| 53 | − dispatched.push(command); | |
| 54 | − return { sequence: dispatched.length }; | |
| 67 | + input.launches?.push(launchInput); | |
| 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 | 91 | } as unknown as WsRpcProtocolClient; |
| 57 | 92 | const session: RpcSession.RpcSession = { |
| @@ -72,103 +107,222 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 14 unmodified lines | ||
| 72 | 107 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); |
| 73 | 108 | }); |
| 74 | 109 | |
| 75 | −describe("environment commands", () => { | |
| 76 | − it.effect("adds generated command metadata", () => | |
| 110 | +describe("V2 environment commands", () => { | |
| 111 | + it.effect("routes projects through the event-sourced project transport", () => | |
| 77 | 112 | Effect.gen(function* () { |
| 78 | − const dispatched: ClientOrchestrationCommand[] = []; | |
| 79 | − const supervisor = yield* makeSupervisor(dispatched); | |
| 113 | + const projects: ProjectMutation[] = []; | |
| 114 | + const supervisor = yield* makeSupervisor({ commands: [], projects }); | |
| 80 | 115 | |
| 81 | − const result = yield* createProject({ | |
| 116 | + yield* createProject({ | |
| 82 | 117 | projectId: ProjectId.make("project-1"), |
| 83 | 118 | title: "Project", |
| 84 | 119 | workspaceRoot: "/workspace/project", |
| 85 | − createdAt: "2026-06-06T00:00:00.000Z", | |
| 120 | + createWorkspaceRootIfMissing: true, | |
| 86 | 121 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 87 | 122 | |
| 88 | − expect(result).toEqual({ sequence: 1 }); | |
| 89 | − expect(dispatched).toEqual([ | |
| 123 | + expect(projects).toEqual([ | |
| 90 | 124 | { |
| 91 | 125 | type: "project.create", |
| 92 | 126 | commandId: "00000000-0000-4000-8000-000000000000", |
| 93 | 127 | projectId: "project-1", |
| 94 | 128 | title: "Project", |
| 95 | 129 | workspaceRoot: "/workspace/project", |
| 96 | − createdAt: "2026-06-06T00:00:00.000Z", | |
| 130 | + createWorkspaceRootIfMissing: true, | |
| 97 | 131 | }, |
| 98 | 132 | ]); |
| 99 | 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 | 137 | Effect.gen(function* () { |
| 104 | − const dispatched: ClientOrchestrationCommand[] = []; | |
| 105 | − const supervisor = yield* makeSupervisor(dispatched); | |
| 138 | + const commands: OrchestrationV2Command[] = []; | |
| 139 | + const supervisor = yield* makeSupervisor({ commands, projects: [] }); | |
| 106 | 140 | |
| 107 | − yield* stopThreadSession({ | |
| 141 | + yield* archiveThread({ | |
| 108 | 142 | commandId: CommandId.make("queued-command"), |
| 109 | 143 | threadId: ThreadId.make("thread-1"), |
| 110 | − createdAt: "2026-06-06T00:01:00.000Z", | |
| 111 | 144 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 112 | 145 | |
| 113 | − expect(dispatched).toEqual([ | |
| 114 | − { | |
| 115 | − type: "thread.session.stop", | |
| 116 | − commandId: "queued-command", | |
| 117 | − threadId: "thread-1", | |
| 118 | − createdAt: "2026-06-06T00:01:00.000Z", | |
| 119 | − }, | |
| 146 | + expect(commands).toEqual([ | |
| 147 | + { type: "thread.archive", commandId: "queued-command", threadId: "thread-1" }, | |
| 120 | 148 | ]); |
| 121 | 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 | 153 | Effect.gen(function* () { |
| 126 | − const dispatched: ClientOrchestrationCommand[] = []; | |
| 127 | − const supervisor = yield* makeSupervisor(dispatched); | |
| 154 | + const commands: OrchestrationV2Command[] = []; | |
| 155 | + const supervisor = yield* makeSupervisor({ commands, projects: [] }); | |
| 128 | 156 | |
| 129 | − yield* archiveThread({ | |
| 130 | − commandId: CommandId.make("archive-command"), | |
| 131 | − threadId: ThreadId.make("thread-1"), | |
| 157 | + yield* startThreadTurn({ | |
| 158 | + commandId: CommandId.make("implement-plan"), | |
| 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 | 176 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 133 | 177 | |
| 134 | − expect(dispatched).toEqual([ | |
| 135 | − { | |
| 136 | − type: "thread.archive", | |
| 137 | − commandId: "archive-command", | |
| 138 | − threadId: "thread-1", | |
| 139 | − }, | |
| 140 | − ]); | |
| 178 | + expect(commands).toHaveLength(1); | |
| 179 | + expect(commands[0]).toMatchObject({ | |
| 180 | + type: "message.dispatch", | |
| 181 | + commandId: "implement-plan", | |
| 182 | + threadId: v2ThreadId, | |
| 183 | + sourcePlanRef: { threadId: "thread-plan", planId: "plan-1" }, | |
| 184 | + dispatchMode: { type: "start_immediately" }, | |
| 185 | + }); | |
| 141 | 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 | 190 | Effect.gen(function* () { |
| 146 | − const dispatched: ClientOrchestrationCommand[] = []; | |
| 147 | − const supervisor = yield* makeSupervisor(dispatched); | |
| 191 | + const launches: OrchestrationV2ThreadLaunchInput[] = []; | |
| 192 | + const supervisor = yield* makeSupervisor({ commands: [], projects: [], launches }); | |
| 148 | 193 | |
| 149 | − yield* settleThread({ | |
| 150 | − commandId: CommandId.make("settle-command"), | |
| 151 | − threadId: ThreadId.make("thread-1"), | |
| 152 | − }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | |
| 153 | − yield* unsettleThread({ | |
| 154 | − commandId: CommandId.make("unsettle-command"), | |
| 155 | − threadId: ThreadId.make("thread-1"), | |
| 156 | − reason: "user", | |
| 194 | + yield* startThreadTurn({ | |
| 195 | + commandId: CommandId.make("launch-existing-worktree"), | |
| 196 | + threadId: v2ThreadId, | |
| 197 | + message: { | |
| 198 | + messageId: MessageId.make("message-existing-worktree"), | |
| 199 | + role: "user", | |
| 200 | + text: "Continue here", | |
| 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 | 217 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); |
| 158 | 218 | |
| 159 | − expect(dispatched).toEqual([ | |
| 160 | − { | |
| 161 | − type: "thread.settle", | |
| 162 | − commandId: "settle-command", | |
| 163 | − threadId: "thread-1", | |
| 219 | + expect(launches[0]).toMatchObject({ | |
| 220 | + threadId: v2ThreadId, | |
| 221 | + workspaceStrategy: { | |
| 222 | + type: "existing_worktree", | |
| 223 | + worktreePath: "/workspace/project-worktrees/feature", | |
| 224 | + branch: "feature", | |
| 164 | 225 | }, |
| 165 | − { | |
| 166 | − type: "thread.unsettle", | |
| 167 | − commandId: "unsettle-command", | |
| 168 | − threadId: "thread-1", | |
| 169 | − reason: "user", | |
| 226 | + }); | |
| 227 | + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | |
| 228 | + ); | |
| 229 | + | |
| 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 | 326 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 173 | 327 | ); |
| 174 | 328 | }); |
| @@ -1,300 +1,548 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | CommandId, |
| 3 | − ORCHESTRATION_WS_METHODS, | |
| 4 | − type ClientOrchestrationCommand, | |
| 3 | + ORCHESTRATION_V2_WS_METHODS, | |
| 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 | 21 | } from "@t3tools/contracts"; |
| 6 | 22 | import * as Crypto from "effect/Crypto"; |
| 7 | −import * as DateTime from "effect/DateTime"; | |
| 8 | 23 | import * as Effect from "effect/Effect"; |
| 9 | 24 | |
| 10 | −import type { EnvironmentSupervisor } from "../connection/supervisor.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 | −} | |
| 25 | +import { request } from "../rpc/client.ts"; | |
| 68 | 26 | |
| 69 | −function timestampedCommandMetadata(input: { | |
| 27 | +interface CommandMetadata { | |
| 70 | 28 | readonly commandId?: CommandId; |
| 71 | 29 | readonly createdAt?: string; |
| 72 | −}) { | |
| 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 | − }); | |
| 30 | + readonly creationSource?: OrchestrationV2CreationSource; | |
| 80 | 31 | } |
| 81 | 32 | |
| 82 | −function dispatch(command: ClientOrchestrationCommand) { | |
| 83 | − return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command); | |
| 33 | +export interface CreateProjectInput extends CommandMetadata { | |
| 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( | |
| 87 | − "EnvironmentCommands.createProject", | |
| 88 | −)(function* (input) { | |
| 89 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 90 | − return yield* dispatch({ | |
| 91 | − ...input, | |
| 92 | − type: "project.create", | |
| 93 | − commandId: metadata.commandId, | |
| 94 | − createdAt: metadata.createdAt, | |
| 95 | − }); | |
| 96 | −}); | |
| 42 | +export interface UpdateProjectInput extends CommandMetadata { | |
| 43 | + readonly projectId: ProjectId; | |
| 44 | + readonly title?: string; | |
| 45 | + readonly workspaceRoot?: string; | |
| 46 | + readonly defaultModelSelection?: ModelSelection | null; | |
| 47 | + readonly scripts?: ReadonlyArray<ProjectScript>; | |
| 48 | +} | |
| 97 | 49 | |
| 98 | −export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn( | |
| 99 | − "EnvironmentCommands.updateProject", | |
| 100 | −)(function* (input) { | |
| 101 | − return yield* dispatch({ | |
| 102 | − ...input, | |
| 103 | − type: "project.meta.update", | |
| 104 | − commandId: yield* commandId(input), | |
| 105 | − }); | |
| 106 | −}); | |
| 50 | +export interface DeleteProjectInput extends CommandMetadata { | |
| 51 | + readonly projectId: ProjectId; | |
| 52 | + readonly force?: boolean; | |
| 53 | +} | |
| 107 | 54 | |
| 108 | −export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn( | |
| 109 | − "EnvironmentCommands.deleteProject", | |
| 110 | −)(function* (input) { | |
| 111 | − return yield* dispatch({ | |
| 112 | − ...input, | |
| 113 | − type: "project.delete", | |
| 114 | − commandId: yield* commandId(input), | |
| 115 | − }); | |
| 116 | −}); | |
| 55 | +export interface CreateThreadInput extends CommandMetadata { | |
| 56 | + readonly threadId: ThreadId; | |
| 57 | + readonly projectId: ProjectId; | |
| 58 | + readonly title: string; | |
| 59 | + readonly modelSelection: ModelSelection; | |
| 60 | + readonly runtimeMode: RuntimeMode; | |
| 61 | + readonly interactionMode: ProviderInteractionMode; | |
| 62 | + readonly branch: string | null; | |
| 63 | + readonly worktreePath: string | null; | |
| 64 | +} | |
| 117 | 65 | |
| 118 | −export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn( | |
| 119 | − "EnvironmentCommands.createThread", | |
| 120 | −)(function* (input) { | |
| 121 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 122 | − return yield* dispatch({ | |
| 123 | − ...input, | |
| 124 | − type: "thread.create", | |
| 125 | − commandId: metadata.commandId, | |
| 126 | − createdAt: metadata.createdAt, | |
| 127 | − }); | |
| 66 | +export interface ThreadCommandInput extends CommandMetadata { | |
| 67 | + readonly threadId: ThreadId; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export type DeleteThreadInput = ThreadCommandInput; | |
| 71 | +export type ArchiveThreadInput = ThreadCommandInput; | |
| 72 | +export type UnarchiveThreadInput = ThreadCommandInput; | |
| 73 | + | |
| 74 | +export interface UpdateThreadMetadataInput extends ThreadCommandInput { | |
| 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( | |
| 131 | − "EnvironmentCommands.deleteThread", | |
| 132 | −)(function* (input) { | |
| 133 | − return yield* dispatch({ | |
| 134 | − ...input, | |
| 135 | − type: "thread.delete", | |
| 136 | − commandId: yield* commandId(input), | |
| 157 | +const dispatch = (command: OrchestrationV2Command) => | |
| 158 | + request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command); | |
| 159 | + | |
| 160 | +const getProjection = (threadId: ThreadId) => | |
| 161 | + request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); | |
| 162 | + | |
| 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( | |
| 141 | − "EnvironmentCommands.archiveThread", | |
| 142 | −)(function* (input) { | |
| 143 | − return yield* dispatch({ | |
| 144 | − ...input, | |
| 145 | − type: "thread.archive", | |
| 146 | − commandId: yield* commandId(input), | |
| 147 | − }); | |
| 191 | +const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( | |
| 192 | + mutation: | |
| 193 | + | { | |
| 194 | + readonly type: "project.create"; | |
| 195 | + readonly commandId: CommandId; | |
| 196 | + readonly projectId: ProjectId; | |
| 197 | + readonly title: string; | |
| 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( | |
| 151 | − "EnvironmentCommands.unarchiveThread", | |
| 152 | −)(function* (input) { | |
| 153 | − return yield* dispatch({ | |
| 154 | − ...input, | |
| 155 | − type: "thread.unarchive", | |
| 156 | − commandId: yield* commandId(input), | |
| 222 | +export const createProject = Effect.fn("EnvironmentCommands.createProject")(function* ( | |
| 223 | + input: CreateProjectInput, | |
| 224 | +) { | |
| 225 | + return yield* mutateProject({ | |
| 226 | + type: "project.create", | |
| 227 | + commandId: yield* allocateCommandId(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( | |
| 161 | − "EnvironmentCommands.settleThread", | |
| 162 | −)(function* (input) { | |
| 163 | − return yield* dispatch({ | |
| 164 | − ...input, | |
| 165 | − type: "thread.settle", | |
| 166 | − commandId: yield* commandId(input), | |
| 241 | +export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(function* ( | |
| 242 | + input: UpdateProjectInput, | |
| 243 | +) { | |
| 244 | + return yield* mutateProject({ | |
| 245 | + type: "project.update", | |
| 246 | + commandId: yield* allocateCommandId(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( | |
| 171 | − "EnvironmentCommands.unsettleThread", | |
| 172 | −)(function* (input) { | |
| 173 | − return yield* dispatch({ | |
| 174 | − ...input, | |
| 175 | − type: "thread.unsettle", | |
| 176 | − commandId: yield* commandId(input), | |
| 257 | +export const deleteProject = Effect.fn("EnvironmentCommands.deleteProject")(function* ( | |
| 258 | + input: DeleteProjectInput, | |
| 259 | +) { | |
| 260 | + return yield* mutateProject({ | |
| 261 | + type: "project.delete", | |
| 262 | + commandId: yield* allocateCommandId(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( | |
| 181 | − "EnvironmentCommands.snoozeThread", | |
| 182 | −)(function* (input) { | |
| 268 | +export const createThread = Effect.fn("EnvironmentCommands.createThread")(function* ( | |
| 269 | + input: CreateThreadInput, | |
| 270 | +) { | |
| 183 | 271 | return yield* dispatch({ |
| 184 | − ...input, | |
| 185 | − type: "thread.snooze", | |
| 186 | − commandId: yield* commandId(input), | |
| 272 | + type: "thread.create", | |
| 273 | + commandId: yield* allocateCommandId(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( | |
| 191 | − "EnvironmentCommands.unsnoozeThread", | |
| 192 | −)(function* (input) { | |
| 193 | − return yield* dispatch({ | |
| 194 | − ...input, | |
| 195 | − type: "thread.unsnooze", | |
| 196 | − commandId: yield* commandId(input), | |
| 197 | − }); | |
| 287 | +function simpleThreadCommand( | |
| 288 | + type: "thread.delete" | "thread.archive" | "thread.unarchive", | |
| 289 | + input: ThreadCommandInput, | |
| 290 | +) { | |
| 291 | + return allocateCommandId(input).pipe( | |
| 292 | + Effect.flatMap((commandId) => dispatch({ type, commandId, threadId: input.threadId })), | |
| 293 | + ); | |
| 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( | |
| 201 | − "EnvironmentCommands.updateThreadMetadata", | |
| 202 | −)(function* (input) { | |
| 203 | − return yield* dispatch({ | |
| 204 | − ...input, | |
| 205 | − type: "thread.meta.update", | |
| 206 | − commandId: yield* commandId(input), | |
| 207 | − }); | |
| 302 | +export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* ( | |
| 303 | + input: ArchiveThreadInput, | |
| 304 | +) { | |
| 305 | + return yield* simpleThreadCommand("thread.archive", input); | |
| 208 | 306 | }); |
| 209 | 307 | |
| 210 | −export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( | |
| 211 | − "EnvironmentCommands.setThreadRuntimeMode", | |
| 212 | −)(function* (input) { | |
| 213 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 214 | − return yield* dispatch({ | |
| 215 | − ...input, | |
| 216 | − type: "thread.runtime-mode.set", | |
| 217 | − commandId: metadata.commandId, | |
| 218 | − createdAt: metadata.createdAt, | |
| 219 | − }); | |
| 308 | +export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* ( | |
| 309 | + input: UnarchiveThreadInput, | |
| 310 | +) { | |
| 311 | + return yield* simpleThreadCommand("thread.unarchive", input); | |
| 220 | 312 | }); |
| 221 | 313 | |
| 222 | −export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect = | |
| 223 | − Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) { | |
| 224 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 314 | +export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadMetadata")( | |
| 315 | + function* (input: UpdateThreadMetadataInput) { | |
| 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 | 357 | return yield* dispatch({ |
| 226 | − ...input, | |
| 227 | 358 | type: "thread.interaction-mode.set", |
| 228 | − commandId: metadata.commandId, | |
| 229 | − createdAt: metadata.createdAt, | |
| 359 | + commandId: yield* allocateCommandId(input), | |
| 360 | + threadId: input.threadId, | |
| 361 | + interactionMode: input.interactionMode, | |
| 230 | 362 | }); |
| 231 | − }); | |
| 363 | + }, | |
| 364 | +); | |
| 232 | 365 | |
| 233 | −export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn( | |
| 234 | − "EnvironmentCommands.startThreadTurn", | |
| 235 | −)(function* (input) { | |
| 236 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 366 | +export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(function* ( | |
| 367 | + input: StartThreadTurnInput, | |
| 368 | +) { | |
| 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 | 455 | return yield* dispatch({ |
| 238 | − ...input, | |
| 239 | − type: "thread.turn.start", | |
| 240 | − commandId: metadata.commandId, | |
| 241 | − createdAt: metadata.createdAt, | |
| 456 | + type: "message.dispatch", | |
| 457 | + commandId, | |
| 458 | + createdBy: "user", | |
| 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( | |
| 246 | − "EnvironmentCommands.interruptThreadTurn", | |
| 247 | −)(function* (input) { | |
| 248 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 470 | +export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( | |
| 471 | + input: InterruptThreadTurnInput, | |
| 472 | +) { | |
| 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 | 481 | return yield* dispatch({ |
| 250 | − ...input, | |
| 251 | − type: "thread.turn.interrupt", | |
| 252 | − commandId: metadata.commandId, | |
| 253 | − createdAt: metadata.createdAt, | |
| 482 | + type: "run.interrupt", | |
| 483 | + commandId: yield* allocateCommandId(input), | |
| 484 | + threadId: input.threadId, | |
| 485 | + runId, | |
| 254 | 486 | }); |
| 255 | 487 | }); |
| 256 | 488 | |
| 257 | −export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = | |
| 258 | − Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { | |
| 259 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 489 | +export const respondToThreadApproval = Effect.fn("EnvironmentCommands.respondToThreadApproval")( | |
| 490 | + function* (input: RespondToThreadApprovalInput) { | |
| 260 | 491 | return yield* dispatch({ |
| 261 | − ...input, | |
| 262 | − type: "thread.approval.respond", | |
| 263 | − commandId: metadata.commandId, | |
| 264 | − createdAt: metadata.createdAt, | |
| 492 | + type: "runtime-request.respond", | |
| 493 | + commandId: yield* allocateCommandId(input), | |
| 494 | + threadId: input.threadId, | |
| 495 | + requestId: input.requestId, | |
| 496 | + decision: input.decision, | |
| 265 | 497 | }); |
| 266 | − }); | |
| 498 | + }, | |
| 499 | +); | |
| 267 | 500 | |
| 268 | −export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect = | |
| 269 | − Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) { | |
| 270 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 501 | +export const respondToThreadUserInput = Effect.fn("EnvironmentCommands.respondToThreadUserInput")( | |
| 502 | + function* (input: RespondToThreadUserInputInput) { | |
| 271 | 503 | return yield* dispatch({ |
| 272 | − ...input, | |
| 273 | − type: "thread.user-input.respond", | |
| 274 | − commandId: metadata.commandId, | |
| 275 | − createdAt: metadata.createdAt, | |
| 504 | + type: "runtime-request.respond", | |
| 505 | + commandId: yield* allocateCommandId(input), | |
| 506 | + threadId: input.threadId, | |
| 507 | + requestId: input.requestId, | |
| 508 | + answers: input.answers, | |
| 276 | 509 | }); |
| 277 | − }); | |
| 510 | + }, | |
| 511 | +); | |
| 278 | 512 | |
| 279 | −export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = | |
| 280 | − Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { | |
| 281 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 513 | +export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThreadCheckpoint")( | |
| 514 | + function* (input: RevertThreadCheckpointInput) { | |
| 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 | 522 | return yield* dispatch({ |
| 283 | − ...input, | |
| 284 | − type: "thread.checkpoint.revert", | |
| 285 | − commandId: metadata.commandId, | |
| 286 | − createdAt: metadata.createdAt, | |
| 523 | + type: "checkpoint.rollback", | |
| 524 | + commandId: yield* allocateCommandId(input), | |
| 525 | + threadId: input.threadId, | |
| 526 | + scopeId: checkpoint.scopeId, | |
| 527 | + checkpointId: checkpoint.id, | |
| 287 | 528 | }); |
| 288 | − }); | |
| 529 | + }, | |
| 530 | +); | |
| 289 | 531 | |
| 290 | −export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn( | |
| 291 | − "EnvironmentCommands.stopThreadSession", | |
| 292 | −)(function* (input) { | |
| 293 | − const metadata = yield* timestampedCommandMetadata(input); | |
| 294 | − return yield* dispatch({ | |
| 295 | − ...input, | |
| 296 | − type: "thread.session.stop", | |
| 297 | − commandId: metadata.commandId, | |
| 298 | − createdAt: metadata.createdAt, | |
| 299 | − }); | |
| 532 | +export const stopThreadSession = Effect.fn("EnvironmentCommands.stopThreadSession")(function* ( | |
| 533 | + input: StopThreadSessionInput, | |
| 534 | +) { | |
| 535 | + const projection = yield* getProjection(input.threadId); | |
| 536 | + const commandId = yield* allocateCommandId(input); | |
| 537 | + let result = { sequence: 0 }; | |
| 538 | + for (const session of projection.providerSessions) { | |
| 539 | + result = yield* dispatch({ | |
| 540 | + type: "provider-session.detach", | |
| 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 | 1 | export * from "./capabilities.ts"; |
| 2 | 2 | export * from "./persistence.ts"; |
| 3 | +export * from "./orchestrationCache.ts"; | |
| 3 | 4 | export * from "./source.ts"; |
| 4 | 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 | 1 | import { |
| 2 | 2 | type EnvironmentId, |
| 3 | − type OrchestrationShellSnapshot, | |
| 4 | − type OrchestrationThreadDetailSnapshot, | |
| 5 | − type ServerConfig, | |
| 3 | + type OrchestrationV2ShellSnapshot, | |
| 4 | + type OrchestrationV2ThreadProjection, | |
| 6 | 5 | type ThreadId, |
| 7 | 6 | type VcsListRefsResult, |
| 8 | 7 | } from "@t3tools/contracts"; |
| @@ -60,21 +59,18 @@ export class EnvironmentCacheStore extends Context.Service< · 51 unmodified lines | ||
| 60 | 59 | { |
| 61 | 60 | readonly loadShell: ( |
| 62 | 61 | environmentId: EnvironmentId, |
| 63 | − ) => Effect.Effect<Option.Option<OrchestrationShellSnapshot>, ConnectionPersistenceError>; | |
| 62 | + ) => Effect.Effect<Option.Option<OrchestrationV2ShellSnapshot>, ConnectionPersistenceError>; | |
| 64 | 63 | readonly saveShell: ( |
| 65 | 64 | environmentId: EnvironmentId, |
| 66 | − snapshot: OrchestrationShellSnapshot, | |
| 65 | + snapshot: OrchestrationV2ShellSnapshot, | |
| 67 | 66 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
| 68 | 67 | readonly loadThread: ( |
| 69 | 68 | environmentId: EnvironmentId, |
| 70 | 69 | threadId: ThreadId, |
| 71 | − ) => Effect.Effect< | |
| 72 | − Option.Option<OrchestrationThreadDetailSnapshot>, | |
| 73 | − ConnectionPersistenceError | |
| 74 | − >; | |
| 70 | + ) => Effect.Effect<Option.Option<OrchestrationV2ThreadProjection>, ConnectionPersistenceError>; | |
| 75 | 71 | readonly saveThread: ( |
| 76 | 72 | environmentId: EnvironmentId, |
| 77 | − snapshot: OrchestrationThreadDetailSnapshot, | |
| 73 | + thread: OrchestrationV2ThreadProjection, | |
| 78 | 74 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
| 79 | 75 | readonly removeThread: ( |
| 80 | 76 | environmentId: EnvironmentId, |
| @@ -1,8 +1,4 @@ | ||
| 1 | −import { | |
| 2 | − ORCHESTRATION_V2_WS_METHODS, | |
| 3 | − ORCHESTRATION_WS_METHODS, | |
| 4 | − WS_METHODS, | |
| 5 | −} from "@t3tools/contracts"; | |
| 1 | +import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; | |
| 6 | 2 | import * as Cause from "effect/Cause"; |
| 7 | 3 | import * as Context from "effect/Context"; |
| 8 | 4 | import type * as Duration from "effect/Duration"; |
| @@ -44,8 +40,8 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; · 35 unmodified lines | ||
| 44 | 40 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; |
| 45 | 41 | |
| 46 | 42 | export type EnvironmentSubscriptionRpcTag = |
| 47 | − | typeof ORCHESTRATION_WS_METHODS.subscribeShell | |
| 48 | − | typeof ORCHESTRATION_WS_METHODS.subscribeThread | |
| 43 | + | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell | |
| 44 | + | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread | |
| 49 | 45 | | typeof WS_METHODS.subscribeAuthAccess |
| 50 | 46 | | typeof WS_METHODS.subscribeServerConfig |
| 51 | 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 | 2 | import * as Cause from "effect/Cause"; |
| 3 | 3 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 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 | 22 | const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({ |
| 23 | 23 | getSnapshotAtom: () => |
| 24 | 24 | Atom.make( |
| 25 | − AsyncResult.failure<OrchestrationShellSnapshot, Error>( | |
| 25 | + AsyncResult.failure<OrchestrationV2ArchivedShellSnapshot, Error>( | |
| 26 | 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 | 2 | import * as Arr from "effect/Array"; |
| 3 | 3 | import { pipe } from "effect/Function"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| @@ -7,7 +7,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; · 2 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | export interface ArchivedSnapshotEntry { |
| 9 | 9 | readonly environmentId: EnvironmentId; |
| 10 | − readonly snapshot: OrchestrationShellSnapshot; | |
| 10 | + readonly snapshot: OrchestrationV2ArchivedShellSnapshot; | |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | 13 | export interface ArchivedThreadSnapshotsState { |
| @@ -40,7 +40,7 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E · 26 unmodified lines | ||
| 40 | 40 | export function createArchivedThreadSnapshotsAtomFamily<E>(options: { |
| 41 | 41 | readonly getSnapshotAtom: ( |
| 42 | 42 | environmentId: EnvironmentId, |
| 43 | − ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationShellSnapshot, E>>; | |
| 43 | + ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationV2ArchivedShellSnapshot, E>>; | |
| 44 | 44 | readonly labelPrefix: string; |
| 45 | 45 | }) { |
| 46 | 46 | return Atom.family((environmentKey: string) => |
| @@ -1,370 +1,115 @@ | ||
| 1 | −import { | |
| 2 | − EnvironmentId, | |
| 3 | − ProjectId, | |
| 4 | − ProviderInstanceId, | |
| 5 | − ThreadId, | |
| 6 | − type OrchestrationShellSnapshot, | |
| 7 | − type OrchestrationThread, | |
| 8 | −} from "@t3tools/contracts"; | |
| 9 | −import { describe, expect, it } from "@effect/vitest"; | |
| 10 | −import * as Option from "effect/Option"; | |
| 11 | −import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 12 | − | |
| 13 | −import { PrimaryConnectionTarget } from "../connection/model.ts"; | |
| 14 | −import { | |
| 15 | − InvalidScopedProjectKeyError, | |
| 16 | − InvalidScopedProjectRefCollectionKeyError, | |
| 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() }); | |
| 1 | +import { EnvironmentId, MessageId, RunId, TurnItemId } from "@t3tools/contracts"; | |
| 2 | +import * as DateTime from "effect/DateTime"; | |
| 3 | +import { describe, expect, it } from "vite-plus/test"; | |
| 4 | + | |
| 5 | +import { presentThread, presentThreadShell } from "./models.ts"; | |
| 6 | +import { v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; | |
| 7 | + | |
| 8 | +const environmentId = EnvironmentId.make("environment-v2"); | |
| 9 | + | |
| 10 | +describe("V2 client presentation", () => { | |
| 11 | + it("presents shell timestamps and status without constructing V1 state", () => { | |
| 12 | + const shell = presentThreadShell(environmentId, v2ThreadShell); | |
| 13 | + expect(shell.environmentId).toBe(environmentId); | |
| 14 | + expect(shell.createdAt).toBe("2026-06-20T00:00:00.000Z"); | |
| 15 | + expect(shell.runtime).toBeNull(); | |
| 16 | + expect(shell.source).toBe(v2ThreadShell); | |
| 75 | 17 | }); |
| 76 | 18 | |
| 77 | − it("rejects invalid project reference collection shapes", () => { | |
| 78 | − const key = JSON.stringify([["environment-1"]]); | |
| 79 | − | |
| 80 | − expect(() => parseProjectRefCollectionKey(key)).toThrowError( | |
| 81 | − InvalidScopedProjectRefCollectionKeyError, | |
| 82 | − ); | |
| 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, | |
| 19 | + it("retains the complete projection while deriving conversation parity", () => { | |
| 20 | + const runId = RunId.make("run-1"); | |
| 21 | + const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"); | |
| 22 | + const projection = { | |
| 23 | + ...v2Projection, | |
| 24 | + runs: [ | |
| 164 | 25 | { |
| 165 | − target: new PrimaryConnectionTarget({ | |
| 166 | − environmentId: ENVIRONMENT_ID, | |
| 167 | − label: "Environment", | |
| 168 | − httpBaseUrl: "https://example.test", | |
| 169 | − wsBaseUrl: "wss://example.test", | |
| 170 | − }), | |
| 171 | − profile: Option.none(), | |
| 26 | + id: runId, | |
| 27 | + threadId: v2Projection.thread.id, | |
| 28 | + ordinal: 1, | |
| 29 | + providerInstanceId: v2Projection.thread.providerInstanceId, | |
| 30 | + modelSelection: v2Projection.thread.modelSelection, | |
| 31 | + providerThreadId: null, | |
| 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 | − ]), | |
| 175 | − }); | |
| 176 | − const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); | |
| 177 | − const projects = createEnvironmentProjectAtoms({ | |
| 178 | − catalogValueAtom, | |
| 179 | − snapshotAtom, | |
| 180 | − }); | |
| 181 | − const threadShells = createEnvironmentThreadShellAtoms({ | |
| 182 | − catalogValueAtom, | |
| 183 | − snapshotAtom, | |
| 184 | − }); | |
| 185 | − const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) => | |
| 186 | − threadStateAtoms(`${environmentId}\u0000${threadId}`), | |
| 187 | − ); | |
| 188 | − | |
| 189 | − return { | |
| 190 | − registry: AtomRegistry.make(), | |
| 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, | |
| 43 | + messages: [ | |
| 44 | + { | |
| 45 | + id: MessageId.make("message-user"), | |
| 46 | + threadId: v2Projection.thread.id, | |
| 47 | + runId, | |
| 48 | + nodeId: null, | |
| 49 | + role: "user" as const, | |
| 50 | + text: "Hello", | |
| 51 | + attachments: [], | |
| 52 | + streaming: false, | |
| 53 | + createdBy: "user" as const, | |
| 54 | + creationSource: "web" as const, | |
| 55 | + createdAt: now, | |
| 56 | + updatedAt: now, | |
| 57 | + }, | |
| 58 | + ], | |
| 59 | + updatedAt: now, | |
| 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( | |
| 289 | − harness.shellStateAtom, | |
| 290 | − AsyncResult.success( | |
| 291 | − shellState({ | |
| 292 | − ...SNAPSHOT, | |
| 293 | − snapshotSequence: 2, | |
| 294 | − threads: SNAPSHOT.threads.map((thread) => | |
| 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); | |
| 62 | + const thread = presentThread(environmentId, projection); | |
| 63 | + expect(thread.projection).toBe(projection); | |
| 64 | + expect(thread.latestRun?.runId).toBe(runId); | |
| 65 | + expect(thread.runtime?.status).toBe("running"); | |
| 66 | + expect(thread.runs).toHaveLength(1); | |
| 67 | + expect(thread.availableActions.canInterrupt).toBe(true); | |
| 68 | + expect(thread.messages[0]).toMatchObject({ text: "Hello", runId }); | |
| 303 | 69 | }); |
| 304 | 70 | |
| 305 | − it("updates only the requested thread detail and preserves untouched field identities", () => { | |
| 306 | − const harness = makeHarness(); | |
| 307 | − const threadRef = { | |
| 308 | − environmentId: ENVIRONMENT_ID, | |
| 309 | − threadId: THREAD_ID, | |
| 71 | + it("keeps the complete payload for generic V2 work-item rendering", () => { | |
| 72 | + const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"); | |
| 73 | + const item = { | |
| 74 | + id: TurnItemId.make("item-dynamic"), | |
| 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 = { | |
| 312 | − environmentId: ENVIRONMENT_ID, | |
| 313 | − threadId: OTHER_THREAD_ID, | |
| 93 | + const projection = { | |
| 94 | + ...v2Projection, | |
| 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 }); | |
| 344 | − expect(harness.registry.get(statusAtom)).toBe("live"); | |
| 345 | − expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue); | |
| 346 | − | |
| 347 | − harness.registry.set( | |
| 348 | − harness.threadStateAtom(THREAD_ID), | |
| 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); | |
| 108 | + const thread = presentThread(environmentId, projection); | |
| 109 | + expect(thread.workEntries[0]).toMatchObject({ | |
| 110 | + itemType: "dynamic_tool", | |
| 111 | + label: "custom_tool", | |
| 112 | + structuredPayload: item, | |
| 113 | + }); | |
| 369 | 114 | }); |
| 370 | 115 | }); |
| @@ -1,25 +1,459 @@ | ||
| 1 | 1 | import type { |
| 2 | + ChatAttachment, | |
| 3 | + CheckpointId, | |
| 4 | + CheckpointRef, | |
| 5 | + CheckpointScopeId, | |
| 2 | 6 | EnvironmentId, |
| 3 | − OrchestrationMessage, | |
| 7 | + MessageId, | |
| 4 | 8 | OrchestrationProjectShell, |
| 5 | − OrchestrationShellSnapshot, | |
| 6 | − OrchestrationThread, | |
| 7 | − OrchestrationThreadShell, | |
| 9 | + OrchestrationV2PlanArtifact, | |
| 10 | + OrchestrationV2ProviderCapabilities, | |
| 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 | 23 | ThreadId, |
| 9 | 24 | } from "@t3tools/contracts"; |
| 25 | +import * as DateTime from "effect/DateTime"; | |
| 10 | 26 | |
| 11 | 27 | export interface EnvironmentProject extends OrchestrationProjectShell { |
| 12 | 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 | 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 | 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 | 459 | export function scopeProject( |
| @@ -29,25 +463,281 @@ export function scopeProject( · 3 unmodified lines | ||
| 29 | 463 | return { ...project, environmentId }; |
| 30 | 464 | } |
| 31 | 465 | |
| 32 | −export function scopeThreadShell( | |
| 466 | +export function presentThreadShell( | |
| 33 | 467 | environmentId: EnvironmentId, |
| 34 | − thread: OrchestrationThreadShell, | |
| 468 | + thread: OrchestrationV2ThreadShell, | |
| 35 | 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 | 649 | environmentId: EnvironmentId, |
| 41 | − thread: OrchestrationThread, | |
| 650 | + projection: OrchestrationV2ThreadProjection, | |
| 42 | 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 | 732 | export function selectEnvironmentThreadShell( |
| 47 | − snapshot: OrchestrationShellSnapshot | null, | |
| 733 | + snapshot: OrchestrationV2ShellSnapshot | null, | |
| 48 | 734 | environmentId: EnvironmentId, |
| 49 | 735 | threadId: ThreadId, |
| 50 | 736 | ): EnvironmentThreadShell | null { |
| 51 | 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 | 2 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 3 | |
| 4 | 4 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| @@ -10,11 +10,11 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 5 unmodified lines | ||
| 10 | 10 | return { |
| 11 | 11 | turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 12 | 12 | label: "environment-data:orchestration:turn-diff", |
| 13 | − tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 13 | + tag: ORCHESTRATION_V2_WS_METHODS.getTurnDiff, | |
| 14 | 14 | }), |
| 15 | 15 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 16 | 16 | label: "environment-data:orchestration:full-thread-diff", |
| 17 | − tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, | |
| 17 | + tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, | |
| 18 | 18 | }), |
| 19 | 19 | threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 20 | 20 | label: "environment-data:orchestration:thread-search", |
| @@ -24,7 +24,7 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 3 unmodified lines | ||
| 24 | 24 | }), |
| 25 | 25 | archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 26 | 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 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import { |
| 3 | 3 | type OrchestrationV2DomainEvent, |
| 4 | + type OrchestrationV2Run, | |
| 4 | 5 | type OrchestrationV2ThreadProjection, |
| 6 | + type OrchestrationV2TurnItem, | |
| 7 | + MessageId, | |
| 5 | 8 | ProjectId, |
| 6 | 9 | ProviderInstanceId, |
| 10 | + RunId, | |
| 7 | 11 | ThreadId, |
| 12 | + TurnItemId, | |
| 8 | 13 | } from "@t3tools/contracts"; |
| 9 | 14 | import * as DateTime from "effect/DateTime"; |
| 10 | 15 | |
| @@ -12,6 +17,47 @@ import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection · 1 unmodified line | ||
| 12 | 17 | |
| 13 | 18 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 14 | 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 | 61 | const emptyProjection = { |
| 16 | 62 | thread: { |
| 17 | 63 | id: threadId, |
| @@ -79,4 +125,112 @@ describe("applyOrchestrationV2ProjectionEvent", () => { · 61 unmodified lines | ||
| 79 | 125 | |
| 80 | 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 | 20 | rows: OrchestrationV2ThreadProjection["visibleTurnItems"], |
| 21 | 21 | sourceItemId: OrchestrationV2TurnItem["id"], |
| 22 | 22 | ): OrchestrationV2ThreadProjection["visibleTurnItems"] { |
| 23 | − return rows | |
| 24 | − .filter((row) => row.sourceItemId !== sourceItemId) | |
| 25 | − .map((row, position) => ({ ...row, position })); | |
| 23 | + const index = rows.findIndex((row) => row.sourceItemId === sourceItemId); | |
| 24 | + if (index === -1) return rows; | |
| 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 | 34 | function shouldShowLocalTurnItem( |
| @@ -39,9 +45,18 @@ function shouldShowLocalTurnItem( · 10 unmodified lines | ||
| 39 | 45 | function activeVisibleTurnItems( |
| 40 | 46 | projection: OrchestrationV2ThreadProjection, |
| 41 | 47 | ): OrchestrationV2ThreadProjection["visibleTurnItems"] { |
| 42 | − return projection.visibleTurnItems | |
| 43 | − .filter((row) => row.visibility !== "local" || shouldShowLocalTurnItem(projection, row.item)) | |
| 44 | − .map((row, position) => ({ ...row, position })); | |
| 48 | + const rows = projection.visibleTurnItems; | |
| 49 | + let next: Array<(typeof rows)[number]> | null = null; | |
| 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 | 62 | function upsertVisibleTurnItem( |
| @@ -57,9 +72,19 @@ function upsertVisibleTurnItem( · 9 unmodified lines | ||
| 57 | 72 | sourceItemId: item.id, |
| 58 | 73 | item, |
| 59 | 74 | }; |
| 60 | − return index === -1 | |
| 61 | − ? [...rows, next] | |
| 62 | − : rows.map((row, rowIndex) => (rowIndex === index ? next : row)); | |
| 75 | + if (index === -1) return [...rows, next]; | |
| 76 | + const previous = rows[index]!; | |
| 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 | 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 | 1 | import type { |
| 2 | 2 | EnvironmentId, |
| 3 | 3 | OrchestrationProjectShell, |
| 4 | − OrchestrationShellSnapshot, | |
| 4 | + OrchestrationV2ShellSnapshot, | |
| 5 | 5 | ProjectId, |
| 6 | 6 | ScopedProjectRef, |
| 7 | 7 | } from "@t3tools/contracts"; |
| @@ -19,7 +19,7 @@ export function createEnvironmentProjectAtoms(input: { · 11 unmodified lines | ||
| 19 | 19 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; |
| 20 | 20 | readonly snapshotAtom: ( |
| 21 | 21 | environmentId: EnvironmentId, |
| 22 | − ) => Atom.Atom<OrchestrationShellSnapshot | null>; | |
| 22 | + ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>; | |
| 23 | 23 | }) { |
| 24 | 24 | const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => |
| 25 | 25 | Atom.make( |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | EnvironmentId, |
| 3 | − ORCHESTRATION_WS_METHODS, | |
| 4 | − type OrchestrationShellSnapshot, | |
| 5 | − type OrchestrationShellStreamItem, | |
| 3 | + ORCHESTRATION_V2_WS_METHODS, | |
| 4 | + type OrchestrationV2ShellSnapshot, | |
| 5 | + type OrchestrationV2ShellStreamItem, | |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| @@ -22,7 +22,8 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; · 13 unmodified lines | ||
| 22 | 22 | import * as Persistence from "../platform/persistence.ts"; |
| 23 | 23 | import * as RpcSession from "../rpc/session.ts"; |
| 24 | 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 | 28 | const TARGET = new PrimaryConnectionTarget({ |
| 28 | 29 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -31,20 +32,9 @@ const TARGET = new PrimaryConnectionTarget({ · 2 unmodified lines | ||
| 31 | 32 | wsBaseUrl: "wss://environment.example.test", |
| 32 | 33 | }); |
| 33 | 34 | |
| 34 | −const PREPARED: PreparedConnection = { | |
| 35 | − environmentId: TARGET.environmentId, | |
| 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 = { | |
| 35 | +const LIVE_SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = { | |
| 36 | + ...v2ShellSnapshot, | |
| 44 | 37 | snapshotSequence: 1, |
| 45 | − projects: [], | |
| 46 | − threads: [], | |
| 47 | − updatedAt: "2026-06-06T00:00:00.000Z", | |
| 48 | 38 | }; |
| 49 | 39 | |
| 50 | 40 | function session(client: WsRpcProtocolClient): RpcSession.RpcSession { |
| @@ -60,9 +50,9 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { · 9 unmodified lines | ||
| 60 | 50 | describe("environment shell synchronization", () => { |
| 61 | 51 | it.effect("publishes live state before persistence and preserves it when ready", () => |
| 62 | 52 | Effect.gen(function* () { |
| 63 | − const events = yield* Queue.unbounded<OrchestrationShellStreamItem>(); | |
| 53 | + const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>(); | |
| 64 | 54 | const client = { |
| 65 | − [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | |
| 55 | + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | |
| 66 | 56 | } as unknown as WsRpcProtocolClient; |
| 67 | 57 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); |
| 68 | 58 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( |
| @@ -2,11 +2,13 @@ import type { ServerConfig } from "@t3tools/contracts"; · 1 unmodified line | ||
| 2 | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | +import * as DateTime from "effect/DateTime"; | |
| 5 | 6 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 6 | 7 | |
| 7 | 8 | import { PrimaryConnectionTarget } from "../connection/model.ts"; |
| 8 | 9 | import type { EnvironmentShellState } from "./shell.ts"; |
| 9 | 10 | import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; |
| 11 | +import { v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; | |
| 10 | 12 | |
| 11 | 13 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 12 | 14 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); |
| @@ -34,10 +36,16 @@ function shellState(input: { · 21 unmodified lines | ||
| 34 | 36 | input.updatedAt === undefined |
| 35 | 37 | ? Option.none() |
| 36 | 38 | : Option.some({ |
| 39 | + schemaVersion: 1, | |
| 37 | 40 | snapshotSequence: input.snapshotSequence ?? 1, |
| 38 | − updatedAt: input.updatedAt, | |
| 39 | 41 | projects: [], |
| 40 | − threads: [], | |
| 42 | + threads: [ | |
| 43 | + { | |
| 44 | + ...v2ThreadShell, | |
| 45 | + updatedAt: DateTime.makeUnsafe(input.updatedAt), | |
| 46 | + }, | |
| 47 | + ], | |
| 48 | + archivedThreads: [], | |
| 41 | 49 | }), |
| 42 | 50 | status: input.status, |
| 43 | 51 | error: input.error === undefined ? Option.none() : Option.some(input.error), |
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | import { |
| 2 | − ORCHESTRATION_WS_METHODS, | |
| 2 | + ORCHESTRATION_V2_WS_METHODS, | |
| 3 | 3 | type EnvironmentId, |
| 4 | − type OrchestrationShellSnapshot, | |
| 5 | − type OrchestrationShellStreamItem, | |
| 4 | + type OrchestrationV2ShellSnapshot, | |
| 5 | + type OrchestrationV2ShellStreamItem, | |
| 6 | 6 | type ServerConfig, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import * as Cause from "effect/Cause"; |
| 9 | +import * as DateTime from "effect/DateTime"; | |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 10 | 11 | import * as Option from "effect/Option"; |
| 11 | 12 | import * as Queue from "effect/Queue"; |
| @@ -29,7 +30,7 @@ import { followStreamInEnvironment } from "./runtime.ts"; · 17 unmodified lines | ||
| 29 | 30 | export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; |
| 30 | 31 | |
| 31 | 32 | export interface EnvironmentShellState { |
| 32 | − readonly snapshot: Option.Option<OrchestrationShellSnapshot>; | |
| 33 | + readonly snapshot: Option.Option<OrchestrationV2ShellSnapshot>; | |
| 33 | 34 | readonly status: EnvironmentShellStatus; |
| 34 | 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 | 44 | function shellStatusForSnapshot( |
| 44 | − snapshot: Option.Option<OrchestrationShellSnapshot>, | |
| 45 | + snapshot: Option.Option<OrchestrationV2ShellSnapshot>, | |
| 45 | 46 | ): EnvironmentShellStatus { |
| 46 | 47 | return Option.isSome(snapshot) ? "cached" : "empty"; |
| 47 | 48 | } |
| @@ -61,7 +62,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 13 unmodified lines | ||
| 61 | 62 | environmentId, |
| 62 | 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 | 71 | status: shellStatusForSnapshot(cachedSnapshot), |
| 71 | 72 | error: Option.none(), |
| 72 | 73 | }); |
| 73 | − const awaitingCompletion = yield* Ref.make(false); | |
| 74 | − const persistence = yield* Queue.sliding<OrchestrationShellSnapshot>(1); | |
| 74 | + const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1); | |
| 75 | 75 | |
| 76 | 76 | const persist = Effect.fn("EnvironmentShellState.persist")(function* ( |
| 77 | − snapshot: OrchestrationShellSnapshot, | |
| 77 | + snapshot: OrchestrationV2ShellSnapshot, | |
| 78 | 78 | ) { |
| 79 | 79 | yield* cache.saveShell(environmentId, snapshot).pipe( |
| 80 | 80 | Effect.catch((error) => |
| @@ -133,7 +133,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 52 unmodified lines | ||
| 133 | 133 | ); |
| 134 | 134 | |
| 135 | 135 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( |
| 136 | − item: OrchestrationShellStreamItem, | |
| 136 | + item: OrchestrationV2ShellStreamItem, | |
| 137 | 137 | ) { |
| 138 | 138 | if (item.kind === "synchronized") { |
| 139 | 139 | yield* Ref.set(awaitingCompletion, false); |
| @@ -169,56 +169,13 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 29 unmodified lines | ||
| 169 | 169 | yield* Queue.offer(persistence, nextSnapshot); |
| 170 | 170 | }); |
| 171 | 171 | |
| 172 | − const foregroundResubscriptions = Option.match(wakeups, { | |
| 173 | − onNone: () => Stream.never, | |
| 174 | − onSome: (service) => | |
| 175 | − service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), | |
| 176 | − }); | |
| 177 | − | |
| 178 | − yield* setSynchronizing; | |
| 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 | − ); | |
| 172 | + yield* subscribe( | |
| 173 | + ORCHESTRATION_V2_WS_METHODS.subscribeShell, | |
| 174 | + {}, | |
| 175 | + { | |
| 176 | + onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), | |
| 177 | + }, | |
| 178 | + ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); | |
| 222 | 179 | yield* SubscriptionRef.changes(supervisor.state).pipe( |
| 223 | 180 | Stream.runForEach((connectionState) => { |
| 224 | 181 | switch (connectionProjectionPhase(connectionState)) { |
| @@ -314,8 +271,20 @@ export function createEnvironmentShellSummaryAtom(input: { · 89 unmodified lines | ||
| 314 | 271 | continue; |
| 315 | 272 | } |
| 316 | 273 | hasSnapshot = true; |
| 317 | − const updatedAt = state.snapshot.value.updatedAt; | |
| 318 | − if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) { | |
| 274 | + const snapshot = state.snapshot.value; | |
| 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 | 288 | latestSnapshotUpdatedAt = updatedAt; |
| 320 | 289 | } |
| 321 | 290 | } |
| @@ -1,185 +1,84 @@ | ||
| 1 | +import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | −import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 4 | −import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | |
| 5 | − | |
| 4 | +import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; | |
| 6 | 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 | 7 | describe("applyShellStreamEvent", () => { |
| 49 | − it("ignores stale project upserts without mutating the snapshot", () => { | |
| 50 | − const snapshotWithProject: OrchestrationShellSnapshot = { | |
| 51 | − ...baseSnapshot, | |
| 8 | + it("ignores stale project updates without mutating the snapshot", () => { | |
| 9 | + const snapshotWithProject = { | |
| 10 | + ...v2ShellSnapshot, | |
| 52 | 11 | snapshotSequence: 4, |
| 53 | − projects: [stubProject], | |
| 12 | + projects: [v2Project], | |
| 54 | 13 | }; |
| 55 | 14 | |
| 56 | 15 | for (const sequence of [3, 4]) { |
| 57 | 16 | const next = applyShellStreamEvent(snapshotWithProject, { |
| 58 | − kind: "project-upserted", | |
| 17 | + kind: "project.updated", | |
| 59 | 18 | sequence, |
| 60 | − project: { ...stubProject, title: "Stale Title" }, | |
| 19 | + project: { ...v2Project, title: "Stale Title" }, | |
| 61 | 20 | }); |
| 62 | 21 | |
| 63 | 22 | expect(next).toBe(snapshotWithProject); |
| 64 | 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", () => { | |
| 70 | − it("adds a new project", () => { | |
| 71 | − const event: OrchestrationShellStreamEvent = { | |
| 72 | − kind: "project-upserted", | |
| 73 | − sequence: 1, | |
| 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); | |
| 28 | + it("applies project updates and removals", () => { | |
| 29 | + const updated = applyShellStreamEvent(v2ShellSnapshot, { | |
| 30 | + kind: "project.updated", | |
| 31 | + sequence: 1, | |
| 32 | + project: { ...v2Project, title: "Updated" }, | |
| 82 | 33 | }); |
| 34 | + expect(updated.projects[0]?.title).toBe("Updated"); | |
| 35 | + expect(updated.snapshotSequence).toBe(1); | |
| 83 | 36 | |
| 84 | − it("updates an existing project", () => { | |
| 85 | − const snapshotWithProject: OrchestrationShellSnapshot = { | |
| 86 | − ...baseSnapshot, | |
| 87 | − projects: [stubProject], | |
| 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); | |
| 37 | + const removed = applyShellStreamEvent(updated, { | |
| 38 | + kind: "project.removed", | |
| 39 | + sequence: 2, | |
| 40 | + projectId: ProjectId.make(v2Project.id), | |
| 122 | 41 | }); |
| 42 | + expect(removed.projects).toEqual([]); | |
| 123 | 43 | }); |
| 124 | 44 | |
| 125 | − describe("thread-upserted", () => { | |
| 126 | − it("adds a new thread", () => { | |
| 127 | − const event: OrchestrationShellStreamEvent = { | |
| 128 | − kind: "thread-upserted", | |
| 129 | − sequence: 4, | |
| 130 | − thread: stubThread, | |
| 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); | |
| 45 | + it("moves a thread between active and archive without duplicating it", () => { | |
| 46 | + const archived = applyShellStreamEvent(v2ShellSnapshot, { | |
| 47 | + kind: "thread.updated", | |
| 48 | + sequence: 3, | |
| 49 | + location: "archive", | |
| 50 | + thread: { ...v2ThreadShell, archivedAt: v2ThreadShell.updatedAt }, | |
| 138 | 51 | }); |
| 139 | − | |
| 140 | − it("updates an existing thread", () => { | |
| 141 | − const snapshotWithThread: OrchestrationShellSnapshot = { | |
| 142 | − ...baseSnapshot, | |
| 143 | − threads: [stubThread], | |
| 144 | − }; | |
| 145 | − | |
| 146 | − const updatedThread = { ...stubThread, title: "Updated Thread" }; | |
| 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"); | |
| 52 | + expect(archived.threads).toEqual([]); | |
| 53 | + expect(archived.archivedThreads).toHaveLength(1); | |
| 54 | + | |
| 55 | + const active = applyShellStreamEvent(archived, { | |
| 56 | + kind: "thread.updated", | |
| 57 | + sequence: 4, | |
| 58 | + location: "active", | |
| 59 | + thread: v2ThreadShell, | |
| 157 | 60 | }); |
| 61 | + expect(active.threads).toHaveLength(1); | |
| 62 | + expect(active.archivedThreads).toEqual([]); | |
| 158 | 63 | }); |
| 159 | 64 | |
| 160 | − describe("thread-removed", () => { | |
| 161 | − it("removes a thread by id", () => { | |
| 162 | − const snapshotWithThread: OrchestrationShellSnapshot = { | |
| 163 | − ...baseSnapshot, | |
| 164 | − threads: [stubThread], | |
| 165 | − }; | |
| 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); | |
| 65 | + it("removes a thread from either collection", () => { | |
| 66 | + const next = applyShellStreamEvent(v2ShellSnapshot, { | |
| 67 | + kind: "thread.removed", | |
| 68 | + sequence: 5, | |
| 69 | + location: "active", | |
| 70 | + threadId: ThreadId.make(v2ThreadShell.id), | |
| 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", () => { | |
| 181 | − const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; | |
| 182 | − const next = applyShellStreamEvent(baseSnapshot, unknownEvent); | |
| 183 | − expect(next).toBe(baseSnapshot); | |
| 76 | + it("leaves unknown future events unchanged", () => { | |
| 77 | + const next = applyShellStreamEvent( | |
| 78 | + v2ShellSnapshot, | |
| 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"; | |
| 2 | −import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | |
| 1 | +import type { | |
| 2 | + OrchestrationV2ShellSnapshot, | |
| 3 | + OrchestrationV2ShellStreamItem, | |
| 4 | +} from "@t3tools/contracts"; | |
| 3 | 5 | |
| 4 | −/** | |
| 5 | − * Reduce a single shell stream event into an existing snapshot, returning a new | |
| 6 | − * snapshot with the event's changes applied. This is a pure reducer that both | |
| 7 | − * web and mobile can use to keep their local shell snapshot in sync. | |
| 8 | − * | |
| 9 | − * Returns the original snapshot reference unchanged if the event is not | |
| 10 | − * recognized (forward-compatible). | |
| 11 | − */ | |
| 6 | +function upsertById<T extends { readonly id: unknown }>( | |
| 7 | + items: ReadonlyArray<T>, | |
| 8 | + item: T, | |
| 9 | +): ReadonlyArray<T> { | |
| 10 | + const index = items.findIndex((candidate) => candidate.id === item.id); | |
| 11 | + if (index === -1) return [...items, item]; | |
| 12 | + return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate)); | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** Applies one committed V2 shell delta while preserving active/archive exclusivity. */ | |
| 12 | 16 | export function applyShellStreamEvent( |
| 13 | − snapshot: OrchestrationShellSnapshot, | |
| 14 | − event: OrchestrationShellStreamEvent, | |
| 15 | −): OrchestrationShellSnapshot { | |
| 17 | + snapshot: OrchestrationV2ShellSnapshot, | |
| 18 | + event: Exclude<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }>, | |
| 19 | +): OrchestrationV2ShellSnapshot { | |
| 16 | 20 | if (event.sequence <= snapshot.snapshotSequence) return snapshot; |
| 17 | 21 | |
| 18 | 22 | switch (event.kind) { |
| 19 | − case "project-upserted": { | |
| 20 | − const projects = snapshot.projects.some((p) => p.id === event.project.id) | |
| 21 | − ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p)) | |
| 22 | − : Arr.append(snapshot.projects, event.project); | |
| 23 | − return { ...snapshot, projects, snapshotSequence: event.sequence }; | |
| 24 | − } | |
| 25 | − case "project-removed": | |
| 23 | + case "project.updated": | |
| 24 | + return { | |
| 25 | + ...snapshot, | |
| 26 | + projects: upsertById(snapshot.projects, event.project), | |
| 27 | + snapshotSequence: event.sequence, | |
| 28 | + }; | |
| 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 | 38 | return { |
| 27 | 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 | 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 | 52 | return { |
| 39 | 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 | 56 | snapshotSequence: event.sequence, |
| 42 | 57 | }; |
| 43 | 58 | default: |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; | |
| 1 | +import type { EnvironmentId, OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| @@ -10,7 +10,7 @@ export function createEnvironmentSnapshotAtom<E>( · 5 unmodified lines | ||
| 10 | 10 | ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>, |
| 11 | 11 | ) { |
| 12 | 12 | return Atom.family((environmentId: EnvironmentId) => |
| 13 | − Atom.make((get): OrchestrationShellSnapshot | null => | |
| 13 | + Atom.make((get): OrchestrationV2ShellSnapshot | null => | |
| 14 | 14 | Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), { |
| 15 | 15 | onNone: () => null, |
| 16 | 16 | onSome: (state) => Option.getOrNull(state.snapshot), |
| @@ -54,6 +54,7 @@ export type { · 53 unmodified lines | ||
| 54 | 54 | SnoozeThreadInput, |
| 55 | 55 | StartThreadTurnInput, |
| 56 | 56 | StopThreadSessionInput, |
| 57 | + ThreadCommandInput, | |
| 57 | 58 | UnarchiveThreadInput, |
| 58 | 59 | UnsettleThreadInput, |
| 59 | 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 { | |
| 2 | − OrchestrationCheckpointSummary, | |
| 3 | − OrchestrationLatestTurn, | |
| 4 | − OrchestrationMessage, | |
| 5 | − OrchestrationProposedPlan, | |
| 6 | − OrchestrationSession, | |
| 7 | − OrchestrationThread, | |
| 8 | − OrchestrationThreadActivity, | |
| 9 | − ScopedThreadRef, | |
| 10 | −} from "@t3tools/contracts"; | |
| 1 | +import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; | |
| 11 | 2 | import * as Option from "effect/Option"; |
| 12 | 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 13 | 4 | |
| 14 | −import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; | |
| 15 | −import { scopeThread } from "./models.ts"; | |
| 16 | −import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts"; | |
| 5 | +import type { | |
| 6 | + EnvironmentThread, | |
| 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 | 20 | import { parseThreadKey, threadKey } from "./entities.ts"; |
| 18 | 21 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 19 | 22 | |
| 20 | −const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]); | |
| 21 | −const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]); | |
| 22 | −const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]); | |
| 23 | −const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]); | |
| 23 | +const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]); | |
| 24 | +const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]); | |
| 25 | +const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = 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 | −/** | |
| 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 | − */ | |
| 33 | +/** Shell metadata wins over an independently cached detail projection. */ | |
| 34 | 34 | export function mergeEnvironmentThread( |
| 35 | 35 | detail: EnvironmentThread | null, |
| 36 | 36 | shell: EnvironmentThreadShell | null, |
| 37 | 37 | ): EnvironmentThread | null { |
| 38 | − if (detail === null || shell === null) { | |
| 39 | − return detail; | |
| 40 | − } | |
| 41 | − if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) { | |
| 42 | − return detail; | |
| 43 | − } | |
| 44 | − | |
| 38 | + if (detail === null || shell === null) return detail; | |
| 39 | + if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) return detail; | |
| 45 | 40 | return { |
| 46 | 41 | ...detail, |
| 47 | − environmentId: shell.environmentId, | |
| 48 | − id: shell.id, | |
| 49 | − projectId: shell.projectId, | |
| 50 | − title: shell.title, | |
| 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, | |
| 42 | + ...shell, | |
| 43 | + latestRun: detail.latestRun, | |
| 44 | + runtime: detail.runtime, | |
| 45 | + source: shell.source, | |
| 65 | 46 | }; |
| 66 | 47 | } |
| 67 | 48 | |
| @@ -86,15 +67,13 @@ export function createEnvironmentThreadDetailAtoms<E>( · 18 unmodified lines | ||
| 86 | 67 | |
| 87 | 68 | const threadDetailAtomFamily = Atom.family((key: string) => { |
| 88 | 69 | const ref = parseThreadKey(key); |
| 89 | − let previousSource: OrchestrationThread | null = null; | |
| 70 | + let previousSource: OrchestrationV2ThreadProjection | null = null; | |
| 90 | 71 | let previousValue: EnvironmentThread | null = null; |
| 91 | 72 | return Atom.make((get) => { |
| 92 | 73 | const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); |
| 93 | − if (source === previousSource) { | |
| 94 | − return previousValue; | |
| 95 | − } | |
| 74 | + if (source === previousSource) return previousValue; | |
| 96 | 75 | previousSource = source; |
| 97 | − previousValue = source === null ? null : scopeThread(ref.environmentId, source); | |
| 76 | + previousValue = source === null ? null : presentThread(ref.environmentId, source); | |
| 98 | 77 | return previousValue; |
| 99 | 78 | }).pipe( |
| 100 | 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) => | |
| 106 | − Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( | |
| 107 | − Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | |
| 108 | − Atom.withLabel(`environment-thread-status:${key}`), | |
| 109 | − ), | |
| 110 | − ); | |
| 111 | − | |
| 112 | − const threadErrorAtomFamily = Atom.family((key: string) => | |
| 113 | − Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( | |
| 84 | + const scopedThreadProjectionAtomFamily = Atom.family((key: string) => { | |
| 85 | + const ref = parseThreadKey(key); | |
| 86 | + let previousProjection: OrchestrationV2ThreadProjection | null = null; | |
| 87 | + let previousValue: ScopedThreadProjection | null = null; | |
| 88 | + return Atom.make((get) => { | |
| 89 | + const projection = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); | |
| 90 | + if (projection === previousProjection) return previousValue; | |
| 91 | + previousProjection = projection; | |
| 92 | + previousValue = projection === null ? null : { environmentId: ref.environmentId, projection }; | |
| 93 | + return previousValue; | |
| 94 | + }).pipe( | |
| 114 | 95 | Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), |
| 115 | − Atom.withLabel(`environment-thread-error:${key}`), | |
| 116 | − ), | |
| 117 | − ); | |
| 96 | + Atom.withLabel(`scoped-thread-projection:${key}`), | |
| 97 | + ); | |
| 98 | + }); | |
| 118 | 99 | |
| 119 | − const threadMessagesAtomFamily = Atom.family((key: string) => | |
| 100 | + const visibleTurnItemsAtomFamily = Atom.family((key: string) => | |
| 120 | 101 | Atom.make( |
| 121 | − (get): ReadonlyArray<OrchestrationMessage> => | |
| 122 | − get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES, | |
| 102 | + (get): OrchestrationV2ThreadProjection["visibleTurnItems"] => | |
| 103 | + Option.getOrNull(get(threadStateValueAtomFamily(key)).data)?.visibleTurnItems ?? | |
| 104 | + EMPTY_VISIBLE_TURN_ITEMS, | |
| 123 | 105 | ).pipe( |
| 124 | 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) => | |
| 130 | − Atom.make( | |
| 131 | − (get): ReadonlyArray<OrchestrationThreadActivity> => | |
| 132 | − get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES, | |
| 133 | − ).pipe( | |
| 134 | − Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | |
| 135 | − Atom.withLabel(`environment-thread-activities:${key}`), | |
| 136 | − ), | |
| 137 | − ); | |
| 111 | + const family = <A>(label: string, select: (thread: EnvironmentThread) => A, empty: A) => | |
| 112 | + Atom.family((key: string) => | |
| 113 | + Atom.make((get): A => { | |
| 114 | + const thread = get(threadDetailAtomFamily(key)); | |
| 115 | + return thread === null ? empty : select(thread); | |
| 116 | + }).pipe( | |
| 117 | + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | |
| 118 | + Atom.withLabel(`environment-thread-${label}:${key}`), | |
| 119 | + ), | |
| 120 | + ); | |
| 138 | 121 | |
| 139 | − const threadProposedPlansAtomFamily = Atom.family((key: string) => | |
| 140 | − Atom.make( | |
| 141 | − (get): ReadonlyArray<OrchestrationProposedPlan> => | |
| 142 | − get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS, | |
| 143 | − ).pipe( | |
| 144 | − Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | |
| 145 | − Atom.withLabel(`environment-thread-proposed-plans:${key}`), | |
| 146 | − ), | |
| 122 | + const messages = family("messages", (thread) => thread.messages, EMPTY_MESSAGES); | |
| 123 | + const workEntries = family("work-entries", (thread) => thread.workEntries, EMPTY_WORK_ENTRIES); | |
| 124 | + const proposedPlans = family( | |
| 125 | + "proposed-plans", | |
| 126 | + (thread) => thread.proposedPlans, | |
| 127 | + EMPTY_PROPOSED_PLANS, | |
| 147 | 128 | ); |
| 148 | − | |
| 149 | − const threadCheckpointsAtomFamily = Atom.family((key: string) => | |
| 150 | − Atom.make( | |
| 151 | − (get): ReadonlyArray<OrchestrationCheckpointSummary> => | |
| 152 | − get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS, | |
| 153 | − ).pipe( | |
| 154 | − Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), | |
| 155 | − Atom.withLabel(`environment-thread-checkpoints:${key}`), | |
| 156 | − ), | |
| 129 | + const checkpoints = family("checkpoints", (thread) => thread.checkpoints, EMPTY_CHECKPOINTS); | |
| 130 | + const pendingApprovals = family( | |
| 131 | + "pending-approvals", | |
| 132 | + (thread) => thread.pendingApprovals, | |
| 133 | + EMPTY_APPROVALS, | |
| 134 | + ); | |
| 135 | + const pendingUserInputs = family( | |
| 136 | + "pending-user-inputs", | |
| 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) => | |
| 160 | − Atom.make( | |
| 161 | − (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null, | |
| 162 | − ).pipe( | |
| 147 | + const status = Atom.family((key: string) => | |
| 148 | + Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( | |
| 163 | 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 | − | |
| 168 | − const threadLatestTurnAtomFamily = Atom.family((key: string) => | |
| 169 | − Atom.make( | |
| 170 | − (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null, | |
| 171 | − ).pipe( | |
| 153 | + const error = Atom.family((key: string) => | |
| 154 | + Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( | |
| 172 | 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 | 160 | return { |
| 178 | 161 | stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)), |
| 162 | + threadAtom: (ref: ScopedThreadRef) => scopedThreadProjectionAtomFamily(threadKey(ref)), | |
| 163 | + visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)), | |
| 179 | 164 | detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)), |
| 180 | − statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)), | |
| 181 | − errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)), | |
| 182 | − messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)), | |
| 183 | − activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)), | |
| 184 | − proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)), | |
| 185 | − checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)), | |
| 186 | − sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)), | |
| 187 | − latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)), | |
| 165 | + statusAtom: (ref: ScopedThreadRef) => status(threadKey(ref)), | |
| 166 | + errorAtom: (ref: ScopedThreadRef) => error(threadKey(ref)), | |
| 167 | + messagesAtom: (ref: ScopedThreadRef) => messages(threadKey(ref)), | |
| 168 | + workEntriesAtom: (ref: ScopedThreadRef) => workEntries(threadKey(ref)), | |
| 169 | + proposedPlansAtom: (ref: ScopedThreadRef) => proposedPlans(threadKey(ref)), | |
| 170 | + checkpointsAtom: (ref: ScopedThreadRef) => checkpoints(threadKey(ref)), | |
| 171 | + pendingApprovalsAtom: (ref: ScopedThreadRef) => pendingApprovals(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 | 1 | import type { |
| 2 | 2 | EnvironmentId, |
| 3 | − OrchestrationShellSnapshot, | |
| 4 | − OrchestrationThreadShell, | |
| 3 | + OrchestrationV2ShellSnapshot, | |
| 4 | + OrchestrationV2ThreadShell, | |
| 5 | 5 | ProjectId, |
| 6 | 6 | ScopedProjectRef, |
| 7 | 7 | ScopedThreadRef, |
| @@ -10,7 +10,7 @@ import type { · 2 unmodified lines | ||
| 10 | 10 | import { Atom } from "effect/unstable/reactivity"; |
| 11 | 11 | |
| 12 | 12 | import type { EnvironmentThreadShell } from "./models.ts"; |
| 13 | −import { scopeThreadShell } from "./models.ts"; | |
| 13 | +import { presentThreadShell } from "./models.ts"; | |
| 14 | 14 | import type { EnvironmentCatalogState } from "./connections.ts"; |
| 15 | 15 | import { |
| 16 | 16 | arrayElementsEqual, |
| @@ -21,9 +21,9 @@ import { · 4 unmodified lines | ||
| 21 | 21 | threadRefsEqual, |
| 22 | 22 | } from "./entities.ts"; |
| 23 | 23 | |
| 24 | −const EMPTY_THREADS: ReadonlyArray<OrchestrationThreadShell> = Object.freeze([]); | |
| 24 | +const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]); | |
| 25 | 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 | 27 | const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< |
| 28 | 28 | ProjectId, |
| 29 | 29 | ReadonlyArray<ScopedThreadRef> |
| @@ -33,17 +33,17 @@ export function createEnvironmentThreadShellAtoms(input: { · 3 unmodified lines | ||
| 33 | 33 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; |
| 34 | 34 | readonly snapshotAtom: ( |
| 35 | 35 | environmentId: EnvironmentId, |
| 36 | − ) => Atom.Atom<OrchestrationShellSnapshot | null>; | |
| 36 | + ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>; | |
| 37 | 37 | }) { |
| 38 | 38 | const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) => |
| 39 | 39 | Atom.make( |
| 40 | − (get): ReadonlyArray<OrchestrationThreadShell> => | |
| 40 | + (get): ReadonlyArray<OrchestrationV2ThreadShell> => | |
| 41 | 41 | get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS, |
| 42 | 42 | ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)), |
| 43 | 43 | ); |
| 44 | 44 | |
| 45 | 45 | const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) => |
| 46 | − Atom.make((get): ReadonlyMap<ThreadId, OrchestrationThreadShell> => { | |
| 46 | + Atom.make((get): ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> => { | |
| 47 | 47 | const threads = get(environmentThreadsAtom(environmentId)); |
| 48 | 48 | if (threads.length === 0) { |
| 49 | 49 | return EMPTY_THREAD_INDEX; |
| @@ -102,7 +102,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 52 unmodified lines | ||
| 102 | 102 | |
| 103 | 103 | const threadShellAtomFamily = Atom.family((key: string) => { |
| 104 | 104 | const ref = parseThreadKey(key); |
| 105 | − let previousSource: OrchestrationThreadShell | null = null; | |
| 105 | + let previousSource: OrchestrationV2ThreadShell | null = null; | |
| 106 | 106 | let previousValue: EnvironmentThreadShell | null = null; |
| 107 | 107 | return Atom.make((get) => { |
| 108 | 108 | const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; |
| @@ -110,7 +110,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 1 unmodified line | ||
| 110 | 110 | return previousValue; |
| 111 | 111 | } |
| 112 | 112 | previousSource = source; |
| 113 | − previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source); | |
| 113 | + previousValue = source === null ? null : presentThreadShell(ref.environmentId, source); | |
| 114 | 114 | return previousValue; |
| 115 | 115 | }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); |
| 116 | 116 | }); |
| @@ -1,41 +1,29 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | EnvironmentId, |
| 3 | 3 | EventId, |
| 4 | − ORCHESTRATION_WS_METHODS, | |
| 5 | − ProjectId, | |
| 6 | − ProviderInstanceId, | |
| 7 | − ThreadId, | |
| 8 | − TurnId, | |
| 9 | − type OrchestrationThread, | |
| 10 | − type OrchestrationThreadDetailSnapshot, | |
| 11 | − type OrchestrationThreadStreamItem, | |
| 4 | + ORCHESTRATION_V2_WS_METHODS, | |
| 5 | + type OrchestrationV2ThreadStreamItem, | |
| 12 | 6 | } from "@t3tools/contracts"; |
| 13 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | +import * as DateTime from "effect/DateTime"; | |
| 14 | 9 | import * as Effect from "effect/Effect"; |
| 15 | 10 | import * as Option from "effect/Option"; |
| 16 | 11 | import * as Queue from "effect/Queue"; |
| 17 | −import * as Ref from "effect/Ref"; | |
| 18 | 12 | import * as Stream from "effect/Stream"; |
| 19 | 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 | 15 | import { |
| 24 | 16 | AVAILABLE_CONNECTION_STATE, |
| 25 | 17 | PrimaryConnectionTarget, |
| 26 | 18 | type PreparedConnection, |
| 27 | − type SupervisorConnectionState, | |
| 28 | 19 | } from "../connection/model.ts"; |
| 29 | 20 | import * as ConnectionWakeups from "../connection/wakeups.ts"; |
| 30 | 21 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 31 | 22 | import * as Persistence from "../platform/persistence.ts"; |
| 32 | 23 | import * as RpcSession from "../rpc/session.ts"; |
| 33 | −import { | |
| 34 | − EMPTY_ENVIRONMENT_THREAD_STATE, | |
| 35 | − makeEnvironmentThreadState, | |
| 36 | − ThreadSnapshotLoader, | |
| 37 | − type EnvironmentThreadState, | |
| 38 | −} from "./threads.ts"; | |
| 24 | +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | |
| 25 | +import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts"; | |
| 26 | +import { makeEnvironmentThreadState } from "./threads.ts"; | |
| 39 | 27 | |
| 40 | 28 | const TARGET = new PrimaryConnectionTarget({ |
| 41 | 29 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -43,559 +31,73 @@ const TARGET = new PrimaryConnectionTarget({ · 1 unmodified line | ||
| 43 | 31 | httpBaseUrl: "https://environment.example.test", |
| 44 | 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 | |
| 344 | − // full snapshot over HTTP. | |
| 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", () => | |
| 35 | +describe("V2 thread synchronization", () => { | |
| 36 | + it.effect("applies a snapshot followed by committed projection events", () => | |
| 398 | 37 | Effect.gen(function* () { |
| 399 | − const httpThread: OrchestrationThread = { ...BASE_THREAD, title: "HTTP title" }; | |
| 400 | − const harness = yield* makeHarness({ | |
| 401 | − httpSnapshot: Option.some({ snapshotSequence: 1, thread: httpThread }), | |
| 38 | + const events = yield* Queue.unbounded<OrchestrationV2ThreadStreamItem>(); | |
| 39 | + const client = { | |
| 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. | |
| 404 | − // It can only be applied if the HTTP snapshot already seeded the thread. | |
| 405 | − yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); | |
| 406 | − | |
| 407 | − const state = yield* awaitThreadState( | |
| 408 | − harness.observed, | |
| 409 | − (value) => | |
| 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 | − }), | |
| 58 | + const cache = Persistence.EnvironmentCacheStore.of({ | |
| 59 | + loadShell: () => Effect.succeed(Option.none()), | |
| 60 | + saveShell: () => Effect.void, | |
| 61 | + loadThread: () => Effect.succeed(Option.none()), | |
| 62 | + saveThread: () => Effect.void, | |
| 63 | + removeThread: () => Effect.void, | |
| 64 | + clear: () => Effect.void, | |
| 467 | 65 | }); |
| 468 | − yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | |
| 469 | − yield* Queue.offer(harness.inputs, deleted()); | |
| 470 | − yield* awaitThreadState(harness.observed, (value) => value.status === "deleted"); | |
| 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", | |
| 66 | + const state = yield* makeEnvironmentThreadState(v2ThreadId).pipe( | |
| 67 | + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 68 | + Effect.provideService(Persistence.EnvironmentCacheStore, cache), | |
| 560 | 69 | ); |
| 561 | 70 | |
| 562 | − expect(Option.isNone(recovered.error)).toBe(true); | |
| 563 | − expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | |
| 564 | − expect(yield* Ref.get(harness.retryCount)).toBe(0); | |
| 565 | − }), | |
| 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, | |
| 71 | + yield* Queue.offer(events, { | |
| 72 | + kind: "snapshot", | |
| 73 | + snapshotSequence: 1, | |
| 74 | + projection: v2Projection, | |
| 580 | 75 | }); |
| 581 | − yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | |
| 582 | − yield* awaitThreadState(harness.observed, (value) => value.status === "live"); | |
| 583 | − | |
| 584 | − yield* SubscriptionRef.set(harness.supervisorState, { | |
| 585 | − desired: true, | |
| 586 | − network: "online", | |
| 587 | − phase: "connected", | |
| 588 | − stage: null, | |
| 589 | − attempt: 1, | |
| 590 | − generation: 1, | |
| 591 | − lastFailure: null, | |
| 592 | − retryAt: null, | |
| 76 | + yield* SubscriptionRef.changes(state).pipe( | |
| 77 | + Stream.filter((current) => current.status === "live"), | |
| 78 | + Stream.runHead, | |
| 79 | + ); | |
| 80 | + | |
| 81 | + const occurredAt = DateTime.makeUnsafe("2026-06-20T02:00:00.000Z"); | |
| 82 | + yield* Queue.offer(events, { | |
| 83 | + kind: "event", | |
| 84 | + sequence: 2, | |
| 85 | + event: { | |
| 86 | + id: EventId.make("event-title"), | |
| 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) { | |
| 595 | − yield* Effect.yieldNow; | |
| 596 | − } | |
| 93 | + yield* SubscriptionRef.changes(state).pipe( | |
| 94 | + Stream.filter((current) => Option.getOrNull(current.data)?.thread.title === "Updated"), | |
| 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 | 1 | import { |
| 2 | − ORCHESTRATION_WS_METHODS, | |
| 2 | + ORCHESTRATION_V2_WS_METHODS, | |
| 3 | 3 | type EnvironmentId as EnvironmentIdType, |
| 4 | − type OrchestrationThread, | |
| 5 | − type OrchestrationThreadDetailSnapshot, | |
| 6 | − type OrchestrationThreadStreamItem, | |
| 4 | + type OrchestrationV2ThreadProjection, | |
| 5 | + type OrchestrationV2ThreadStreamItem, | |
| 7 | 6 | type ThreadId as ThreadIdType, |
| 8 | 7 | } from "@t3tools/contracts"; |
| 9 | 8 | import * as Cause from "effect/Cause"; |
| @@ -23,16 +22,27 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; · 13 unmodified lines | ||
| 23 | 22 | import { subscribeDynamic } from "../rpc/client.ts"; |
| 24 | 23 | import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; |
| 25 | 24 | import { parseThreadKey, threadKey } from "./entities.ts"; |
| 26 | −import { applyThreadDetailEvent } from "./threadReducer.ts"; | |
| 25 | +import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; | |
| 27 | 26 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 28 | 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 | 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 | 74 | threadId, |
| 65 | 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 | 84 | status: statusWithoutLiveData(cachedThread), |
| 75 | 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.