From 46b12dff445b7978031b3fa173a720199cbee59f Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 11:36:13 +0200 Subject: [PATCH] Add opt-in sorting by the latest completed result --- CHANGELOG.md | 7 ++++ README.md | 15 ++++++++ extension.js | 66 ++++++++++++++++++++++++++++++-- package.json | 13 +++++++ scripts/validate.mjs | 2 + session-index.js | 20 +++++++--- session-results.js | 71 +++++++++++++++++++++++++++++++++++ session-sort.js | 10 +++++ test/extension.test.cjs | 69 +++++++++++++++++++++++++++++++++- test/session-results.test.cjs | 61 ++++++++++++++++++++++++++++++ test/session-sort.test.cjs | 24 ++++++++++++ 11 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 session-results.js create mode 100644 session-sort.js create mode 100644 test/session-results.test.cjs create mode 100644 test/session-sort.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 848f26d..6f65644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Codex Extras are documented in this file. +## Unreleased + +- Add opt-in latest-result sorting within workspace groups. Only completed + results change recency; opening, focusing, or reading a chat cannot move it. + Remember observed completion times across reloads; keep index-date sorting + as the default. + ## 1.1.0 - Add a Codex Chats Activity Bar panel with local chat history, automatic diff --git a/README.md b/README.md index b5c5777..368623b 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,23 @@ The official Codex extension is declared as a dependency and is installed automa | Setting | Default | Description | | --- | --- | --- | +| `codexExtras.chatSortOrder` | `recent` | `recent`: session-index date; `latestResult`: most recently delivered result first, within each workspace group. | | `codexExtras.showNewAgentButton` | `true` | Shows the New Codex Agent button in the editor toolbar. | +Set **Codex Extras: Chat Sort Order** to **latestResult** to order each workspace +by the time its sessions last delivered a completed result. Opening a chat, +changing focus, reading a result, starting work, questions, and streamed tokens +do not change the order. A new completed result moves that chat up within its +workspace group. Remembered completion times survive reloads. Chats with no known +result time appear below dated results in a stable order. Switching back to +**recent** restores the original index-date ordering immediately. +This option reads bounded log tails, without changing conversation icons; it does not +require a live IPC connection. +Only explicit `task_complete` records with valid timestamps count. Reads are +cached and limited to the last 256 KiB per log. An older completion outside that +tail can be unknown on first use; once observed, its time is remembered locally. +No transcripts are modified. File events refresh results without status polling. + ## Install Install **Codex Extras** from the Visual Studio Marketplace, or use: diff --git a/extension.js b/extension.js index aae4c3d..bc160c6 100644 --- a/extension.js +++ b/extension.js @@ -4,10 +4,13 @@ const fs = require("node:fs"); const { readFile } = require("node:fs/promises"); const path = require("node:path"); const vscode = require("vscode"); +const { SessionResultReader, watchSessionResults } = require("./session-results"); +const { sortSessions } = require("./session-sort"); const { getSessionIndexPath, getSessionsDirectory, isPathInsideWorkspace, + loadSessionFiles, loadSessionWorkspaces, parseSessionIndex, } = require("./session-index"); @@ -23,6 +26,7 @@ const CODEX_EDITOR_VIEW_TYPE = "chatgpt.conversationEditor"; const CODEX_NEW_PANEL_SCHEME = "openai-codex"; const CODEX_ROUTE_AUTHORITY = "route"; const CODEX_NEW_PANEL_PATH = "/extension/panel/new"; +const RESULT_TIMES_KEY = "codexExtras.lastResultTimes"; let panelSequence = 0; let openQueue = Promise.resolve(); @@ -77,10 +81,14 @@ class CodexChatGroupItem extends vscode.TreeItem { } class CodexChatHistoryProvider { - constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders) { + constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders, getResultTimes, saveResultTimes) { this.sessionIndexPath = sessionIndexPath; this.sessionsDirectory = sessionsDirectory; this.getWorkspaceFolders = getWorkspaceFolders; + this.getResultTimes = getResultTimes; + this.saveResultTimes = saveResultTimes; + this.sortOrder = "recent"; + this.resultReader = new SessionResultReader(); this.workspaceBySessionId = new Map(); this.changeEmitter = new vscode.EventEmitter(); this.statusEmitter = new vscode.EventEmitter(); @@ -111,9 +119,12 @@ class CodexChatHistoryProvider { const missingWorkspaceIds = sessions .filter((session) => !this.workspaceBySessionId.has(session.id)) .map((session) => session.id); + const files = this.sortOrder === "latestResult" + ? await loadSessionFiles(this.sessionsDirectory, sessions.map(({ id }) => id)) : undefined; const loadedWorkspaces = await loadSessionWorkspaces( this.sessionsDirectory, missingWorkspaceIds, + files, ); for (const [sessionId, workspacePath] of loadedWorkspaces) { @@ -121,13 +132,20 @@ class CodexChatHistoryProvider { } const workspaceFolders = this.getWorkspaceFolders(); - const enrichedSessions = sessions.map((session) => ({ + const enrichedSessions = await Promise.all(sessions.map(async (session) => ({ ...session, workspacePath: this.workspaceBySessionId.get(session.id), - })); + completedAt: files ? Math.max(this.getResultTimes()[session.id] ?? 0, + files.has(session.id) ? await this.resultReader.read(files.get(session.id)) : 0) : 0, + }))); + if (files) { + this.resultReader.retain(files.values()); + await this.saveResultTimes(Object.fromEntries(enrichedSessions + .filter((session) => session.completedAt > 0).map((session) => [session.id, session.completedAt]))); + } this.statusEmitter.fire(undefined); - return createChatGroups(enrichedSessions, workspaceFolders); + return createChatGroups(sortSessions(enrichedSessions, this.sortOrder), workspaceFolders); } catch (error) { if (error?.code === "ENOENT") { this.statusEmitter.fire(undefined); @@ -140,6 +158,7 @@ class CodexChatHistoryProvider { } dispose() { + this.resultReader.clear(); this.changeEmitter.dispose(); this.statusEmitter.dispose(); } @@ -350,6 +369,8 @@ function watchSessionIndex(sessionIndexPath, onChange) { * @param {import("vscode").ExtensionContext} context */ function activate(context) { + let resultTimes = context.globalState.get(RESULT_TIMES_KEY, {}); + let resultTimesQueue = Promise.resolve(); const openNewChat = () => { openQueue = openQueue.catch(() => undefined).then(openNewCodexAgent); return openQueue; @@ -359,6 +380,24 @@ function activate(context) { sessionIndexPath, getSessionsDirectory(), () => vscode.workspace.workspaceFolders ?? [], + () => resultTimes, + (times) => { + resultTimesQueue = resultTimesQueue.catch(() => {}).then(async () => { + const next = { ...resultTimes }; + let changed = false; + for (const [id, time] of Object.entries(times)) { + if (Number.isFinite(time) && time > (next[id] ?? 0)) { + next[id] = time; + changed = true; + } + } + if (changed) { + await context.globalState.update(RESULT_TIMES_KEY, next); + resultTimes = next; + } + }); + return resultTimesQueue; + }, ); const historyView = vscode.window.createTreeView(CHAT_HISTORY_VIEW_ID, { treeDataProvider: historyProvider, @@ -377,6 +416,23 @@ function activate(context) { const workspaceSubscription = vscode.workspace.onDidChangeWorkspaceFolders( () => historyProvider.refresh(), ); + let resultWatcher; + const updateSortOrder = () => { + const order = vscode.workspace.getConfiguration("codexExtras").get("chatSortOrder", "recent"); + historyProvider.sortOrder = order === "latestResult" ? order : "recent"; + if (historyProvider.sortOrder === "latestResult" && !resultWatcher) { + resultWatcher = watchSessionResults(historyProvider.sessionsDirectory, () => historyProvider.refresh(), vscode); + } else if (historyProvider.sortOrder === "recent") { + resultWatcher?.dispose(); + resultWatcher = undefined; + historyProvider.resultReader.clear(); + } + historyProvider.refresh(); + }; + const configurationSubscription = vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("codexExtras.chatSortOrder")) updateSortOrder(); + }); + updateSortOrder(); context.subscriptions.push( vscode.commands.registerCommand(COMMAND_ID, openNewChat), @@ -390,6 +446,8 @@ function activate(context) { statusSubscription, visibilitySubscription, workspaceSubscription, + configurationSubscription, + { dispose() { resultWatcher?.dispose(); } }, watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()), ); } diff --git a/package.json b/package.json index 72f4ffb..6d169b6 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,19 @@ "type": "boolean", "default": true, "description": "Show the New Codex Agent button in the editor toolbar." + }, + "codexExtras.chatSortOrder": { + "type": "string", + "enum": [ + "recent", + "latestResult" + ], + "enumDescriptions": [ + "Newest session-index update first.", + "Most recently delivered result first. Opening, focus, read state, and live activity do not change the order." + ], + "default": "recent", + "description": "Sort chats within each workspace group. Latest result uses bounded log reads and locally remembered completion times, independently of status icons." } } }, diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 3e95090..f4c1ceb 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -16,6 +16,8 @@ assert.equal(manifest.license, "MIT"); assert.equal(manifest.repository.url, "https://github.com/netroforge/codex-extras.git"); assert.ok(manifest.extensionDependencies.includes("openai.chatgpt")); assert.equal(manifest.main, "./extension.js"); +assert.equal(manifest.contributes.configuration.properties["codexExtras.chatSortOrder"].default, "recent"); +assert.deepEqual(manifest.contributes.configuration.properties["codexExtras.chatSortOrder"].enum, ["recent", "latestResult"]); assert.ok( manifest.activationEvents.includes("onCommand:codexExtras.newCodexAgent"), ); diff --git a/session-index.js b/session-index.js index b3dc8d9..18ed497 100644 --- a/session-index.js +++ b/session-index.js @@ -129,16 +129,13 @@ async function findSessionFiles(directory, requestedSessionIds, results) { requestedSessionIds.has(sessionId) && !results.has(sessionId) ) { - const workspacePath = await readSessionWorkspace(entryPath); - if (workspacePath) { - results.set(sessionId, workspacePath); - } + results.set(sessionId, entryPath); } }), ); } -async function loadSessionWorkspaces(sessionsDirectory, sessionIds) { +async function loadSessionFiles(sessionsDirectory, sessionIds) { const requestedSessionIds = new Set(sessionIds); const results = new Map(); @@ -149,6 +146,18 @@ async function loadSessionWorkspaces(sessionsDirectory, sessionIds) { return results; } +async function loadSessionWorkspaces(sessionsDirectory, sessionIds, sessionFiles) { + const files = sessionFiles ?? await loadSessionFiles(sessionsDirectory, sessionIds); + const results = new Map(); + await Promise.all(sessionIds.map(async (id) => { + const filePath = files.get(id); + if (!filePath) return; + const workspacePath = await readSessionWorkspace(filePath); + if (workspacePath) results.set(id, workspacePath); + })); + return results; +} + function isPathInsideWorkspace(candidatePath, workspacePath) { if (!candidatePath || !workspacePath) { return false; @@ -171,6 +180,7 @@ module.exports = { getSessionIndexPath, getSessionsDirectory, isPathInsideWorkspace, + loadSessionFiles, loadSessionWorkspaces, parseSessionIndex, }; diff --git a/session-results.js b/session-results.js new file mode 100644 index 0000000..ebeb3b8 --- /dev/null +++ b/session-results.js @@ -0,0 +1,71 @@ +"use strict"; + +const { open } = require("node:fs/promises"); +const RESULT_TAIL_BYTES = 256 * 1024; + +function parseResultTime(contents, previous = 0) { + let result = previous; + for (const line of contents.split("\n").slice(0, -1)) { + try { + const event = JSON.parse(line); + if (event.type !== "event_msg" || event.payload?.type !== "task_complete") continue; + const time = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : NaN; + if (Number.isFinite(time) && time > result) result = time; + } catch { + // Ignore partial or malformed records; never infer completion from text. + } + } + return result; +} + +class SessionResultReader { + constructor() { this.cache = new Map(); } + + async read(filePath) { + let handle; + try { + handle = await open(filePath, "r"); + const stat = await handle.stat(); + const previous = this.cache.get(filePath); + if (previous?.size === stat.size && previous.mtimeMs === stat.mtimeMs && previous.ino === stat.ino) return previous.time; + const growing = previous && previous.ino === stat.ino && stat.size > previous.size; + const start = Math.max(0, stat.size - RESULT_TAIL_BYTES); + const buffer = Buffer.alloc(stat.size - start); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + const firstLineEnd = start > 0 ? buffer.indexOf(10) : -1; + const skip = start > 0 ? (firstLineEnd < 0 ? bytesRead : firstLineEnd + 1) : 0; + const time = parseResultTime(buffer.toString("utf8", skip, bytesRead), growing ? previous.time : 0); + this.cache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino, time }); + return time; + } catch { + this.cache.delete(filePath); + return 0; + } finally { + await handle?.close().catch(() => {}); + } + } + + retain(filePaths) { + const retained = new Set(filePaths); + for (const filePath of this.cache.keys()) if (!retained.has(filePath)) this.cache.delete(filePath); + } + + clear() { this.cache.clear(); } +} + +function watchSessionResults(directory, onChange, vscode) { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(directory, "**/*.jsonl")); + let timer; + const refresh = () => { + clearTimeout(timer); + timer = setTimeout(onChange, 100); + }; + const subscriptions = [watcher.onDidCreate(refresh), watcher.onDidChange(refresh), watcher.onDidDelete(refresh)]; + return { dispose() { + clearTimeout(timer); + for (const subscription of subscriptions) subscription.dispose(); + watcher.dispose(); + } }; +} + +module.exports = { SessionResultReader, parseResultTime, watchSessionResults }; diff --git a/session-sort.js b/session-sort.js new file mode 100644 index 0000000..8a55cc2 --- /dev/null +++ b/session-sort.js @@ -0,0 +1,10 @@ +"use strict"; + +function sortSessions(sessions, order) { + if (order !== "latestResult") return sessions; + return [...sessions].sort((left, right) => { + return (right.completedAt ?? 0) - (left.completedAt ?? 0) || right.id.localeCompare(left.id); + }); +} + +module.exports = { sortSessions }; diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 24202a6..e37c79e 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -1,7 +1,7 @@ "use strict"; const assert = require("node:assert/strict"); -const { mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises"); +const { appendFile, mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises"); const Module = require("node:module"); const os = require("node:os"); const path = require("node:path"); @@ -62,7 +62,12 @@ function createHarness(t, tabs = [], options = {}) { } } + const configurationChanged = new EventEmitter(); + let sortOrder = options.chatSortOrder ?? "recent"; + const fileWatchers = []; + const vscode = { + RelativePattern: class { constructor(base, pattern) { this.base = base; this.pattern = pattern; } }, commands: { registerCommand(command, handler) { commandHandlers.set(command, handler); @@ -93,6 +98,17 @@ function createHarness(t, tabs = [], options = {}) { Expanded: 2, }, workspace: { + getConfiguration() { return { get: (key, fallback) => key === "chatSortOrder" ? sortOrder : fallback }; }, + onDidChangeConfiguration: configurationChanged.event, + createFileSystemWatcher(pattern) { + const created = new EventEmitter(), changed = new EventEmitter(), deleted = new EventEmitter(); + const watcher = { pattern, created, changed, deleted, disposed: false, + onDidCreate: created.event, onDidChange: changed.event, onDidDelete: deleted.event, + dispose() { this.disposed = true; created.dispose(); changed.dispose(); deleted.dispose(); }, + }; + fileWatchers.push(watcher); + return watcher; + }, workspaceFolders: options.workspaceFolders ?? [ { name: "codex-extras", @@ -124,7 +140,11 @@ function createHarness(t, tabs = [], options = {}) { }, }, }; - const context = { subscriptions: [] }; + const storedState = options.storedState ?? new Map(); + const context = { subscriptions: [], globalState: { + get: (key, fallback) => storedState.get(key) ?? fallback, + async update(key, value) { storedState.set(key, value); }, + } }; const originalCodexHome = process.env.CODEX_HOME; if (options.codexHome) { @@ -151,6 +171,12 @@ function createHarness(t, tabs = [], options = {}) { calls, commandHandlers, treeViews, + fileWatchers, + storedState, + setSortOrder(value) { + sortOrder = value; + configurationChanged.fire({ affectsConfiguration: (key) => key === "codexExtras.chatSortOrder" }); + }, runCommand: (command = "codexExtras.newCodexAgent", ...args) => commandHandlers.get(command)(...args), }; @@ -237,6 +263,45 @@ test("registers the Codex chat history tree view", (t) => { assert.ok(harness.commandHandlers.has("codexExtras.refreshChats")); }); +test("result sorting preserves workspaces, ignores opens, and remembers delivery times across reloads", async (t) => { + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-sort-")); + t.after(() => rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions"); + await mkdir(sessions); + const ids = Array.from({ length: 4 }, (_, index) => `019f9c23-f7ec-72c3-8eb6-0018b17bea4${index}`); + const file = (index) => path.join(sessions, `rollout-${ids[index]}.jsonl`); + const complete = (timestamp) => JSON.stringify({ type: "event_msg", timestamp, payload: { type: "task_complete" } }) + "\n"; + for (const [index] of ids.entries()) { + await writeFile(file(index), JSON.stringify({ type: "session_meta", payload: { cwd: index < 3 ? "/current" : "/other" } }) + "\n" + complete(`2026-02-0${index + 1}T00:00:00Z`)); + } + await writeFile(path.join(codexHome, "session_index.jsonl"), ids.map((id, index) => JSON.stringify({ id, thread_name: `Chat ${index}`, updated_at: `2026-01-0${4 - index}T00:00:00Z` })).join("\n")); + const options = { codexHome, workspaceFolders: [{ name: "current", uri: { fsPath: "/current" } }] }; + const harness = createHarness(t, [], options), provider = harness.treeViews[0].options.treeDataProvider; + assert.equal(harness.fileWatchers.length, 0, "default mode must not watch result logs"); + harness.setSortOrder("latestResult"); + let groups = await provider.getChildren(); + const order = () => groups[0].sessions.map(({ id }) => id); + assert.deepEqual(groups.map(({ label }) => label), ["Current Workspace", "Other Workspaces"]); + assert.deepEqual(order(), [ids[2], ids[1], ids[0]]); + assert.equal(groups[1].sessions[0].id, ids[3], "the newest other-workspace result stays in its own group"); + await harness.runCommand("codexExtras.openChat", ids[0]); + await appendFile(file(0), JSON.stringify({ type: "event_msg", timestamp: "2026-03-01T00:00:00Z", payload: { type: "task_started" } }) + "\n"); + groups = await provider.getChildren(); + assert.deepEqual(order(), [ids[2], ids[1], ids[0]], "opens and work starts do not move a row"); + await appendFile(file(0), complete("2026-03-01T01:00:00Z")); + groups = await provider.getChildren(); + assert.deepEqual(order(), [ids[0], ids[2], ids[1]]); + await appendFile(file(0), JSON.stringify({ type: "response_item", text: "x".repeat(300 * 1024) }) + "\n"); + const reloaded = createHarness(t, [], { ...options, storedState: harness.storedState, chatSortOrder: "latestResult" }); + assert.equal((await reloaded.treeViews[0].options.treeDataProvider.getChildren())[0].sessions[0].id, ids[0]); + const watcher = harness.fileWatchers[0]; + harness.setSortOrder("recent"); + groups = await provider.getChildren(); + assert.deepEqual(order(), ids.slice(0, 3)); + assert.ok(watcher.disposed); + assert.equal(provider.resultReader.cache.size, 0); +}); + test("opens a selected Codex chat as a pinned editor tab", async (t) => { const harness = createHarness(t); const sessionId = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; diff --git a/test/session-results.test.cjs b/test/session-results.test.cjs new file mode 100644 index 0000000..24dbfe8 --- /dev/null +++ b/test/session-results.test.cjs @@ -0,0 +1,61 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { mkdtemp, writeFile, appendFile, rm } = require("node:fs/promises"); +const { setTimeout: delay } = require("node:timers/promises"); +const path = require("node:path"); +const os = require("node:os"); +const test = require("node:test"); +const { SessionResultReader, parseResultTime, watchSessionResults } = require("../session-results"); +const { EventEmitter } = require("node:events"); +const record = (type, timestamp = "2026-01-01T00:00:00Z") => JSON.stringify({ type: "event_msg", timestamp, payload: { type } }) + "\n"; + +test("only valid explicit completion records advance delivery time", () => { + const time = Date.parse("2026-01-01T00:00:00Z"); + assert.equal(parseResultTime(record("task_complete")), time); + assert.equal(parseResultTime(record("task_started") + record("turn_aborted") + record("task_failed")), 0); + assert.equal(parseResultTime(record("task_complete", "invalid")), 0); + assert.equal(parseResultTime(record("task_complete").trimEnd()), 0); + assert.equal(parseResultTime("invalid JSON\n"), 0); + assert.equal(parseResultTime(record("task_complete"), time + 1), time + 1); +}); + +test("bounded cached reads retain a result through new work and streaming, resetting on deletion or truncation", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codex-extras-results-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = path.join(root, "session.jsonl"), reader = new SessionResultReader(); + const time = Date.parse("2026-01-01T00:00:00Z"); + assert.equal(await reader.read(file), 0); + await writeFile(file, record("task_complete")); + assert.equal(await reader.read(file), time); + assert.equal(await reader.read(file), time); + await appendFile(file, record("task_started") + JSON.stringify({ type: "response_item", text: "a".repeat(300 * 1024) }) + "\n"); + assert.equal(await reader.read(file), time); + assert.equal(await new SessionResultReader().read(file), 0, "cold reads must not exceed the tail budget"); + await appendFile(file, record("task_complete", "2026-02-01T00:00:00Z")); + assert.equal(await reader.read(file), Date.parse("2026-02-01T00:00:00Z")); + reader.retain([]); + assert.equal(reader.cache.size, 0); + await writeFile(file, "{}\n"); + assert.equal(await reader.read(file), 0); + await rm(file); + assert.equal(await reader.read(file), 0); + assert.equal(reader.cache.size, 0); +}); + +test("result watchers coalesce file events and cancel refresh after disposal", async () => { + const emitter = new EventEmitter(); + const event = (name) => (listener) => { emitter.on(name, listener); return { dispose: () => emitter.off(name, listener) }; }; + let changes = 0, closed = false; + const watcher = watchSessionResults("/sessions", () => changes++, { + RelativePattern: class { constructor(base, pattern) { assert.equal(base, "/sessions"); assert.equal(pattern, "**/*.jsonl"); } }, + workspace: { createFileSystemWatcher: () => ({ onDidCreate: event("create"), onDidChange: event("change"), onDidDelete: event("delete"), dispose() { closed = true; } }) }, + }); + emitter.emit("create"); emitter.emit("change"); emitter.emit("delete"); + await delay(150); + assert.equal(changes, 1); + emitter.emit("change"); watcher.dispose(); + await delay(150); + assert.equal(changes, 1); + assert.ok(closed); +}); diff --git a/test/session-sort.test.cjs b/test/session-sort.test.cjs new file mode 100644 index 0000000..9ecf168 --- /dev/null +++ b/test/session-sort.test.cjs @@ -0,0 +1,24 @@ +"use strict"; +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { sortSessions } = require("../session-sort"); + +test("latest results stay ordered by delivery time regardless of status, read state, or focus", () => { + const input = [ + { id: "old", completedAt: 100, status: "working", activityAt: Date.now() }, + { id: "new", completedAt: 300, status: "reviewed" }, + { id: "middle", completedAt: 200, status: "question" }, + { id: "unconfirmed", updatedAt: "2099-01-01T00:00:00Z" }, + ]; + assert.deepEqual(sortSessions(input, "latestResult").map(({ id }) => id), ["new", "middle", "old", "unconfirmed"]); + assert.equal(input[0].id, "old", "sorting must not mutate the source list"); + assert.equal(sortSessions(input, "recent"), input); +}); + +test("missing or equal result timestamps have a stable identity order", () => { + const input = [{ id: "A" }, { id: "B" }]; + assert.deepEqual(sortSessions(input, "latestResult").map(({ id }) => id), ["B", "A"]); + input[0].title = "A changed title"; + input[0].updatedAt = "2099-01-01T00:00:00Z"; + assert.deepEqual(sortSessions(input, "latestResult").map(({ id }) => id), ["B", "A"]); +});