From 71accd012339ee9d8e8b519dffda459baa90fa83 Mon Sep 17 00:00:00 2001 From: Khaldoon Sinjab Date: Sat, 10 Oct 2026 15:33:42 +0400 Subject: [PATCH] fix(cli): stable conversation-id resolution across chats runs Short IDs are re-derived on every `chats` run, so a short ID captured earlier (in a script, a note, or an earlier command) can stop resolving with a bare 'Short ID not found'. resolveConversationId now: - dedupes the chat index by full conversation ID before matching (the conversations API returns duplicate rows for the same thread), - falls back to unique-suffix matching (>= 4 characters, the same minimum `chats` uses) against both the short ID and the conversation-ID body, so stale short IDs and id tails still resolve, - lists the matching conversations and exits when a suffix is ambiguous (e.g. the current user's org-id tail is shared by every 1:1 thread). The resolver is exported and takes an injectable chat index for testing; covered by 9 new unit tests. --- src/cli.ts | 40 ++++++++++++-- tests/resolve-conversation-id.test.ts | 80 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/resolve-conversation-id.test.ts diff --git a/src/cli.ts b/src/cli.ts index 9a8750a..0b7dfde 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ import { formatMessage, formatPerson, getConversationDisplayName, + makeShortId, parseRecordings, stripHtml, transcriptFilename, @@ -51,12 +52,43 @@ function loadChatIndex(): FormattedChat[] { return JSON.parse(readFileSync(LAST_CHATS_FILE, "utf-8")); } -function resolveConversationId(idOrShort: string): string { +export function resolveConversationId( + idOrShort: string, + chats: FormattedChat[] = loadChatIndex(), +): string { + // Full conversation IDs (e.g. "19:...@thread.v2") pass through untouched. if (idOrShort.includes(":")) return idOrShort; - const chats = loadChatIndex(); - for (const c of chats) { - if (c.short_id === idOrShort) return c.id; + + // Exact short-id hit. The chat index can hold several rows for the same + // thread (the conversations API returns duplicate rows), so dedupe by + // full ID before deciding. + const exactIds = new Set( + chats.filter((c) => c.short_id === idOrShort).map((c) => c.id), + ); + if (exactIds.size === 1) return [...exactIds][0]; + + // Suffix fallback: `chats` re-derives short IDs on every run, so a short + // ID captured earlier may no longer be assigned. Any unique tail (>= 4 + // characters, the same minimum `chats` uses) of the short ID or of the + // conversation ID body still resolves. + if (idOrShort.length >= 4) { + const suffixIds = new Set( + chats + .filter( + (c) => + c.short_id.endsWith(idOrShort) || + makeShortId(c.id).endsWith(idOrShort), + ) + .map((c) => c.id), + ); + if (suffixIds.size === 1) return [...suffixIds][0]; + if (suffixIds.size > 1) { + console.error(`ID '${idOrShort}' matches multiple conversations:`); + for (const id of suffixIds) console.error(` ${id}`); + process.exit(1); + } } + console.error( `Short ID '${idOrShort}' not found. Run \`teams chats\` first.`, ); diff --git a/tests/resolve-conversation-id.test.ts b/tests/resolve-conversation-id.test.ts new file mode 100644 index 0000000..7b1c091 --- /dev/null +++ b/tests/resolve-conversation-id.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveConversationId } from "../src/cli.js"; +import type { FormattedChat } from "../src/formatting.js"; + +function chat(id: string, short_id: string): FormattedChat { + return { short_id, name: `Chat ${short_id}`, type: "Chat", preview: "", time: "", id }; +} + +const OSAMA = "19:63d43783-f462-431d-9a35-02c4bc67344e_b5bb788f-e279-4f3d-acff-0790cbe8ea6f@unq.gbl.spaces"; +const OTHER = "19:76559d43-ef26-489f-a90b-06467794080b_11111111-2222-3333-4444-555555555555@unq.gbl.spaces"; + +describe("resolveConversationId", () => { + it("passes through full conversation IDs", () => { + expect(resolveConversationId(OSAMA, [])).toBe(OSAMA); + }); + + it("resolves an exact short id", () => { + expect(resolveConversationId("ab12", [chat(OSAMA, "ab12"), chat(OTHER, "cd34")])).toBe(OSAMA); + }); + + it("resolves an exact short id when the thread has duplicate index rows", () => { + const idx = [chat(OSAMA, "ab12"), chat(OSAMA, "zz99"), chat(OTHER, "cd34")]; + expect(resolveConversationId("ab12", idx)).toBe(OSAMA); + }); + + it("resolves a stale short id via unique-suffix fallback", () => { + // `chats` re-assigned the short id since it was captured; the old + // value is still a tail of the new assignment. + const idx = [chat(OSAMA, "01ab12"), chat(OTHER, "cd34")]; + expect(resolveConversationId("ab12", idx)).toBe(OSAMA); + }); + + it("resolves a unique suffix of duplicate rows that share one thread", () => { + // The conversations API returns duplicate rows for the same thread + // with different short ids; a shared tail must still resolve. + const idx = [chat(OSAMA, "c3ea6f"), chat(OSAMA, "d7ea6f"), chat(OTHER, "cd34")]; + expect(resolveConversationId("ea6f", idx)).toBe(OSAMA); + }); + + it("resolves a unique suffix of the conversation id body", () => { + const idx = [chat(OSAMA, "ab12"), chat(OTHER, "cd34")]; + expect(resolveConversationId("cbe8ea6f", idx)).toBe(OSAMA); + }); + + it("lists candidates when a suffix matches different threads", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + try { + const idx = [chat(OSAMA, "c3ea6f"), chat(OTHER, "d7ea6f")]; + resolveConversationId("ea6f", idx); + expect(exit).toHaveBeenCalledWith(1); + expect(err).toHaveBeenCalledWith(expect.stringContaining("matches multiple conversations")); + } finally { + err.mockRestore(); + exit.mockRestore(); + } + }); + + it("exits when nothing matches", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + try { + resolveConversationId("ffff", [chat(OSAMA, "ab12")]); + expect(exit).toHaveBeenCalledWith(1); + } finally { + err.mockRestore(); + exit.mockRestore(); + } + }); + + it("does not suffix-match inputs shorter than 4 characters", () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + try { + resolveConversationId("f6f", [chat(OSAMA, "c3f6f")]); + expect(exit).toHaveBeenCalledWith(1); + } finally { + exit.mockRestore(); + } + }); +});