| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { |
| 3 | DEFAULT_RUNTIME_MODE, |
| 4 | EnvironmentId, |
| 5 | ProjectId, |
| 6 | ProviderInstanceId, |
| 7 | ThreadId, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import type { Thread } from "../types"; |
| 10 | import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures"; |
| 11 | import { getLatestThreadForProject, sortThreads } from "./threadSort"; |
| 12 | |
| 13 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 14 | const PROJECT_ID = ProjectId.make("project-1"); |
| 15 | |
| 16 | function makeThread(overrides: ThreadFixtureOverrides = {}): Thread { |
| 17 | return makeThreadFixture({ |
| 18 | id: ThreadId.make("thread-1"), |
| 19 | environmentId: LOCAL_ENVIRONMENT_ID, |
| 20 | projectId: PROJECT_ID, |
| 21 | title: "Thread", |
| 22 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 23 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 24 | interactionMode: "default", |
| 25 | runtime: null, |
| 26 | messages: [], |
| 27 | proposedPlans: [], |
| 28 | createdAt: "2026-03-09T10:00:00.000Z", |
| 29 | archivedAt: null, |
| 30 | settledOverride: null, |
| 31 | settledAt: null, |
| 32 | deletedAt: null, |
| 33 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 34 | latestRun: null, |
| 35 | branch: null, |
| 36 | worktreePath: null, |
| 37 | ...overrides, |
| 38 | }); |
| 39 | } |
| 40 | |
| 41 | describe("sortThreads", () => { |
| 42 | it("sorts threads by the latest user message in recency mode", () => { |
| 43 | const sorted = sortThreads( |
| 44 | [ |
| 45 | makeThread({ |
| 46 | id: ThreadId.make("thread-1"), |
| 47 | updatedAt: "2026-03-09T10:10:00.000Z", |
| 48 | messages: [ |
| 49 | { |
| 50 | id: "message-1" as never, |
| 51 | role: "user", |
| 52 | text: "older", |
| 53 | runId: null, |
| 54 | createdAt: "2026-03-09T10:01:00.000Z", |
| 55 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 56 | streaming: false, |
| 57 | }, |
| 58 | ], |
| 59 | }), |
| 60 | makeThread({ |
| 61 | id: ThreadId.make("thread-2"), |
| 62 | createdAt: "2026-03-09T10:05:00.000Z", |
| 63 | updatedAt: "2026-03-09T10:05:00.000Z", |
| 64 | messages: [ |
| 65 | { |
| 66 | id: "message-2" as never, |
| 67 | role: "user", |
| 68 | text: "newer", |
| 69 | runId: null, |
| 70 | createdAt: "2026-03-09T10:06:00.000Z", |
| 71 | updatedAt: "2026-03-09T10:06:00.000Z", |
| 72 | streaming: false, |
| 73 | }, |
| 74 | ], |
| 75 | }), |
| 76 | ], |
| 77 | "updated_at", |
| 78 | ); |
| 79 | |
| 80 | expect(sorted.map((thread) => thread.id)).toEqual([ |
| 81 | ThreadId.make("thread-2"), |
| 82 | ThreadId.make("thread-1"), |
| 83 | ]); |
| 84 | }); |
| 85 | |
| 86 | it("falls back to thread timestamps when there is no user message", () => { |
| 87 | const sorted = sortThreads( |
| 88 | [ |
| 89 | makeThread({ |
| 90 | id: ThreadId.make("thread-1"), |
| 91 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 92 | messages: [ |
| 93 | { |
| 94 | id: "message-1" as never, |
| 95 | role: "assistant", |
| 96 | text: "assistant only", |
| 97 | runId: null, |
| 98 | createdAt: "2026-03-09T10:02:00.000Z", |
| 99 | updatedAt: "2026-03-09T10:02:00.000Z", |
| 100 | streaming: false, |
| 101 | }, |
| 102 | ], |
| 103 | }), |
| 104 | makeThread({ |
| 105 | id: ThreadId.make("thread-2"), |
| 106 | createdAt: "2026-03-09T10:05:00.000Z", |
| 107 | updatedAt: "2026-03-09T10:05:00.000Z", |
| 108 | messages: [], |
| 109 | }), |
| 110 | ], |
| 111 | "updated_at", |
| 112 | ); |
| 113 | |
| 114 | expect(sorted.map((thread) => thread.id)).toEqual([ |
| 115 | ThreadId.make("thread-2"), |
| 116 | ThreadId.make("thread-1"), |
| 117 | ]); |
| 118 | }); |
| 119 | |
| 120 | it("can sort threads by createdAt when configured", () => { |
| 121 | const sorted = sortThreads( |
| 122 | [ |
| 123 | makeThread({ |
| 124 | id: ThreadId.make("thread-1"), |
| 125 | createdAt: "2026-03-09T10:05:00.000Z", |
| 126 | updatedAt: "2026-03-09T10:05:00.000Z", |
| 127 | }), |
| 128 | makeThread({ |
| 129 | id: ThreadId.make("thread-2"), |
| 130 | createdAt: "2026-03-09T10:00:00.000Z", |
| 131 | updatedAt: "2026-03-09T10:10:00.000Z", |
| 132 | }), |
| 133 | ], |
| 134 | "created_at", |
| 135 | ); |
| 136 | |
| 137 | expect(sorted.map((thread) => thread.id)).toEqual([ |
| 138 | ThreadId.make("thread-1"), |
| 139 | ThreadId.make("thread-2"), |
| 140 | ]); |
| 141 | }); |
| 142 | |
| 143 | it("uses updatedAt as a fallback for created_at sorting when createdAt is invalid", () => { |
| 144 | const sorted = sortThreads( |
| 145 | [ |
| 146 | makeThread({ |
| 147 | id: ThreadId.make("thread-1"), |
| 148 | createdAt: "invalid-date" as never, |
| 149 | updatedAt: "2026-03-09T10:05:00.000Z", |
| 150 | }), |
| 151 | makeThread({ |
| 152 | id: ThreadId.make("thread-2"), |
| 153 | createdAt: "2026-03-09T10:00:00.000Z", |
| 154 | updatedAt: "2026-03-09T10:10:00.000Z", |
| 155 | }), |
| 156 | ], |
| 157 | "created_at", |
| 158 | ); |
| 159 | |
| 160 | expect(sorted.map((thread) => thread.id)).toEqual([ |
| 161 | ThreadId.make("thread-1"), |
| 162 | ThreadId.make("thread-2"), |
| 163 | ]); |
| 164 | }); |
| 165 | |
| 166 | it("returns the latest active thread for a project", () => { |
| 167 | const latestThread = getLatestThreadForProject( |
| 168 | [ |
| 169 | makeThread({ |
| 170 | id: ThreadId.make("thread-1"), |
| 171 | createdAt: "2026-03-09T10:00:00.000Z", |
| 172 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 173 | archivedAt: null, |
| 174 | }), |
| 175 | makeThread({ |
| 176 | id: ThreadId.make("thread-2"), |
| 177 | createdAt: "2026-03-09T10:05:00.000Z", |
| 178 | updatedAt: "2026-03-09T10:10:00.000Z", |
| 179 | archivedAt: "2026-03-10T00:00:00.000Z", |
| 180 | }), |
| 181 | makeThread({ |
| 182 | id: ThreadId.make("thread-3"), |
| 183 | createdAt: "2026-03-09T10:06:00.000Z", |
| 184 | updatedAt: "2026-03-09T10:06:00.000Z", |
| 185 | archivedAt: null, |
| 186 | }), |
| 187 | ], |
| 188 | PROJECT_ID, |
| 189 | "updated_at", |
| 190 | ); |
| 191 | |
| 192 | expect(latestThread?.id).toBe(ThreadId.make("thread-3")); |
| 193 | }); |
| 194 | }); |