1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 type ModelSelection,
7} from "@t3tools/contracts";
8import { describe, expect, it, vi } from "vite-plus/test";
9import {
10 resolveThreadActionProjectRef,
11 hasExplicitComposerModelSelection,
12 resolveNewDraftStartFromOrigin,
13 resolveNewThreadModelSelectionOverride,
14 startNewThreadFromContext,
15 type ChatThreadActionContext,
16} from "./chatThreadActions";
17
18const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
19const PROJECT_ID = ProjectId.make("project-1");
20const FALLBACK_PROJECT_ID = ProjectId.make("project-2");
21const PROJECT_DEFAULT_SELECTION: ModelSelection = {
22 instanceId: ProviderInstanceId.make("codex"),
23 model: "project-default",
24};
25const CARRIED_SELECTION: ModelSelection = {
26 instanceId: ProviderInstanceId.make("codex"),
27 model: "carried-model",
28};
29
30function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatThreadActionContext {
31 return {
32 activeDraftThread: null,
33 activeThread: undefined,
34 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID),
35 handleNewThread: async () => {},
36 ...overrides,
37 };
38}
39
40describe("chatThreadActions", () => {
41 it("only treats an active stored selection marked explicit as an explicit pick", () => {
42 const draft = {
43 activeProvider: PROJECT_DEFAULT_SELECTION.instanceId,
44 modelSelectionByProvider: {
45 [PROJECT_DEFAULT_SELECTION.instanceId]: PROJECT_DEFAULT_SELECTION,
46 },
47 modelSelectionExplicit: true,
48 };
49
50 expect(hasExplicitComposerModelSelection(draft)).toBe(true);
51 expect(hasExplicitComposerModelSelection({ ...draft, modelSelectionExplicit: false })).toBe(
52 false,
53 );
54 expect(hasExplicitComposerModelSelection({ ...draft, activeProvider: null })).toBe(false);
55 });
56
57 it("does not carry a non-explicit model from the destination draft back into itself", () => {
58 expect(
59 resolveNewThreadModelSelectionOverride({
60 projectDefaultSelection: null,
61 carrySelection: CARRIED_SELECTION,
62 carrySourceDraftId: "draft-a",
63 destinationDraftId: "draft-a",
64 }),
65 ).toBeNull();
66 });
67
68 it("still carries models between different threads when the project has no default", () => {
69 expect(
70 resolveNewThreadModelSelectionOverride({
71 projectDefaultSelection: null,
72 carrySelection: CARRIED_SELECTION,
73 carrySourceDraftId: "draft-a",
74 destinationDraftId: "draft-b",
75 }),
76 ).toEqual(CARRIED_SELECTION);
77 });
78
79 it("keeps the project default above any carried selection", () => {
80 expect(
81 resolveNewThreadModelSelectionOverride({
82 projectDefaultSelection: PROJECT_DEFAULT_SELECTION,
83 carrySelection: CARRIED_SELECTION,
84 carrySourceDraftId: "draft-a",
85 destinationDraftId: "draft-b",
86 }),
87 ).toEqual(PROJECT_DEFAULT_SELECTION);
88 });
89
90 it("only applies the start-from-origin default to new worktree drafts", () => {
91 expect(
92 resolveNewDraftStartFromOrigin({
93 envMode: "worktree",
94 newWorktreesStartFromOrigin: true,
95 }),
96 ).toBe(true);
97 expect(
98 resolveNewDraftStartFromOrigin({
99 envMode: "local",
100 newWorktreesStartFromOrigin: true,
101 }),
102 ).toBe(false);
103 });
104
105 it("prefers the active thread project when resolving thread actions", () => {
106 const projectRef = resolveThreadActionProjectRef(
107 createContext({
108 activeThread: {
109 environmentId: ENVIRONMENT_ID,
110 projectId: PROJECT_ID,
111 },
112 }),
113 );
114
115 expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
116 });
117
118 it("falls back to the active draft thread project when there is no active thread", () => {
119 const projectRef = resolveThreadActionProjectRef(
120 createContext({
121 activeDraftThread: {
122 environmentId: ENVIRONMENT_ID,
123 projectId: PROJECT_ID,
124 },
125 }),
126 );
127
128 expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
129 });
130
131 it("falls back to the default project ref when there is no active thread context", () => {
132 const projectRef = resolveThreadActionProjectRef(
133 createContext({
134 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
135 }),
136 );
137
138 expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
139 });
140
141 it("inherits only the project from context, never branch or worktree state", async () => {
142 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
143
144 const didStart = await startNewThreadFromContext(
145 createContext({
146 activeThread: {
147 environmentId: ENVIRONMENT_ID,
148 projectId: PROJECT_ID,
149 },
150 handleNewThread,
151 }),
152 );
153
154 expect(didStart).toBe(true);
155 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
156 });
157
158 it("does not start a thread when there is no project context", async () => {
159 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
160
161 const didStart = await startNewThreadFromContext(
162 createContext({
163 defaultProjectRef: null,
164 handleNewThread,
165 }),
166 );
167
168 expect(didStart).toBe(false);
169 expect(handleNewThread).not.toHaveBeenCalled();
170 });
171});