diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index 6481e8851..663b5e068 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" import { findNearestMessageWithFields, findFirstMessageWithAgent, @@ -13,6 +16,7 @@ import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode- //#region Mocks const mockIsSqliteBackend = vi.fn() +const tempDirs: string[] = [] vi.mock("../../shared/opencode-storage-detection", () => ({ isSqliteBackend: mockIsSqliteBackend, @@ -21,15 +25,33 @@ vi.mock("../../shared/opencode-storage-detection", () => ({ //#endregion +afterEach(() => { + while (tempDirs.length > 0) { + const directory = tempDirs.pop() + if (directory) { + rmSync(directory, { recursive: true, force: true }) + } + } +}) + +function createMessageDir(): string { + const directory = mkdtempSync(join(tmpdir(), "omo-injector-message-dir-")) + tempDirs.push(directory) + mkdirSync(directory, { recursive: true }) + return directory +} + //#region Test Helpers function createMockClient(messages: Array<{ + id?: string info?: { agent?: string model?: { providerID?: string; modelID?: string; variant?: string } providerID?: string modelID?: string tools?: Record + time?: { created?: number } } }>): { session: { @@ -76,8 +98,8 @@ describe("findNearestMessageWithFieldsFromSDK", () => { it("returns nearest (most recent) message with all fields", async () => { const mockClient = createMockClient([ - { info: { agent: "old-agent", model: { providerID: "old", modelID: "model" } } }, - { info: { agent: "new-agent", model: { providerID: "new", modelID: "model" } } }, + { id: "msg_old", info: { agent: "old-agent", model: { providerID: "old", modelID: "model" }, time: { created: 10 } } }, + { id: "msg_new", info: { agent: "new-agent", model: { providerID: "new", modelID: "model" }, time: { created: 20 } } }, ]) const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") @@ -143,6 +165,38 @@ describe("findNearestMessageWithFieldsFromSDK", () => { expect(result?.tools).toEqual({ edit: true, write: false }) }) + + it("uses message time.created rather than SDK array order when resolving nearest message", async () => { + const mockClient = createMockClient([ + { id: "msg_newer", info: { agent: "older-array-entry", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 10 } } }, + { id: "msg_older", info: { agent: "newest-by-time", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 100 } } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.agent).toBe("newest-by-time") + }) +}) + +describe("findNearestMessageWithFields JSON backend ordering", () => { + it("uses message time.created rather than filename order", () => { + mockIsSqliteBackend.mockReturnValue(false) + const messageDir = createMessageDir() + writeFileSync(join(messageDir, "msg_ffff0000_000001.json"), JSON.stringify({ + agent: "older-by-time", + model: { providerID: "openai", modelID: "gpt-5" }, + time: { created: 10 }, + })) + writeFileSync(join(messageDir, "msg_00000000_000999.json"), JSON.stringify({ + agent: "newest-by-time", + model: { providerID: "openai", modelID: "gpt-5" }, + time: { created: 100 }, + })) + + const result = findNearestMessageWithFields(messageDir) + + expect(result?.agent).toBe("newest-by-time") + }) }) describe("findFirstMessageWithAgentFromSDK", () => { @@ -157,6 +211,17 @@ describe("findFirstMessageWithAgentFromSDK", () => { expect(result).toBe("first-agent") }) + it("uses message time.created rather than SDK array order when resolving first agent", async () => { + const mockClient = createMockClient([ + { id: "msg_late", info: { agent: "later-agent", time: { created: 100 } } }, + { id: "msg_early", info: { agent: "earliest-agent", time: { created: 10 } } }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBe("earliest-agent") + }) + it("skips messages without agent field", async () => { const mockClient = createMockClient([ { info: {} }, diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index d34839827..a0568371e 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -17,6 +17,7 @@ export interface StoredMessage { type OpencodeClient = PluginInput["client"] interface SDKMessage { + id?: string info?: { agent?: string model?: { @@ -27,6 +28,9 @@ interface SDKMessage { providerID?: string modelID?: string tools?: Record + time?: { + created?: number + } } } @@ -71,16 +75,22 @@ export async function findNearestMessageWithFieldsFromSDK( try { const response = await client.session.messages({ path: { id: sessionID } }) const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + .map((message) => ({ + stored: convertSDKMessageToStoredMessage(message), + createdAt: message.info?.time?.created ?? Number.NEGATIVE_INFINITY, + id: typeof message.id === "string" ? message.id : "", + })) + .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id)) - for (let i = messages.length - 1; i >= 0; i--) { - const stored = convertSDKMessageToStoredMessage(messages[i]) + for (const message of messages) { + const stored = message.stored if (stored?.agent && stored.model?.providerID && stored.model?.modelID) { return stored } } - for (let i = messages.length - 1; i >= 0; i--) { - const stored = convertSDKMessageToStoredMessage(messages[i]) + for (const message of messages) { + const stored = message.stored if (stored?.agent || (stored?.model?.providerID && stored?.model?.modelID)) { return stored } @@ -104,6 +114,14 @@ export async function findFirstMessageWithAgentFromSDK( try { const response = await client.session.messages({ path: { id: sessionID } }) const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + .sort((left, right) => { + const leftTime = left.info?.time?.created ?? Number.POSITIVE_INFINITY + const rightTime = right.info?.time?.created ?? Number.POSITIVE_INFINITY + if (leftTime !== rightTime) return leftTime - rightTime + const leftId = typeof left.id === "string" ? left.id : "" + const rightId = typeof right.id === "string" ? right.id : "" + return leftId.localeCompare(rightId) + }) for (const msg of messages) { const stored = convertSDKMessageToStoredMessage(msg) @@ -137,32 +155,33 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage } try { - const files = readdirSync(messageDir) + const messages = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) - .sort() - .reverse() - - for (const file of files) { - try { - const content = readFileSync(join(messageDir, file), "utf-8") - const msg = JSON.parse(content) as StoredMessage - if (msg.agent && msg.model?.providerID && msg.model?.modelID) { - return msg + .map((fileName) => { + try { + const content = readFileSync(join(messageDir, fileName), "utf-8") + const msg = JSON.parse(content) as StoredMessage & { time?: { created?: number } } + return { + fileName, + msg, + createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY, + } + } catch { + return null } - } catch { - continue + }) + .filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null) + .sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName)) + + for (const entry of messages) { + if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) { + return entry.msg } } - for (const file of files) { - try { - const content = readFileSync(join(messageDir, file), "utf-8") - const msg = JSON.parse(content) as StoredMessage - if (msg.agent || (msg.model?.providerID && msg.model?.modelID)) { - return msg - } - } catch { - continue + for (const entry of messages) { + if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) { + return entry.msg } } } catch { @@ -188,19 +207,27 @@ export function findFirstMessageWithAgent(messageDir: string): string | null { } try { - const files = readdirSync(messageDir) + const messages = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) - .sort() - - for (const file of files) { - try { - const content = readFileSync(join(messageDir, file), "utf-8") - const msg = JSON.parse(content) as StoredMessage - if (msg.agent) { - return msg.agent + .map((fileName) => { + try { + const content = readFileSync(join(messageDir, fileName), "utf-8") + const msg = JSON.parse(content) as StoredMessage & { time?: { created?: number } } + return { + fileName, + msg, + createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY, + } + } catch { + return null } - } catch { - continue + }) + .filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null) + .sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName)) + + for (const entry of messages) { + if (entry.msg.agent) { + return entry.msg.agent } } } catch {