diff --git a/src/hooks/atlas/session-last-agent.json.test.ts b/src/hooks/atlas/session-last-agent.json.test.ts new file mode 100644 index 000000000..196078a50 --- /dev/null +++ b/src/hooks/atlas/session-last-agent.json.test.ts @@ -0,0 +1,67 @@ +declare const require: (name: string) => any +const { afterEach, describe, expect, mock, test, afterAll } = require("bun:test") +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +const testDirs: string[] = [] +const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`) +const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message") + +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => false, +})) + +mock.module("../../shared/opencode-message-dir", () => ({ + getMessageDir: (sessionID: string) => { + const directPath = join(TEST_MESSAGE_STORAGE, sessionID) + return require("node:fs").existsSync(directPath) ? directPath : null + }, +})) + +afterAll(() => { mock.restore() }) + +afterEach(() => { + while (testDirs.length > 0) { + const directory = testDirs.pop() + if (directory) { + rmSync(directory, { recursive: true, force: true }) + } + } +}) + +function createTempMessageDir(sessionID: string): string { + const directory = mkdtempSync(join(tmpdir(), "atlas-session-last-agent-json-")) + testDirs.push(directory) + const messageDir = join(TEST_MESSAGE_STORAGE, sessionID) + mkdirSync(messageDir, { recursive: true }) + return messageDir +} + +describe("getLastAgentFromSession JSON backend", () => { + test("returns the newest non-compaction agent by message timestamp rather than filename order", async () => { + // given + const sessionID = "ses_json_last_agent" + const messageDir = createTempMessageDir(sessionID) + writeFileSync(join(messageDir, "msg_ffff0000_000001.json"), JSON.stringify({ + agent: "compaction", + time: { created: 200 }, + }), "utf-8") + writeFileSync(join(messageDir, "msg_00000000_000999.json"), JSON.stringify({ + agent: "atlas", + time: { created: 100 }, + }), "utf-8") + writeFileSync(join(messageDir, "msg_11111111_000002.json"), JSON.stringify({ + agent: "sisyphus-junior", + time: { created: 50 }, + }), "utf-8") + + const { getLastAgentFromSession } = await import("./session-last-agent") + + // when + const result = await getLastAgentFromSession(sessionID) + + // then + expect(result).toBe("atlas") + }) +}) diff --git a/src/hooks/atlas/session-last-agent.sqlite.test.ts b/src/hooks/atlas/session-last-agent.sqlite.test.ts index 9b2a0eb62..a5ce6dbcb 100644 --- a/src/hooks/atlas/session-last-agent.sqlite.test.ts +++ b/src/hooks/atlas/session-last-agent.sqlite.test.ts @@ -1,56 +1,71 @@ +export {} const { describe, expect, mock, test, afterAll } = require("bun:test") +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, +})) + afterAll(() => { mock.restore() }) -async function importFreshSessionLastAgentModule() { - mock.module("../../shared/opencode-message-dir", () => ({ - getMessageDir: () => null, - })) +const { getLastAgentFromSession } = await import("./session-last-agent") - mock.module("../../shared/opencode-storage-detection", () => ({ - isSqliteBackend: () => true, - })) - - const module = await import(`./session-last-agent?test=${Date.now()}-${Math.random()}`) - mock.restore() - return module -} - -const { getLastAgentFromSession } = await importFreshSessionLastAgentModule() - -function createMockClient(messages: Array<{ info?: { agent?: string } }>) { - return { - session: { - messages: async () => ({ data: messages }), - }, - } -} - -describe("getLastAgentFromSession sqlite branch", () => { - test("should skip compaction and return the previous real agent from sqlite messages", async () => { +describe("getLastAgentFromSession SQLite backend ordering", () => { + test("returns newest non-compaction agent using time.created and id tie-breaker", async () => { // given - const client = createMockClient([ - { info: { agent: "atlas" } }, - { info: { agent: "compaction" } }, - ]) + const client = { + session: { + messages: async () => ({ + data: [ + { id: "msg_0001", info: { agent: "atlas", time: { created: 100 } } }, + { id: "msg_0003", info: { agent: "compaction", time: { created: 200 } } }, + { id: "msg_0002", info: { agent: "sisyphus-junior", time: { created: 100 } } }, + ], + }), + }, + } // when - const result = await getLastAgentFromSession("ses_sqlite_compaction", client) + const result = await getLastAgentFromSession("ses_sqlite_last_agent", client as never) // then - expect(result).toBe("atlas") + expect(result).toBe("sisyphus-junior") }) - test("should return null when sqlite history contains only compaction", async () => { + test("handles equal timestamps with random-looking ids deterministically", async () => { // given - const client = createMockClient([{ info: { agent: "compaction" } }]) + const client = { + session: { + messages: async () => ({ + data: [ + { id: "msg_a91f00ab", info: { agent: "atlas", time: { created: 100 } } }, + { id: "msg_f0e1d2c3", info: { agent: "compaction", time: { created: 200 } } }, + { id: "msg_d4c3b2a1", info: { agent: "sisyphus-junior", time: { created: 100 } } }, + ], + }), + }, + } // when - const result = await getLastAgentFromSession("ses_sqlite_only_compaction", client) + const result = await getLastAgentFromSession("ses_sqlite_last_agent_equal_time", client as never) + + // then + expect(result).toBe("sisyphus-junior") + }) + + test("returns null instead of throwing when SQLite message lookup fails", async () => { + // given + const client = { + session: { + messages: async () => { + throw new Error("sqlite lookup failed") + }, + }, + } + + // when + const result = await getLastAgentFromSession("ses_sqlite_error", client as never) // then expect(result).toBeNull() }) }) - -export {} diff --git a/src/hooks/atlas/session-last-agent.ts b/src/hooks/atlas/session-last-agent.ts index 6c6b821db..43933b33f 100644 --- a/src/hooks/atlas/session-last-agent.ts +++ b/src/hooks/atlas/session-last-agent.ts @@ -15,20 +15,27 @@ function isCompactionAgent(agent: unknown): boolean { function getLastAgentFromMessageDir(messageDir: string): string | null { try { - const files = readdirSync(messageDir) + const messages = readdirSync(messageDir) .filter((fileName) => fileName.endsWith(".json")) - .sort() - - for (let i = files.length - 1; i >= 0; i--) { - const fileName = files[i] - try { - const content = readFileSync(join(messageDir, fileName), "utf-8") - const parsed = JSON.parse(content) as { agent?: unknown } - if (typeof parsed.agent === "string" && !isCompactionAgent(parsed.agent)) { - return parsed.agent.toLowerCase() + .map((fileName) => { + try { + const content = readFileSync(join(messageDir, fileName), "utf-8") + const parsed = JSON.parse(content) as { agent?: unknown; time?: { created?: unknown } } + return { + fileName, + agent: parsed.agent, + createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY, + } + } catch { + return null } - } catch { - continue + }) + .filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null) + .sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName)) + + for (const message of messages) { + if (typeof message.agent === "string" && !isCompactionAgent(message.agent)) { + return message.agent.toLowerCase() } } } catch { @@ -43,16 +50,30 @@ export async function getLastAgentFromSession( client?: SessionMessagesClient ): Promise { if (isSqliteBackend() && client) { - const response = await client.session.messages({ path: { id: sessionID } }) - const messages = normalizeSDKResponse(response, [] as Array<{ info?: { agent?: string } }>, { - preferResponseOnMissingData: true, - }) + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as Array<{ id?: string; info?: { agent?: string; time?: { created?: number } } }>, { + preferResponseOnMissingData: true, + }).sort((left, right) => { + const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY + const rightTime = (right as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY + if (leftTime !== rightTime) { + return rightTime - leftTime + } - for (let i = messages.length - 1; i >= 0; i--) { - const agent = messages[i].info?.agent - if (typeof agent === "string" && !isCompactionAgent(agent)) { - return agent.toLowerCase() + const leftId = typeof left.id === "string" ? left.id : "" + const rightId = typeof right.id === "string" ? right.id : "" + return rightId.localeCompare(leftId) + }) + + for (const message of messages) { + const agent = message.info?.agent + if (typeof agent === "string" && !isCompactionAgent(agent)) { + return agent.toLowerCase() + } } + } catch { + return null } return null