diff --git a/apps/discord-bot/DESIGN-thread-restore.md b/apps/discord-bot/DESIGN-thread-restore.md new file mode 100644 index 00000000000..2ba1c88d949 --- /dev/null +++ b/apps/discord-bot/DESIGN-thread-restore.md @@ -0,0 +1,321 @@ +# Discord bot: restore thread bridges after restart + +**Status:** implemented (boot + T3 reconnect rehydrate) +**Branch context:** Discord bot restore bridges on start +**Non-goals for implementers:** do not deploy/restart microVM or host services as part of this design work unless explicitly asked later. + +## Problem + +After the Discord bot process restarts (or the T3 WebSocket session is replaced): + +- Durable **Discord ↔ T3** links already exist in `links.json`. +- **Live** T3 `subscribeThread` bridges and in-memory stream state do **not**. +- In-flight turns go silent on Discord until a human `@mention`s again. +- Completed turns that finished while the bot was down may never get a Discord final post. + +Mentions still **continue** the same T3 thread (lookup works). What is missing is **automatic re-attach + catch-up finalize**. + +## Goals + +1. On bot boot (and T3 reconnect), re-establish bridges for the right set of links. +2. If a turn finished offline, run the **normal finalize path**: remove in-progress stream tips (from **stored ids only**), post final answer + `stream-history.md` as today. +3. Cap concurrent bridges; prefer freshest activity. + +## Non-goals (v1) + +- Multi-bot HA / shared remote DB. +- Scanning arbitrary Discord threads for `_Working.._` outside stored stream message ids. +- Restoring idle historical links “just in case.” +- Perfect recovery of Discord message identity when stream tip ids were never persisted. + +--- + +## Locked decisions + +| # | Decision | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Restore set:** only threads that are **running** or **pending** (approval / user-input). Not idle/recent-by-default. | +| 2 | **Missed finalize:** **auto-post** to Discord using the usual pipeline — find previous in-progress messages for that turn (via **stored** tip/stale ids), replace with final message(s) + stream-history transcript. | +| 3 | **Orphan cleanup:** **only stored stream message ids**. Do not scan other channels/threads for Working.. markers. | +| 4 | **Cap:** max **50** concurrent bridges. When over capacity, **drop oldest by `lastActivityAt`** (do not restore / evict ensure). | + +--- + +## Current building blocks + +| Piece | Location | Notes | +| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------- | +| Link store | `apps/discord-bot/src/store/ThreadLinkStore.ts` | `$T3_DISCORD_BOT_DATA_DIR/links.json` | +| Link fields today | `discordThreadId`, `t3ThreadId`, `projectId`, `channelId`, `guildId`, `createdAt` | Written on first worktree turn | +| Bridge entry | `bridgeThreadToDiscord` in `ResponseBridge.ts` | In-memory `bridgeFibers` only | +| Adopt-without-repost | `adoptedInitialSnapshot` in bridge | Avoids re-posting completed answers on re-subscribe | +| Finalize | `finalizeAssistantMessage` | Create final + delete stream tip ids; stream-history.md | + +Guest path today: `/var/lib/t3/discord-bot/links.json`. + +--- + +## Architecture + +``` +main.ts boot / T3 reconnect + │ + ▼ + ThreadLinkStore.list() + │ + ▼ + selectCandidates(running | pending) + │ + ▼ + rank by lastActivityAt desc, take ≤ 50 + │ + ▼ + BridgeHub.ensure(link) ──singleflight per discordThreadId──► + │ + ├── subscribeThread(t3ThreadId) + ├── snapshot: turn running? → resume stream tips (new tip if needed) + └── snapshot: turn complete + not finalized on Discord? + → finalize (delete stored openStreamMessageIds, post final + transcript) +``` + +**Link** = durable mapping. +**Bridge** = live fiber + ephemeral Discord stream state. +Boot/reconnect turns links → bridges for the selected subset only. + +--- + +## Data model (links.json v2) + +Version the document so old arrays still load. + +```json +{ + "version": 2, + "links": [ + { + "discordThreadId": "string", + "t3ThreadId": "string", + "projectId": "string", + "channelId": "string", + "guildId": "string", + "createdAt": "ISO-8601", + "updatedAt": "ISO-8601", + "lastActivityAt": "ISO-8601", + "status": "active", + "lastSeenTurnId": null, + "lastFinalizedAssistantId": null, + "openStreamMessageIds": [] + } + ] +} +``` + +| Field | Purpose | +| ---------------------------- | ---------------------------------------------------------------------------------------------- | +| `lastActivityAt` | Eviction order; touch on mention, bridge event, put | +| `status` | `active` \| `tombstone` (missing Discord/T3 thread) | +| `lastSeenTurnId` | Correlate turn for catch-up finalize | +| `lastFinalizedAssistantId` | Skip re-finalizing the same assistant bubble | +| `lastThreadSnapshotSequence` | Orchestration cursor for performant `subscribeThread({ afterSequence })` resume | +| `lastDeliveredSequence` | Advances only after Discord delivery succeeds; lag vs orchestration triggers catch-up | +| `openStreamMessageIds` | Discord message ids for in-progress tips (+ stale tips). **Only** these are deleted on cleanup | + +**Migration:** if file is a bare array (v1), wrap as `{ version: 2, links: [...] }` and default `lastActivityAt = createdAt`, empty stream ids, `status: active`. + +**Store API additions:** + +- `getByT3ThreadId` +- `touch(discordThreadId, at?)` +- `tombstone(discordThreadId)` +- `updateBridgeHints(discordThreadId, partial)` — stream ids, last finalized assistant, lastSeenTurnId +- Atomic write: temp file + rename; mode `0o600` + +--- + +## BridgeHub + +Extract from ad-hoc `bridgeFibers` in `ResponseBridge.ts`. + +| Method | Behavior | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `ensure(link, opts?)` | Singleflight per `discordThreadId`. If live fiber exists, no-op (or replace if `t3ThreadId` changed). Starts `runBridge`. | +| `drop(discordThreadId)` | Interrupt fiber; clear memory only (keep durable link). | +| `listActive()` | Ops / alerts | +| Cap enforcement | Before ensure beyond 50: drop active bridges with oldest `lastActivityAt` among **active fibers**, or skip restore of older candidates | + +`bridgeThreadToDiscord` becomes a thin wrapper around `BridgeHub.ensure` (callers: MentionRouter, boot rehydrate, T3 reconnect). + +**Opts (v1):** + +- `workingAckMessageId?` — only from mention path +- `mode: "interactive" | "rehydrate"` — rehydrate skips posting a new Working.. unless turn is running and no tip exists + +--- + +## Candidate selection (decision 1) + +For each `status === active` link: + +1. Resolve T3 thread from shell / thread detail (skip + tombstone if missing). +2. Resolve Discord channel (GET); on 404 tombstone. +3. Include if **any** of: + - `latestTurn.state === "running"` (or equivalent “in progress”) + - pending approval(s) on the thread + - pending user-input request(s) +4. Exclude idle completed threads (they restore **lazily** on next `@mention` via existing link lookup). + +Sort included candidates by `lastActivityAt` **descending**. +Take first **50**. Log dropped count. + +--- + +## Catch-up finalize (decision 2 + 3) + +When rehydrate snapshot shows: + +- Turn **not** running, and +- There is a completed assistant answer for the last user turn, and +- `assistant.id !== lastFinalizedAssistantId` (or open stream ids still present / never finalized), + +then run the **same** finalize path as live turns: + +1. Load `openStreamMessageIds` from the link (and any in-memory state if fiber just started). +2. **Delete only those ids** (decision 3) — no channel-wide Working.. scan. +3. Post final answer text (use existing `finalAnswerText` / finalize rules) + attachments. +4. Attach `stream-history.md` from accumulated progress text when available (from T3 messages this turn if stream text was not in memory). +5. Persist `lastFinalizedAssistantId`, clear `openStreamMessageIds`, `touch` link. + +If turn is **still running**: + +- `ensure` bridge, adopt snapshot, open/edit a stream tip if needed. +- Prefer reusing stored tip ids when still editable and latest; else create a new tip and **append** id to `openStreamMessageIds` (leave old ids listed so finalize still deletes them). +- Do not invent tips in unrelated threads. + +**Transcript source when process memory is empty:** reconstruct from T3 thread messages after last user message (same as `turnProgressText`), not from Discord history scraping. + +--- + +## Boot sequence + +``` +1. DiscordBotRunning / gateway up +2. t3.connect() +3. migrate links.json if needed +4. candidates = select running|pending +5. sort by lastActivityAt desc; cap 50 +6. for each candidate (concurrency 3–5): + BridgeHub.ensure(link, { mode: "rehydrate" }) +7. log: restored / failed / tombstoned / capped-out +8. Effect.never (existing) +``` + +Do **not** restart microVM or host units for this feature. + +--- + +## T3 WebSocket reconnect + +When `T3Session` replaces the session (existing pattern clears thread fibers): + +1. Interrupt / clear BridgeHub fibers (or they die with subscribe). +2. Re-run the **same** candidate selection + ensure (running|pending, cap 50). +3. Catch-up finalize again if turns completed during the outage. + +--- + +## Mention path (unchanged contract) + +- Resolve link by Discord thread id. +- `touch` + `BridgeHub.ensure` + `startTurn` as today. +- On first link create, `put` full record with `lastActivityAt = now`. +- While streaming, bridge **must** persist `openStreamMessageIds` (and stale tip ids) on each tip create/displace so restart cleanup works (decision 3). + +--- + +## Eviction (decision 4) + +- **Hard cap:** 50 concurrent **active bridges**. +- When selecting restores: only top 50 by `lastActivityAt`. +- If an interactive mention needs a bridge while at 50: + - Prefer evicting the active bridge with oldest `lastActivityAt` that is **not** running/pending; if all 50 are running/pending, log error / alert and still ensure the mentioned thread (or refuse with a Discord error — **prefer ensure mentioned thread** and drop oldest non-critical). +- Evict = `drop` fiber only; **keep** durable link. + +Suggested priority for forced eviction: idle completed bridges first; never evict `running` or pending-approval if avoidable. + +--- + +## Failure modes + +| Case | Handling | +| ---------------------------------- | ----------------------------------------------------------------- | +| Discord thread deleted | Tombstone link; skip | +| T3 thread missing | Tombstone link; skip | +| Subscribe timeout | Log + ops alert; leave link active for next mention | +| Finalize fails (Discord 4xx/5xx) | Log + alert; keep `openStreamMessageIds` for retry on next ensure | +| Corrupt links.json | Load empty / backup; do not crash bot | +| Stream ids stale (already deleted) | Delete is best-effort (ignore 404); clear hints after attempt | + +--- + +## Implementation PRs + +### PR1 — Store v2 + BridgeHub shell + +- Versioned links schema + migration +- `getByT3ThreadId`, `touch`, `tombstone`, `updateBridgeHints` +- `BridgeHub` with singleflight `ensure` / `drop` / cap stub +- Wire MentionRouter to Hub without behavior change +- Tests: migrate v1→v2, put/get/touch + +### PR2 — Persist stream message ids + +- Bridge writes `openStreamMessageIds` (+ stale) on tip create/displace/clear on finalize +- Tests: hints updated; finalize clears + +### PR3 — Boot + reconnect rehydrate + +- `selectCandidates(running|pending)` +- Cap 50 by `lastActivityAt` +- `main.ts` after connect; T3 session replace hook +- Catch-up finalize when turn complete and not yet finalized +- Integration-style tests with fakes + +### PR4 — Eviction polish + alerts + +- Cap eviction policy +- Ops alert on rehydrate failures / finalize retry exhaustion +- Docs: `docs/integrations/discord-bot.md` short section + +--- + +## Success criteria + +1. Bot killed mid-turn → start bot → Discord stream resumes or finalizes **without** a new `@mention`. +2. Turn completed while bot down → on boot, Discord gets **one** final post + transcript; stored tip ids removed; **no** duplicate finals on second restart. +3. Idle linked threads: no bridge until next mention; mention still continues T3 thread. +4. Never deletes messages outside `openStreamMessageIds` for that link. +5. ≤ 50 bridges; overflow prefers freshest `lastActivityAt`. +6. T3 WS reconnect re-applies the same restore rules. + +--- + +## File touch map (expected) + +| Path | Change | +| ------------------------------------------------- | ------------------------------------------------------- | +| `apps/discord-bot/src/store/ThreadLinkStore.ts` | v2 schema, migration, new APIs | +| `apps/discord-bot/src/features/BridgeHub.ts` | new | +| `apps/discord-bot/src/features/ResponseBridge.ts` | hub integration, persist stream ids, rehydrate finalize | +| `apps/discord-bot/src/features/MentionRouter.ts` | ensure via hub, touch | +| `apps/discord-bot/src/t3/T3Session.ts` | reconnect callback / re-ensure hook | +| `apps/discord-bot/src/main.ts` | boot rehydrate | +| `apps/discord-bot/src/**/*.test.ts` | store, hub, candidate selection | +| `docs/integrations/discord-bot.md` | operator-facing restore notes | + +--- + +## Out of scope reminders + +- Do not depend on Honeycomb/Sentry for restore logic. +- Do not scan guild history for orphan Working.. messages. +- Do not restore all historical links by default. diff --git a/apps/discord-bot/docs/agent-turn-rules.md b/apps/discord-bot/docs/agent-turn-rules.md new file mode 100644 index 00000000000..8c42cacedfe --- /dev/null +++ b/apps/discord-bot/docs/agent-turn-rules.md @@ -0,0 +1,32 @@ +# Discord turn rules (client overlay) + +**Layer:** Discord overlay on top of global T3 product rules. + +Global product policy lives in `apps/server/docs/t3-agent-rules.md` and is +injected by the T3 server on session start (and again after compaction). This +file is **only** Discord-specific policy. Do not restate global rules here. + +You = Discord bot. Final reply posts in-thread. "you" = you unless another person is named. +Don't mix up requester vs thread starter vs others. + +**Style:** lead with answer; concise; no status recaps. + +**PR:** always open for commits/landable work; draft until lint/typecheck/tests/`vp check`; then mark ready. No abandoned drafts. +**PR lifecycle:** before push/handoff, check the linked PR is still **open** (`gh pr view --json state` or equivalent). If **merged** or **closed**, do **not** keep committing on that branch — branch fresh from the correct base (`fork/discord` overlay / `fork/changes` / etc.), re-apply unmerged work, open a **new** PR (draft if still iterating). One merged PR is not a free ticket for later commits. + +**cab (commits):** bot already resolved the identity map — do **not** re-lookup or invent emails. +Turn field `cab: Name | Name2 ` (deduped). For each entry, append a git trailer: +`Co-authored-by: Name ` (blank line before first). Keep default bot author/committer. +Check: `git log -1 --format=%B`. PR co-author list: `[@login](https://github.com/login)` only — never bare `@login`. +`unmapped:` means no trailer for that Discord user. + +**PR footer** from turn `pr` + `t3` fields (paste at PR body end; bot may re-append): +`opened by [{name}](https://discord.com/users/{uid}) in chat thread **Discord** · [{title}](https://discord.com/channels/{g}/{c}/{m}) · [T3]({t3url})` +URL forms only; never bare snowflakes. +**t3url:** private GitHub repo → turn `t3 full=…`; public repo → turn `t3 short=…` (host is always just `t3vm`). Prefer short when unsure (don't leak internal hosts on public PRs). + +**jira:** put turn keys in PR body (prefer primary in title/branch). + +**ref:** referenced msg is primary context when present. + +**Sentry:** parse starter/ref → Sentry for trace id → Honeycomb link first (turn tpl) → then user ask. Don't invent data; report tool failures. diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json new file mode 100644 index 00000000000..cb5f3211b6a --- /dev/null +++ b/apps/discord-bot/package.json @@ -0,0 +1,35 @@ +{ + "name": "@t3tools/discord-bot", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "browser-profile": "node --import tsx src/browserProfileCli.ts", + "dev": "node --watch --import tsx src/main.ts", + "dev:teams": "node --watch --import tsx src/teams-main.ts", + "start": "node --import tsx src/main.ts", + "start:teams": "node --import tsx src/teams-main.ts", + "typecheck": "tsgo --noEmit", + "test": "vp test run" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@microsoft/teams.apps": "2.0.14", + "@t3tools/client-runtime": "workspace:*", + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "dfx": "catalog:", + "effect": "catalog:", + "playwright-core": "1.60.0", + "yaml": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "tsx": "^4.20.5", + "vite-plus": "catalog:" + }, + "engines": { + "node": "^22.16 || ^23.11 || >=24.10" + } +} diff --git a/apps/discord-bot/src/alertProcessRules.test.ts b/apps/discord-bot/src/alertProcessRules.test.ts new file mode 100644 index 00000000000..54d864a7f93 --- /dev/null +++ b/apps/discord-bot/src/alertProcessRules.test.ts @@ -0,0 +1,80 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + loadAlertProcessRulesFromFileSync, + parseAlertProcessRulesDocument, +} from "./alertProcessRules.ts"; + +describe("parseAlertProcessRulesDocument", () => { + it("parses structured rules with size and duration shorthands", () => { + const rules = parseAlertProcessRulesDocument({ + rules: [ + { + id: "jaeger-linux", + match: "jaeger-linux", + rss: "4gb", + duration: "5m", + }, + ], + }); + + expect(rules).toEqual([ + { + id: "jaeger-linux", + match: "jaeger-linux", + rssMbThreshold: 4096, + sustainedForMs: 5 * 60_000, + }, + ]); + }); + + it("accepts cpu-only rules", () => { + const rules = parseAlertProcessRulesDocument([ + { + id: "cpu-hot", + match: "worker", + cpuPercentThreshold: 90, + sustainedFor: "2m", + }, + ]); + + expect(rules[0]).toEqual({ + id: "cpu-hot", + match: "worker", + cpuPercentThreshold: 90, + sustainedForMs: 2 * 60_000, + }); + }); +}); + +describe("loadAlertProcessRulesFromFileSync", () => { + it("loads yaml files", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bot-alert-rules-")); + const yamlPath = NodePath.join(dir, "alert-rules.yaml"); + await NodeFSP.writeFile( + yamlPath, + [ + "rules:", + " - id: jaeger-linux", + " match: jaeger-linux", + " rss: 4gb", + " duration: 5m", + "", + ].join("\n"), + "utf8", + ); + + expect(loadAlertProcessRulesFromFileSync(yamlPath)).toEqual([ + { + id: "jaeger-linux", + match: "jaeger-linux", + rssMbThreshold: 4096, + sustainedForMs: 5 * 60_000, + }, + ]); + }); +}); diff --git a/apps/discord-bot/src/alertProcessRules.ts b/apps/discord-bot/src/alertProcessRules.ts new file mode 100644 index 00000000000..63771904ffd --- /dev/null +++ b/apps/discord-bot/src/alertProcessRules.ts @@ -0,0 +1,162 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { parse as parseYaml } from "yaml"; + +import { expandHomePath } from "./projectAliases.ts"; + +export interface AlertProcessRule { + readonly id: string; + readonly match: string; + readonly rssMbThreshold?: number; + readonly cpuPercentThreshold?: number; + readonly sustainedForMs: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseCpuPercent(value: unknown, field: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error(`${field} must be a non-negative number.`); + } + return value; +} + +function parseSizeToMb(value: unknown, field: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value; + } + if (typeof value !== "string") { + throw new Error(`${field} must be a non-negative number or size string.`); + } + const trimmed = value.trim().toLowerCase(); + const match = /^(?\d+(?:\.\d+)?)\s*(?b|kb|kib|mb|mib|gb|gib|tb|tib)?$/u.exec( + trimmed, + ); + if (!match?.groups) { + throw new Error(`${field} must be a size like 4096, 4gb, or 512mb.`); + } + const amount = Number(match.groups.amount); + const unit = match.groups.unit ?? "mb"; + const multiplier = + unit === "b" + ? 1 / (1024 * 1024) + : unit === "kb" || unit === "kib" + ? 1 / 1024 + : unit === "mb" || unit === "mib" + ? 1 + : unit === "gb" || unit === "gib" + ? 1024 + : unit === "tb" || unit === "tib" + ? 1024 * 1024 + : null; + if (multiplier === null) { + throw new Error(`${field} has an unsupported unit.`); + } + return amount * multiplier; +} + +function parseDurationToMs(value: unknown, field: string): number { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value; + } + if (typeof value !== "string") { + throw new Error(`${field} must be a non-negative number of milliseconds or duration string.`); + } + const trimmed = value.trim().toLowerCase(); + const match = /^(?\d+(?:\.\d+)?)\s*(?ms|s|m|h|d)?$/u.exec(trimmed); + if (!match?.groups) { + throw new Error(`${field} must be a duration like 5m, 30s, or 60000.`); + } + const amount = Number(match.groups.amount); + const unit = match.groups.unit ?? "ms"; + const multiplier = + unit === "ms" + ? 1 + : unit === "s" + ? 1_000 + : unit === "m" + ? 60_000 + : unit === "h" + ? 60 * 60_000 + : unit === "d" + ? 24 * 60 * 60_000 + : null; + if (multiplier === null) { + throw new Error(`${field} has an unsupported unit.`); + } + return amount * multiplier; +} + +function parseRule(value: unknown, index: number): AlertProcessRule { + if (!isRecord(value)) { + throw new Error(`Process alert rule #${index + 1} must be an object.`); + } + const id = normalizeNonEmptyString(value.id); + if (id === null) { + throw new Error(`Process alert rule #${index + 1} must include a non-empty id.`); + } + const match = normalizeNonEmptyString(value.match); + if (match === null) { + throw new Error(`Process alert rule '${id}' must include a non-empty match string.`); + } + const rssMbThreshold = parseSizeToMb( + value.rssMbThreshold ?? value.rssMb ?? value.rss, + `Process alert rule '${id}' rss`, + ); + const cpuPercentThreshold = parseCpuPercent( + value.cpuPercentThreshold ?? value.cpuPercent ?? value.cpu, + `Process alert rule '${id}' cpu`, + ); + if (rssMbThreshold === undefined && cpuPercentThreshold === undefined) { + throw new Error(`Process alert rule '${id}' must set rss and/or cpu thresholds.`); + } + const sustainedForMs = parseDurationToMs( + value.sustainedForMs ?? value.sustainedFor ?? value.duration, + `Process alert rule '${id}' duration`, + ); + if (sustainedForMs < 0) { + throw new Error(`Process alert rule '${id}' duration must be non-negative.`); + } + return { + id, + match, + ...(rssMbThreshold === undefined ? {} : { rssMbThreshold }), + ...(cpuPercentThreshold === undefined ? {} : { cpuPercentThreshold }), + sustainedForMs, + }; +} + +export function parseAlertProcessRulesDocument(document: unknown): ReadonlyArray { + const source = Array.isArray(document) + ? document + : isRecord(document) && Array.isArray(document.rules) + ? document.rules + : null; + if (source === null) { + throw new Error("Alert process rules file must be an array or an object with a rules array."); + } + return source.map((entry, index) => parseRule(entry, index)); +} + +export function loadAlertProcessRulesFromFileSync( + filePath: string | undefined, +): ReadonlyArray { + if (filePath === undefined || filePath.trim() === "") return []; + const resolvedPath = NodePath.resolve(expandHomePath(filePath.trim())); + if (!NodeFS.existsSync(resolvedPath)) { + throw new Error(`Alert process rules file not found: ${resolvedPath}`); + } + const raw = NodeFS.readFileSync(resolvedPath, "utf8").trim(); + if (raw.length === 0) return []; + const document = resolvedPath.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw); + return parseAlertProcessRulesDocument(document); +} diff --git a/apps/discord-bot/src/browser/BrowserAutomationHost.test.ts b/apps/discord-bot/src/browser/BrowserAutomationHost.test.ts new file mode 100644 index 00000000000..68e871f2272 --- /dev/null +++ b/apps/discord-bot/src/browser/BrowserAutomationHost.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + browserOperationDeadlineMs, + BrowserOperationTimeoutError, + withBrowserOperationDeadline, +} from "./BrowserAutomationHost.ts"; + +describe("browser automation host deadline", () => { + it("reserves time for delivering the response to the broker", () => { + expect(browserOperationDeadlineMs(15_000)).toBe(14_000); + expect(browserOperationDeadlineMs(500)).toBe(450); + }); + + it("rejects a stalled operation before the broker timeout", async () => { + const stalled = new Promise(() => {}); + let interrupted = false; + + await expect( + withBrowserOperationDeadline(stalled, 10, () => { + interrupted = true; + }), + ).rejects.toBeInstanceOf(BrowserOperationTimeoutError); + expect(interrupted).toBe(true); + }); + + it("does not interrupt an operation that completes before its deadline", async () => { + let interrupted = false; + + await expect( + withBrowserOperationDeadline(Promise.resolve("complete"), 100, () => { + interrupted = true; + }), + ).resolves.toBe("complete"); + expect(interrupted).toBe(false); + }); +}); diff --git a/apps/discord-bot/src/browser/BrowserAutomationHost.ts b/apps/discord-bot/src/browser/BrowserAutomationHost.ts new file mode 100644 index 00000000000..6f253b6b8e5 --- /dev/null +++ b/apps/discord-bot/src/browser/BrowserAutomationHost.ts @@ -0,0 +1,162 @@ +import * as NodeTimersPromises from "node:timers/promises"; + +import { + type EnvironmentId, + type PreviewAutomationHost, + type PreviewAutomationHostFocus, + type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, + type ThreadId, +} from "@t3tools/contracts"; + +import type { DiscordBotConfig } from "../config.ts"; +import { BrowserRuntime } from "./BrowserRuntime.ts"; + +const SUPPORTED_OPERATIONS = [ + "status", + "open", + "navigate", + "snapshot", + "click", + "type", + "press", + "scroll", + "evaluate", + "waitFor", + "recordingStart", + "recordingStop", +] as const; + +const MAX_RESPONSE_RESERVE_MS = 1_000; +const RESPONSE_RESERVE_RATIO = 0.1; + +export class BrowserOperationTimeoutError extends Error { + readonly timeoutMs: number; + + constructor(timeoutMs: number) { + super(`Browser automation host timed out after ${timeoutMs}ms.`); + this.name = "BrowserOperationTimeoutError"; + this.timeoutMs = timeoutMs; + } +} + +export function browserOperationDeadlineMs(timeoutMs: number): number { + const reserve = Math.min( + MAX_RESPONSE_RESERVE_MS, + Math.max(1, timeoutMs * RESPONSE_RESERVE_RATIO), + ); + return Math.max(1, Math.floor(timeoutMs - reserve)); +} + +export function withBrowserOperationDeadline( + operation: Promise, + timeoutMs: number, + onTimeout: () => void = () => {}, +): Promise { + const deadlineMs = browserOperationDeadlineMs(timeoutMs); + const controller = new AbortController(); + const timeout = NodeTimersPromises.setTimeout(deadlineMs, undefined, { + signal: controller.signal, + ref: false, + }).then(() => { + onTimeout(); + throw new BrowserOperationTimeoutError(deadlineMs); + }); + return Promise.race([operation, timeout]).finally(() => controller.abort()); +} + +export class BrowserAutomationHost { + readonly #runtime: BrowserRuntime; + readonly #clientId: string; + readonly #environmentId: EnvironmentId; + #connectionId: PreviewAutomationHostFocus["connectionId"] | null = null; + + private constructor(runtime: BrowserRuntime, profile: string, environmentId: EnvironmentId) { + this.#runtime = runtime; + this.#clientId = `discord-browser-${profile}`; + this.#environmentId = environmentId; + } + + static async launch( + config: DiscordBotConfig, + environmentId: EnvironmentId, + ): Promise { + if (!config.browserExecutablePath) { + throw new Error( + "T3_DISCORD_BROWSER_EXECUTABLE_PATH is required when browser automation is enabled.", + ); + } + if (config.browserAllowedOrigins.length === 0) { + throw new Error( + "T3_DISCORD_BROWSER_ALLOWED_ORIGINS is required when browser automation is enabled.", + ); + } + const runtime = await BrowserRuntime.launch({ + dataDir: config.dataDir, + profile: config.browserProfile, + executablePath: config.browserExecutablePath, + ffmpegPath: config.browserFfmpegPath, + allowedOrigins: config.browserAllowedOrigins, + headless: true, + }); + return new BrowserAutomationHost(runtime, config.browserProfile, environmentId); + } + + registration(): PreviewAutomationHost { + return { + clientId: this.#clientId, + environmentId: this.#environmentId, + supportedOperations: [...SUPPORTED_OPERATIONS], + }; + } + + async consume(event: PreviewAutomationStreamEvent): Promise { + if (event.type === "connected") { + this.#connectionId = event.connectionId; + return null; + } + const responseBase = { + clientId: this.#clientId, + connectionId: event.connectionId, + requestId: event.request.requestId, + } as const; + let response: PreviewAutomationResponse; + try { + const result = await withBrowserOperationDeadline( + this.#runtime.handle(event.request), + event.request.timeoutMs, + () => this.#runtime.interrupt(event.request), + ); + response = { ...responseBase, ok: true, ...(result === undefined ? {} : { result }) }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Browser automation failed."; + response = { + ...responseBase, + ok: false, + error: { + _tag: + cause instanceof BrowserOperationTimeoutError + ? "PreviewAutomationTimeoutError" + : "PreviewAutomationExecutionError", + message, + }, + }; + } + return response; + } + + claim(threadId: ThreadId): PreviewAutomationHostFocus | null { + if (this.#connectionId === null) return null; + return { + clientId: this.#clientId, + environmentId: this.#environmentId, + connectionId: this.#connectionId, + focused: true, + threadId, + }; + } + + close(): Promise { + return this.#runtime.close(); + } +} diff --git a/apps/discord-bot/src/browser/BrowserRecording.test.ts b/apps/discord-bot/src/browser/BrowserRecording.test.ts new file mode 100644 index 00000000000..fa778960eed --- /dev/null +++ b/apps/discord-bot/src/browser/BrowserRecording.test.ts @@ -0,0 +1,20 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { ffmpegRecordingArguments } from "./BrowserRecording.ts"; + +describe("browser recording", () => { + it("builds a shell-free MP4 encoding command", () => { + const args = ffmpegRecordingArguments({ + framesDirectory: "/tmp/browser frames", + outputPath: "/tmp/artifacts/recording.mp4", + }); + + expect(args).toContain(NodePath.join("/tmp/browser frames", "frame-%08d.jpg")); + expect(args).toContain("libx264"); + expect(args).toContain("yuv420p"); + expect(args.at(-1)).toBe("/tmp/artifacts/recording.mp4"); + }); +}); diff --git a/apps/discord-bot/src/browser/BrowserRecording.ts b/apps/discord-bot/src/browser/BrowserRecording.ts new file mode 100644 index 00000000000..6a587ef3678 --- /dev/null +++ b/apps/discord-bot/src/browser/BrowserRecording.ts @@ -0,0 +1,267 @@ +// @effect-diagnostics globalDate:off nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, + PreviewTabId, +} from "@t3tools/contracts"; +import type { CDPSession, Page } from "playwright-core"; + +import { expandHome } from "./ProfileStore.ts"; + +const RECORDING_FRAME_RATE = 15; +const MAX_RECORDING_FRAMES = RECORDING_FRAME_RATE * 60 * 10; +const MAX_FFMPEG_ERROR_BYTES = 8_192; + +interface ScreencastFrame { + readonly data: string; + readonly sessionId: number; + readonly metadata?: { + readonly timestamp?: number; + }; +} + +export class BrowserRecordingError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "BrowserRecordingError"; + } +} + +export function ffmpegRecordingArguments(input: { + readonly framesDirectory: string; + readonly outputPath: string; +}): ReadonlyArray { + return [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-framerate", + String(RECORDING_FRAME_RATE), + "-start_number", + "1", + "-i", + NodePath.join(input.framesDirectory, "frame-%08d.jpg"), + "-vf", + "scale=in_range=pc:out_range=tv,pad=ceil(iw/2)*2:ceil(ih/2)*2", + "-an", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + input.outputPath, + ]; +} + +export async function encodeBrowserRecordingFrames( + executablePath: string, + framesDirectory: string, + outputPath: string, +): Promise { + const args = ffmpegRecordingArguments({ framesDirectory, outputPath }); + await new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn(executablePath, args, { + stdio: ["ignore", "ignore", "pipe"], + }); + let errorOutput = ""; + child.stderr.on("data", (chunk: Buffer) => { + if (errorOutput.length < MAX_FFMPEG_ERROR_BYTES) { + errorOutput += chunk.toString("utf8").slice(0, MAX_FFMPEG_ERROR_BYTES - errorOutput.length); + } + }); + child.once("error", (cause) => { + reject( + new BrowserRecordingError( + `Could not start ffmpeg at ${executablePath}; configure T3_DISCORD_BROWSER_FFMPEG_PATH.`, + { cause }, + ), + ); + }); + child.once("close", (code, signal) => { + if (code === 0) resolve(); + else { + reject( + new BrowserRecordingError( + `ffmpeg failed (${signal ?? `exit ${code ?? "unknown"}`}): ${errorOutput.trim() || "no diagnostics"}`, + ), + ); + } + }); + }); +} + +export class BrowserRecording { + readonly #tabId: PreviewTabId; + readonly #id: string; + readonly #startedAt: string; + readonly #session: CDPSession; + readonly #framesDirectory: string; + readonly #outputPath: string; + readonly #ffmpegPath: string; + readonly #onFrame: (frame: ScreencastFrame) => void; + #frameCount = 0; + #writeChain = Promise.resolve(); + #writeError: unknown = null; + #lastFrameTimestamp: number | null = null; + #finished = false; + + private constructor(input: { + tabId: PreviewTabId; + id: string; + startedAt: string; + session: CDPSession; + framesDirectory: string; + outputPath: string; + ffmpegPath: string; + }) { + this.#tabId = input.tabId; + this.#id = input.id; + this.#startedAt = input.startedAt; + this.#session = input.session; + this.#framesDirectory = input.framesDirectory; + this.#outputPath = input.outputPath; + this.#ffmpegPath = input.ffmpegPath; + this.#onFrame = (frame) => { + void this.#session + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(() => {}); + if (this.#frameCount >= MAX_RECORDING_FRAMES || this.#finished) return; + const timestamp = frame.metadata?.timestamp; + if ( + timestamp !== undefined && + this.#lastFrameTimestamp !== null && + timestamp - this.#lastFrameTimestamp < 1 / RECORDING_FRAME_RATE + ) { + return; + } + if (timestamp !== undefined) this.#lastFrameTimestamp = timestamp; + this.#frameCount += 1; + const framePath = NodePath.join( + this.#framesDirectory, + `frame-${String(this.#frameCount).padStart(8, "0")}.jpg`, + ); + this.#writeChain = this.#writeChain + .then(() => + NodeFSP.writeFile(framePath, Buffer.from(frame.data, "base64"), { mode: 0o600 }), + ) + .catch((cause) => { + this.#writeError ??= cause; + }); + }; + } + + static async start(input: { + readonly page: Page; + readonly tabId: PreviewTabId; + readonly dataDir: string; + readonly ffmpegPath: string; + }): Promise { + const id = `browser-recording-${NodeCrypto.randomUUID()}`; + const browserRoot = NodePath.join(expandHome(input.dataDir), "browser"); + const framesDirectory = NodePath.join(browserRoot, "recordings", id); + const artifactsDirectory = NodePath.join(browserRoot, "artifacts"); + const outputPath = NodePath.join(artifactsDirectory, `${id}.mp4`); + await Promise.all([ + NodeFSP.mkdir(framesDirectory, { recursive: true, mode: 0o700 }), + NodeFSP.mkdir(artifactsDirectory, { recursive: true, mode: 0o700 }), + ]); + let session: CDPSession; + try { + session = await input.page.context().newCDPSession(input.page); + } catch (cause) { + await NodeFSP.rm(framesDirectory, { recursive: true, force: true }); + throw new BrowserRecordingError("Could not attach to the browser tab for recording.", { + cause, + }); + } + const recording = new BrowserRecording({ + tabId: input.tabId, + id, + startedAt: new Date().toISOString(), + session, + framesDirectory, + outputPath, + ffmpegPath: input.ffmpegPath, + }); + session.on("Page.screencastFrame", recording.#onFrame); + try { + await session.send("Page.enable"); + await session.send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1_600, + maxHeight: 1_200, + everyNthFrame: 1, + }); + return recording; + } catch (cause) { + session.off("Page.screencastFrame", recording.#onFrame); + await session.detach().catch(() => {}); + await NodeFSP.rm(framesDirectory, { recursive: true, force: true }); + throw new BrowserRecordingError("Could not start Chromium screencast recording.", { cause }); + } + } + + status(): PreviewAutomationRecordingStatus { + return { + tabId: this.#tabId, + recording: true, + startedAt: this.#startedAt, + }; + } + + async stop(): Promise { + if (this.#finished) throw new BrowserRecordingError("Browser recording is already stopped."); + this.#finished = true; + this.#session.off("Page.screencastFrame", this.#onFrame); + await this.#session.send("Page.stopScreencast").catch(() => {}); + await this.#session.detach().catch(() => {}); + await this.#writeChain; + try { + if (this.#writeError !== null) { + throw new BrowserRecordingError("Could not write browser recording frames.", { + cause: this.#writeError, + }); + } + if (this.#frameCount === 0) { + throw new BrowserRecordingError("Browser recording captured no frames."); + } + await encodeBrowserRecordingFrames(this.#ffmpegPath, this.#framesDirectory, this.#outputPath); + await NodeFSP.chmod(this.#outputPath, 0o600); + const file = await NodeFSP.stat(this.#outputPath); + return { + id: this.#id, + tabId: this.#tabId, + path: this.#outputPath, + mimeType: "video/mp4", + sizeBytes: file.size, + createdAt: new Date().toISOString(), + }; + } finally { + await NodeFSP.rm(this.#framesDirectory, { recursive: true, force: true }); + } + } + + async abort(): Promise { + if (this.#finished) return; + this.#finished = true; + this.#session.off("Page.screencastFrame", this.#onFrame); + await this.#session.send("Page.stopScreencast").catch(() => {}); + await this.#session.detach().catch(() => {}); + await this.#writeChain; + await NodeFSP.rm(this.#framesDirectory, { recursive: true, force: true }); + } + + isForTab(tabId: string | undefined): boolean { + return tabId === undefined || tabId === this.#tabId; + } +} diff --git a/apps/discord-bot/src/browser/BrowserRuntime.test.ts b/apps/discord-bot/src/browser/BrowserRuntime.test.ts new file mode 100644 index 00000000000..3988b599199 --- /dev/null +++ b/apps/discord-bot/src/browser/BrowserRuntime.test.ts @@ -0,0 +1,185 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import type { Page } from "playwright-core"; + +import { + assertUrlAllowed, + browserStatusForPage, + browserTabsToEvict, + captureScreenshotWithCdp, + matchesExpectedUrl, + normalizeBrowserUrl, + readSnapshotPageState, + saveScreenshotArtifact, +} from "./BrowserRuntime.ts"; + +describe("browser tab retention", () => { + it("evicts least-recently-used tabs beyond the retention limit", () => { + expect( + browserTabsToEvict( + ["oldest", "older", "recent", "newest"].map((tabId) => ({ + tabId, + threadId: "thread-a", + })), + new Set(), + 2, + 10, + ), + ).toEqual(["oldest", "older"]); + }); + + it("does not evict tabs with in-flight work or recordings", () => { + expect( + browserTabsToEvict( + ["busy-oldest", "recording", "eligible", "newest"].map((tabId) => ({ + tabId, + threadId: "thread-a", + })), + new Set(["busy-oldest", "recording"]), + 3, + 10, + ), + ).toEqual(["eligible"]); + }); + + it("allows temporary overflow when every excess tab is protected", () => { + const tabs = ["busy-a", "busy-b"].map((tabId) => ({ tabId, threadId: "thread-a" })); + expect(browserTabsToEvict(tabs, new Set(["busy-a", "busy-b"]), 1, 10)).toEqual([]); + }); + + it("enforces a global ceiling across threads", () => { + const tabs = [ + { tabId: "a-old", threadId: "thread-a" }, + { tabId: "b-old", threadId: "thread-b" }, + { tabId: "a-new", threadId: "thread-a" }, + { tabId: "b-new", threadId: "thread-b" }, + ]; + expect(browserTabsToEvict(tabs, new Set(), 2, 3)).toEqual(["a-old"]); + }); +}); + +describe("browser URL policy", () => { + it("normalizes schemeless hosts to HTTPS", () => { + expect(normalizeBrowserUrl("github.com/login").toString()).toBe("https://github.com/login"); + }); + + it("rejects unsafe protocols and non-loopback HTTP", () => { + expect(() => normalizeBrowserUrl("file:///etc/passwd")).toThrow(/only supports HTTP/); + expect(() => assertUrlAllowed(new URL("http://example.com"), [])).toThrow(/loopback/); + expect(() => assertUrlAllowed(new URL("http://127.0.0.1:5173"), [])).not.toThrow(); + }); + + it("supports exact and wildcard origin allowlists", () => { + expect(() => + assertUrlAllowed(new URL("https://github.com/x"), ["https://github.com"]), + ).not.toThrow(); + expect(() => + assertUrlAllowed(new URL("https://api.github.com/x"), ["https://*.github.com"]), + ).not.toThrow(); + expect(() => + assertUrlAllowed(new URL("https://example.com"), ["https://*.github.com"]), + ).toThrow(/not allowed/); + }); + + it("matches verification URL globs", () => { + expect( + matchesExpectedUrl("https://github.com/settings/profile", "https://github.com/settings/**"), + ).toBe(true); + expect(matchesExpectedUrl("https://github.com/login", "https://github.com/settings/**")).toBe( + false, + ); + }); +}); + +describe("browser status", () => { + it("returns a JSON-safe result when no tab is active", async () => { + const status = await browserStatusForPage(null, undefined); + + expect(status).toEqual({ + available: false, + visible: false, + tabId: null, + url: null, + title: null, + loading: false, + }); + expect(JSON.parse(JSON.stringify(status))).toEqual(status); + }); +}); + +describe("browser screenshots", () => { + it("persists a PNG artifact for the agent to attach", async () => { + const dataDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-snapshot-test-")); + const source = Buffer.from("png-content").toString("base64"); + + const artifactPath = await saveScreenshotArtifact(dataDir, source); + + expect(NodePath.dirname(artifactPath)).toBe(NodePath.join(dataDir, "browser", "artifacts")); + await expect(NodeFSP.readFile(artifactPath, "utf8")).resolves.toBe("png-content"); + }); + + it("evaluates DOM extraction as browser-native source", async () => { + let expression: unknown; + const expected = { + url: "https://example.com", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + }; + const page = { + evaluate: async (input: unknown) => { + expression = input; + return expected; + }, + } as unknown as Page; + + await expect(readSnapshotPageState(page)).resolves.toEqual(expected); + expect(expression).toEqual(expect.any(String)); + expect(expression).not.toContain("__name"); + }); + + it("captures through CDP without invoking Playwright's font-aware screenshot wrapper", async () => { + let detached = false; + const page = { + context: () => ({ + newCDPSession: async () => ({ + send: async () => ({ data: "base64-png" }), + detach: async () => { + detached = true; + }, + }), + }), + screenshot: async () => { + throw new Error("page.screenshot must not be called"); + }, + } as unknown as Page; + + await expect(captureScreenshotWithCdp(page)).resolves.toBe("base64-png"); + expect(detached).toBe(true); + }); + + it("detaches the CDP session when capture fails", async () => { + let detached = false; + const page = { + context: () => ({ + newCDPSession: async () => ({ + send: async () => { + throw new Error("capture failed"); + }, + detach: async () => { + detached = true; + }, + }), + }), + } as unknown as Page; + + await expect(captureScreenshotWithCdp(page)).rejects.toThrow("capture failed"); + expect(detached).toBe(true); + }); +}); diff --git a/apps/discord-bot/src/browser/BrowserRuntime.ts b/apps/discord-bot/src/browser/BrowserRuntime.ts new file mode 100644 index 00000000000..80ea62b8b08 --- /dev/null +++ b/apps/discord-bot/src/browser/BrowserRuntime.ts @@ -0,0 +1,622 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { + PreviewTabId, + type PreviewAutomationClickInput, + type PreviewAutomationEvaluateInput, + type PreviewAutomationNavigateInput, + type PreviewAutomationOpenInput, + type PreviewAutomationPressInput, + type PreviewAutomationRequest, + type PreviewAutomationScrollInput, + type PreviewAutomationSnapshot, + type PreviewAutomationTypeInput, + type PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import { chromium, type BrowserContext, type Page } from "playwright-core"; + +import { BrowserRecording } from "./BrowserRecording.ts"; +import { acquireProfileLock, profilePaths, readProfileMetadata } from "./ProfileStore.ts"; + +const MAX_VISIBLE_TEXT = 50_000; +const MAX_INTERACTIVE_ELEMENTS = 200; +const MAX_SCREENSHOT_WIDTH = 1_600; +export const MAX_RETAINED_BROWSER_TABS_PER_THREAD = 4; +export const MAX_RETAINED_BROWSER_TABS_TOTAL = 12; + +type SnapshotPageState = Pick< + PreviewAutomationSnapshot, + "url" | "title" | "loading" | "visibleText" | "interactiveElements" +>; + +// Keep this as browser-native source. Runtime transpilers otherwise inject +// helpers into nested callbacks that are unavailable in the page context. +const SNAPSHOT_PAGE_STATE_EXPRESSION = `(() => { + const maxText = ${MAX_VISIBLE_TEXT}; + const maxElements = ${MAX_INTERACTIVE_ELEMENTS}; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0; + }; + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) { + return element.tagName.toLowerCase() + + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + } + const parts = []; + let current = element; + while (current && parts.length < 8) { + const siblings = current.parentElement + ? Array.from(current.parentElement.children).filter( + (sibling) => sibling.tagName === current.tagName, + ) + : []; + const base = current.tagName.toLowerCase(); + parts.unshift( + siblings.length > 1 + ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" + : base, + ); + current = current.parentElement; + } + return parts.join(" > "); + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]", + )) + .filter(visible) + .slice(0, maxElements) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || + element.innerText || + element.getAttribute("name") || + "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, maxText), + interactiveElements: elements, + }; +})()`; + +export async function readSnapshotPageState(page: Page): Promise { + return (await page.evaluate(SNAPSHOT_PAGE_STATE_EXPRESSION)) as SnapshotPageState; +} + +export async function captureScreenshotWithCdp(page: Page): Promise { + const session = await page.context().newCDPSession(page); + try { + const result = await session.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + return result.data; + } finally { + await session.detach().catch(() => {}); + } +} + +export async function saveScreenshotArtifact(dataDir: string, source: string): Promise { + const artifactsDirectory = NodePath.join(dataDir, "browser", "artifacts"); + const outputPath = NodePath.join(artifactsDirectory, `snapshot-${NodeCrypto.randomUUID()}.png`); + await NodeFSP.mkdir(artifactsDirectory, { recursive: true, mode: 0o700 }); + await NodeFSP.writeFile(outputPath, source, { encoding: "base64", mode: 0o600 }); + return outputPath; +} + +export async function browserStatusForPage( + tabId: string | null, + page: Page | undefined, +): Promise { + const available = page !== undefined && !page.isClosed(); + const viewport = available ? page.viewportSize() : null; + return { + available, + visible: false, + tabId: available ? PreviewTabId.make(tabId!) : null, + url: available ? page.url() : null, + title: available ? await page.title() : null, + loading: false, + ...(viewport === null ? {} : { viewport }), + }; +} + +export function browserTabsToEvict( + tabsByLeastRecentUse: ReadonlyArray<{ readonly tabId: string; readonly threadId: string }>, + protectedTabIds: ReadonlySet, + maximumTabsPerThread = MAX_RETAINED_BROWSER_TABS_PER_THREAD, + maximumTabsTotal = MAX_RETAINED_BROWSER_TABS_TOTAL, +): ReadonlyArray { + const evictions = new Set(); + const threadIds = new Set(tabsByLeastRecentUse.map((tab) => tab.threadId)); + for (const threadId of threadIds) { + const threadTabs = tabsByLeastRecentUse.filter((tab) => tab.threadId === threadId); + let excess = Math.max(0, threadTabs.length - maximumTabsPerThread); + for (const { tabId } of threadTabs) { + if (excess === 0) break; + if (protectedTabIds.has(tabId)) continue; + evictions.add(tabId); + excess -= 1; + } + } + let totalExcess = Math.max(0, tabsByLeastRecentUse.length - evictions.size - maximumTabsTotal); + for (const { tabId } of tabsByLeastRecentUse) { + if (totalExcess === 0) break; + if (evictions.has(tabId) || protectedTabIds.has(tabId)) continue; + evictions.add(tabId); + totalExcess -= 1; + } + return [...evictions]; +} + +export interface BrowserRuntimeOptions { + readonly dataDir: string; + readonly profile: string; + readonly executablePath: string; + readonly ffmpegPath: string; + readonly allowedOrigins: ReadonlyArray; + readonly headless: boolean; +} + +export class BrowserRuntimeError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "BrowserRuntimeError"; + } +} + +export function normalizeBrowserUrl(input: string): URL { + const trimmed = input.trim(); + const candidate = /^[a-z][a-z\d+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`; + let url: URL; + try { + url = new URL(candidate); + } catch (cause) { + throw new BrowserRuntimeError(`Invalid browser URL: ${trimmed}`, { cause }); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new BrowserRuntimeError(`Browser navigation only supports HTTP(S), not ${url.protocol}`); + } + return url; +} + +function wildcardPattern(pattern: string): RegExp { + return new RegExp( + `^${pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replaceAll("*", ".*")}$`, + "i", + ); +} + +export function matchesExpectedUrl(actual: string, expected: string): boolean { + return wildcardPattern(expected).test(actual); +} + +export function assertUrlAllowed(url: URL, allowedOrigins: ReadonlyArray): void { + const loopback = ["localhost", "127.0.0.1", "::1"].includes(url.hostname); + if (url.protocol === "http:" && !loopback) { + throw new BrowserRuntimeError("Plain HTTP browser navigation is restricted to loopback hosts."); + } + if (allowedOrigins.length === 0) return; + if (!allowedOrigins.some((pattern) => wildcardPattern(pattern).test(url.origin))) { + throw new BrowserRuntimeError(`Browser navigation to origin ${url.origin} is not allowed.`); + } +} + +function requestUrl(input: PreviewAutomationNavigateInput): string { + if (input.target?.kind === "environment-port") { + throw new BrowserRuntimeError( + "Project-server navigation is not supported by the Discord browser host yet; pass a URL.", + ); + } + const url = input.target?.kind === "url" ? input.target.url : input.url; + if (url === undefined) throw new BrowserRuntimeError("Navigate requires a URL."); + return url; +} + +function locatorFor( + page: Page, + input: { selector?: string | undefined; locator?: string | undefined }, +) { + if (input.locator !== undefined) return page.locator(input.locator); + if (input.selector !== undefined) return page.locator(input.selector); + return null; +} + +export class BrowserRuntime { + readonly #context: BrowserContext; + readonly #releaseLock: () => Promise; + readonly #allowedOrigins: ReadonlyArray; + readonly #dataDir: string; + readonly #ffmpegPath: string; + readonly #pages = new Map(); + readonly #tabThreads = new Map(); + readonly #busyTabs = new Map(); + #recording: BrowserRecording | null = null; + #activeTabId: string | null = null; + #closed = false; + + private constructor(input: { + context: BrowserContext; + releaseLock: () => Promise; + allowedOrigins: ReadonlyArray; + dataDir: string; + ffmpegPath: string; + }) { + this.#context = input.context; + this.#releaseLock = input.releaseLock; + this.#allowedOrigins = input.allowedOrigins; + this.#dataDir = input.dataDir; + this.#ffmpegPath = input.ffmpegPath; + } + + static async launch(options: BrowserRuntimeOptions): Promise { + const paths = profilePaths(options.dataDir, options.profile); + const metadata = await readProfileMetadata(paths); + if (metadata.browserExecutablePath !== options.executablePath) { + throw new BrowserRuntimeError( + `Profile ${options.profile} was created with ${metadata.browserExecutablePath}; use the same browser executable.`, + ); + } + const releaseLock = await acquireProfileLock(paths); + let context: BrowserContext | null = null; + try { + context = await chromium.launchPersistentContext(paths.userDataDir, { + executablePath: options.executablePath, + headless: options.headless, + viewport: { width: 1_440, height: 900 }, + }); + if (!metadata.verifyUrl || !metadata.expectUrl) { + await context.close(); + throw new BrowserRuntimeError( + `Profile ${options.profile} is unverified; rerun headed setup with --verify-url and --expect-url.`, + ); + } + const verificationUrl = normalizeBrowserUrl(metadata.verifyUrl); + assertUrlAllowed(verificationUrl, options.allowedOrigins); + const verificationPage = context.pages()[0] ?? (await context.newPage()); + await verificationPage.goto(verificationUrl.toString(), { + waitUntil: "domcontentloaded", + timeout: 15_000, + }); + if (!matchesExpectedUrl(verificationPage.url(), metadata.expectUrl)) { + await context.close(); + throw new BrowserRuntimeError( + `Profile ${options.profile} is no longer authenticated; rerun headed setup.`, + ); + } + await verificationPage.close(); + return new BrowserRuntime({ + context, + releaseLock, + allowedOrigins: options.allowedOrigins, + dataDir: options.dataDir, + ffmpegPath: options.ffmpegPath, + }); + } catch (cause) { + await context?.close().catch(() => {}); + await releaseLock(); + if (cause instanceof BrowserRuntimeError) throw cause; + throw new BrowserRuntimeError(`Could not launch browser profile ${options.profile}.`, { + cause, + }); + } + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + try { + await this.#recording?.abort().catch(() => {}); + this.#recording = null; + await this.#context.close(); + } finally { + this.#pages.clear(); + this.#tabThreads.clear(); + this.#busyTabs.clear(); + await this.#releaseLock(); + } + } + + interrupt(request: PreviewAutomationRequest): void { + const tabId = request.tabId ?? this.#activeTabId; + if (tabId === null) return; + const page = this.#pages.get(tabId); + this.#pages.delete(tabId); + this.#tabThreads.delete(tabId); + if (this.#activeTabId === tabId) this.#activeTabId = null; + if (this.#recording?.isForTab(tabId)) { + const recording = this.#recording; + this.#recording = null; + void recording.abort().catch(() => {}); + } + // Closing the target interrupts pending CDP and Playwright commands. Do + // not await it here: the timeout response must still beat the broker. + void page?.close({ runBeforeUnload: false }).catch(() => {}); + } + + #touchTab(tabId: string, page: Page): void { + this.#pages.delete(tabId); + this.#pages.set(tabId, page); + } + + #markTabBusy(tabId: string): void { + this.#busyTabs.set(tabId, (this.#busyTabs.get(tabId) ?? 0) + 1); + } + + #releaseBusyTab(tabId: string): void { + const remaining = (this.#busyTabs.get(tabId) ?? 1) - 1; + if (remaining === 0) this.#busyTabs.delete(tabId); + else this.#busyTabs.set(tabId, remaining); + } + + async #enforceTabLimit(): Promise { + const protectedTabIds = new Set(this.#busyTabs.keys()); + for (const tabId of this.#pages.keys()) { + if (this.#recording?.isForTab(tabId)) protectedTabIds.add(tabId); + } + const tabs = [...this.#pages.keys()].flatMap((tabId) => { + const threadId = this.#tabThreads.get(tabId); + return threadId === undefined ? [] : [{ tabId, threadId }]; + }); + const evictions = browserTabsToEvict(tabs, protectedTabIds); + for (const tabId of evictions) { + const page = this.#pages.get(tabId); + this.#pages.delete(tabId); + this.#tabThreads.delete(tabId); + if (this.#activeTabId === tabId) this.#activeTabId = null; + await page?.close({ runBeforeUnload: false }).catch(() => {}); + } + } + + #page(tabId?: string): Page { + const id = tabId ?? this.#activeTabId; + const page = id === null ? undefined : this.#pages.get(id); + if (id === null || page === undefined || page.isClosed()) { + throw new BrowserRuntimeError( + id === null ? "No browser tab is open." : `Browser tab ${id} was not found.`, + ); + } + this.#activeTabId = id; + this.#touchTab(id, page); + return page; + } + + async #open( + input: PreviewAutomationOpenInput, + threadId: string, + requestedTabId?: string, + ): Promise { + const reusableId = requestedTabId ?? this.#activeTabId; + let page = input.reuseExistingTab === false ? undefined : this.#pages.get(reusableId ?? ""); + let tabId = reusableId; + if (page === undefined || page.isClosed()) { + page = await this.#context.newPage(); + const createdTabId = PreviewTabId.make(`tab-discord-${NodeCrypto.randomUUID()}`); + tabId = createdTabId; + this.#pages.set(createdTabId, page); + this.#tabThreads.set(createdTabId, threadId); + page.once("close", () => { + this.#pages.delete(createdTabId); + this.#tabThreads.delete(createdTabId); + if (this.#recording?.isForTab(createdTabId)) { + const recording = this.#recording; + this.#recording = null; + void recording.abort().catch(() => {}); + } + }); + } + this.#tabThreads.set(tabId!, threadId); + this.#activeTabId = tabId!; + this.#touchTab(tabId!, page); + this.#markTabBusy(tabId!); + try { + if (input.url !== undefined) await this.#navigate(page, input.url, "load", 15_000); + return this.#status(tabId!); + } finally { + this.#releaseBusyTab(tabId!); + } + } + + async #navigate( + page: Page, + rawUrl: string, + readiness: "load" | "domcontentloaded" | "networkidle" | "commit" = "load", + timeout = 15_000, + ): Promise { + const url = normalizeBrowserUrl(rawUrl); + assertUrlAllowed(url, this.#allowedOrigins); + await page.goto(url.toString(), { waitUntil: readiness, timeout }); + } + + async #status(tabId?: string): Promise { + const id = tabId ?? this.#activeTabId; + const page = id === null ? undefined : this.#pages.get(id); + if (id !== null && page !== undefined && !page.isClosed()) this.#touchTab(id, page); + return browserStatusForPage(id, page); + } + + async #snapshot(page: Page): Promise { + const pageState = await readSnapshotPageState(page); + const accessibilityTree = await page + .locator("body") + .ariaSnapshot({ timeout: 5_000 }) + .catch(() => ""); + // Playwright's screenshot wrapper waits for document.fonts.ready. Some + // authenticated apps leave that promise pending, so capture through CDP. + const source = await captureScreenshotWithCdp(page); + const artifactPath = await saveScreenshotArtifact(this.#dataDir, source); + const viewport = page.viewportSize() ?? { width: 1_440, height: 900 }; + return { + ...pageState, + accessibilityTree, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: source, + width: Math.min(viewport.width, MAX_SCREENSHOT_WIDTH), + height: viewport.height, + path: artifactPath, + }, + }; + } + + async handle(request: PreviewAutomationRequest): Promise { + const busyTabId = request.operation === "open" ? null : (request.tabId ?? this.#activeTabId); + if (busyTabId !== null) { + if (this.#pages.has(busyTabId)) this.#tabThreads.set(busyTabId, request.threadId); + this.#markTabBusy(busyTabId); + } + try { + return await this.#handle(request); + } finally { + if (busyTabId !== null) { + const page = this.#pages.get(busyTabId); + if (page !== undefined && !page.isClosed()) this.#touchTab(busyTabId, page); + this.#releaseBusyTab(busyTabId); + } + await this.#enforceTabLimit(); + } + } + + async #handle(request: PreviewAutomationRequest): Promise { + const timeout = request.timeoutMs; + switch (request.operation) { + case "status": + return this.#status(request.tabId); + case "open": + return this.#open( + request.input as PreviewAutomationOpenInput, + request.threadId, + request.tabId, + ); + case "navigate": { + const input = request.input as PreviewAutomationNavigateInput; + const page = this.#page(request.tabId); + const readiness = + input.readiness === "domContentLoaded" + ? "domcontentloaded" + : input.readiness === "none" + ? "commit" + : input.readiness; + await this.#navigate(page, requestUrl(input), readiness, input.timeoutMs ?? timeout); + return this.#status(request.tabId); + } + case "snapshot": + return this.#snapshot(this.#page(request.tabId)); + case "click": { + const input = request.input as PreviewAutomationClickInput; + const page = this.#page(request.tabId); + const locator = locatorFor(page, input); + if (locator !== null) await locator.click({ timeout: input.timeoutMs ?? timeout }); + else await page.mouse.click(input.x!, input.y!); + return { clicked: true }; + } + case "type": { + const input = request.input as PreviewAutomationTypeInput; + const page = this.#page(request.tabId); + const locator = locatorFor(page, input) ?? page.locator(":focus"); + if (input.clear) await locator.fill(input.text, { timeout: input.timeoutMs ?? timeout }); + else await locator.pressSequentially(input.text, { timeout: input.timeoutMs ?? timeout }); + return { typed: true }; + } + case "press": { + const input = request.input as PreviewAutomationPressInput; + const key = [...(input.modifiers ?? []), input.key].join("+"); + await this.#page(request.tabId).keyboard.press(key); + return { pressed: true }; + } + case "scroll": { + const input = request.input as PreviewAutomationScrollInput; + const page = this.#page(request.tabId); + const x = input.deltaX ?? 0; + const y = input.deltaY ?? 0; + const locator = locatorFor(page, input); + if (locator === null) await page.mouse.wheel(x, y); + else + await locator.evaluate((element, delta) => element.scrollBy(delta.x, delta.y), { x, y }); + return { scrolled: true }; + } + case "evaluate": { + const input = request.input as PreviewAutomationEvaluateInput; + return this.#page(request.tabId).evaluate(input.expression); + } + case "waitFor": { + const input = request.input as PreviewAutomationWaitForInput; + const page = this.#page(request.tabId); + const waits: Array> = []; + const locator = locatorFor(page, input); + if (locator !== null) + waits.push(locator.waitFor({ state: "visible", timeout: input.timeoutMs ?? timeout })); + if (input.text !== undefined) + waits.push( + page + .getByText(input.text, { exact: false }) + .first() + .waitFor({ timeout: input.timeoutMs ?? timeout }), + ); + if (input.urlIncludes !== undefined) + waits.push( + page.waitForURL((url) => url.toString().includes(input.urlIncludes!), { + timeout: input.timeoutMs ?? timeout, + }), + ); + await Promise.all(waits); + return this.#status(request.tabId); + } + case "recordingStart": + if (this.#recording !== null) { + throw new BrowserRuntimeError("A browser recording is already active."); + } + { + const tabId = request.tabId ?? this.#activeTabId; + const page = this.#page(request.tabId); + if (tabId === null) throw new BrowserRuntimeError("No browser tab is open."); + this.#recording = await BrowserRecording.start({ + page, + tabId: PreviewTabId.make(tabId), + dataDir: this.#dataDir, + ffmpegPath: this.#ffmpegPath, + }); + return this.#recording.status(); + } + case "recordingStop": { + const recording = this.#recording; + if (recording === null || !recording.isForTab(request.tabId)) { + throw new BrowserRuntimeError("No browser recording is active for this tab."); + } + this.#recording = null; + return recording.stop(); + } + case "resize": + throw new BrowserRuntimeError( + `The Discord browser host does not support ${request.operation}.`, + ); + } + } +} diff --git a/apps/discord-bot/src/browser/ProfileStore.test.ts b/apps/discord-bot/src/browser/ProfileStore.test.ts new file mode 100644 index 00000000000..a2bed91d035 --- /dev/null +++ b/apps/discord-bot/src/browser/ProfileStore.test.ts @@ -0,0 +1,65 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + acquireProfileLock, + listProfiles, + profilePaths, + validateProfileName, + writeProfileMetadata, +} from "./ProfileStore.ts"; + +const temporaryDirectories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-browser-profile-test-"), + ); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })), + ); +}); + +describe("browser profile store", () => { + it("rejects path-like profile names", () => { + expect(() => validateProfileName("../credentials")).toThrow(/Profile names/); + expect(() => validateProfileName("Work Account")).toThrow(/Profile names/); + expect(validateProfileName("github-work")).toBe("github-work"); + }); + + it("prevents concurrent profile use", async () => { + const paths = profilePaths(await temporaryDirectory(), "github-work"); + const release = await acquireProfileLock(paths); + await expect(acquireProfileLock(paths)).rejects.toThrow(/is in use/); + await release(); + const releaseAgain = await acquireProfileLock(paths); + await releaseAgain(); + }); + + it("stores only profile metadata in the index", async () => { + const dataDir = await temporaryDirectory(); + const paths = profilePaths(dataDir, "github-work"); + await writeProfileMetadata(paths, { + name: "github-work", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + browserExecutablePath: "/usr/bin/chromium", + }); + await NodeFSP.writeFile(NodePath.join(paths.userDataDir, "Cookies"), "secret"); + + await expect(listProfiles(dataDir)).resolves.toEqual([ + expect.objectContaining({ name: "github-work", browserExecutablePath: "/usr/bin/chromium" }), + ]); + }); +}); diff --git a/apps/discord-bot/src/browser/ProfileStore.ts b/apps/discord-bot/src/browser/ProfileStore.ts new file mode 100644 index 00000000000..5c5ef485913 --- /dev/null +++ b/apps/discord-bot/src/browser/ProfileStore.ts @@ -0,0 +1,198 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +const PROFILE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; + +export interface BrowserProfileMetadata { + readonly name: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly setupUrl?: string; + readonly verifyUrl?: string; + readonly expectUrl?: string; + readonly verifiedAt?: string; + readonly browserExecutablePath: string; +} + +interface LockOwner { + readonly pid: number; + readonly startedAt: string; +} + +export interface BrowserProfilePaths { + readonly root: string; + readonly userDataDir: string; + readonly metadataPath: string; + readonly lockDir: string; +} + +export class BrowserProfileError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "BrowserProfileError"; + } +} + +export function expandHome(input: string): string { + if (input === "~") return NodeOS.homedir(); + if (input.startsWith("~/")) return NodePath.join(NodeOS.homedir(), input.slice(2)); + return NodePath.resolve(input); +} + +export function validateProfileName(name: string): string { + if (!PROFILE_NAME.test(name)) { + throw new BrowserProfileError( + "Profile names must be 1-64 lowercase letters, numbers, or hyphens.", + ); + } + return name; +} + +export function profilePaths(dataDir: string, name: string): BrowserProfilePaths { + const safeName = validateProfileName(name); + const root = NodePath.join(expandHome(dataDir), "browser", "profiles", safeName); + return { + root, + userDataDir: NodePath.join(root, "user-data"), + metadataPath: NodePath.join(root, "profile.json"), + lockDir: NodePath.join(expandHome(dataDir), "browser", "locks", `${safeName}.lock`), + }; +} + +async function pathExists(target: string): Promise { + try { + await NodeFSP.access(target, NodeFS.constants.F_OK); + return true; + } catch { + return false; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function readLockOwner(lockDir: string): Promise { + try { + const value = JSON.parse( + await NodeFSP.readFile(NodePath.join(lockDir, "owner.json"), "utf8"), + ) as { + pid?: unknown; + startedAt?: unknown; + }; + return typeof value.pid === "number" && typeof value.startedAt === "string" + ? { pid: value.pid, startedAt: value.startedAt } + : null; + } catch { + return null; + } +} + +export async function acquireProfileLock(paths: BrowserProfilePaths): Promise<() => Promise> { + await NodeFSP.mkdir(NodePath.dirname(paths.lockDir), { recursive: true, mode: 0o700 }); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await NodeFSP.mkdir(paths.lockDir, { mode: 0o700 }); + await NodeFSP.writeFile( + NodePath.join(paths.lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`, + { mode: 0o600 }, + ); + let released = false; + return async () => { + if (released) return; + released = true; + await NodeFSP.rm(paths.lockDir, { recursive: true, force: true }); + }; + } catch (cause) { + const error = cause as NodeJS.ErrnoException; + if (error.code !== "EEXIST") { + throw new BrowserProfileError( + `Could not lock browser profile ${NodePath.basename(paths.root)}.`, + { + cause, + }, + ); + } + const owner = await readLockOwner(paths.lockDir); + if (attempt === 0 && owner !== null && !isProcessAlive(owner.pid)) { + await NodeFSP.rm(paths.lockDir, { recursive: true, force: true }); + continue; + } + const ownerDescription = owner + ? `PID ${owner.pid} since ${owner.startedAt}` + : "an unknown process"; + throw new BrowserProfileError( + `Browser profile ${NodePath.basename(paths.root)} is in use by ${ownerDescription}.`, + ); + } + } + throw new BrowserProfileError(`Could not lock browser profile ${NodePath.basename(paths.root)}.`); +} + +export async function ensureProfileDirectories(paths: BrowserProfilePaths): Promise { + await NodeFSP.mkdir(paths.userDataDir, { recursive: true, mode: 0o700 }); + await NodeFSP.chmod(paths.root, 0o700); + await NodeFSP.chmod(paths.userDataDir, 0o700); +} + +export async function readProfileMetadata( + paths: BrowserProfilePaths, +): Promise { + try { + return JSON.parse(await NodeFSP.readFile(paths.metadataPath, "utf8")) as BrowserProfileMetadata; + } catch (cause) { + throw new BrowserProfileError( + `Browser profile ${NodePath.basename(paths.root)} is not set up. Run browser-profile setup first.`, + { cause }, + ); + } +} + +export async function writeProfileMetadata( + paths: BrowserProfilePaths, + metadata: BrowserProfileMetadata, +): Promise { + await ensureProfileDirectories(paths); + const temporary = `${paths.metadataPath}.${process.pid}.tmp`; + await NodeFSP.writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 }); + await NodeFSP.rename(temporary, paths.metadataPath); +} + +export async function listProfiles( + dataDir: string, +): Promise> { + const profilesDir = NodePath.join(expandHome(dataDir), "browser", "profiles"); + if (!(await pathExists(profilesDir))) return []; + const entries = await NodeFSP.readdir(profilesDir, { withFileTypes: true }); + const profiles = await Promise.all( + entries + .filter((entry) => entry.isDirectory() && PROFILE_NAME.test(entry.name)) + .map(async (entry) => { + try { + return await readProfileMetadata(profilePaths(dataDir, entry.name)); + } catch { + return null; + } + }), + ); + return profiles.filter((profile): profile is BrowserProfileMetadata => profile !== null); +} + +export async function clearProfile(dataDir: string, name: string): Promise { + const paths = profilePaths(dataDir, name); + const release = await acquireProfileLock(paths); + try { + await NodeFSP.rm(paths.root, { recursive: true, force: true }); + } finally { + await release(); + } +} diff --git a/apps/discord-bot/src/browserProfileCli.test.ts b/apps/discord-bot/src/browserProfileCli.test.ts new file mode 100644 index 00000000000..51cbb204b5a --- /dev/null +++ b/apps/discord-bot/src/browserProfileCli.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseArguments } from "./browserProfileCli.ts"; + +describe("browser profile CLI arguments", () => { + it("parses flags for profile-less commands", () => { + const parsed = parseArguments(["list", "--data-dir", "/tmp/profiles"]); + expect(parsed.command).toBe("list"); + expect(parsed.profile).toBeUndefined(); + expect(parsed.options.get("data-dir")).toBe("/tmp/profiles"); + }); + + it("parses a profile followed by flags", () => { + const parsed = parseArguments(["verify", "github-work", "--executable-path", "/bin/chrome"]); + expect(parsed.profile).toBe("github-work"); + expect(parsed.options.get("executable-path")).toBe("/bin/chrome"); + }); +}); diff --git a/apps/discord-bot/src/browserProfileCli.ts b/apps/discord-bot/src/browserProfileCli.ts new file mode 100644 index 00000000000..e94688fc180 --- /dev/null +++ b/apps/discord-bot/src/browserProfileCli.ts @@ -0,0 +1,210 @@ +// @effect-diagnostics globalDate:off nodeBuiltinImport:off +import * as NodeProcess from "node:process"; +import * as NodeReadlinePromises from "node:readline/promises"; +import * as NodeURL from "node:url"; + +import { chromium } from "playwright-core"; + +import { + acquireProfileLock, + clearProfile, + ensureProfileDirectories, + listProfiles, + profilePaths, + readProfileMetadata, + writeProfileMetadata, + type BrowserProfileMetadata, +} from "./browser/ProfileStore.ts"; + +interface ParsedArguments { + readonly command: string | undefined; + readonly profile: string | undefined; + readonly options: ReadonlyMap; +} + +export function parseArguments(argv: ReadonlyArray): ParsedArguments { + const [command, ...tail] = argv; + const profile = tail[0]?.startsWith("--") === false ? tail.shift() : undefined; + const rest = tail; + const options = new Map(); + for (let index = 0; index < rest.length; index += 1) { + const item = rest[index]!; + if (!item.startsWith("--")) throw new Error(`Unexpected argument: ${item}`); + const next = rest[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + options.set(item.slice(2), next); + index += 1; + } else { + options.set(item.slice(2), true); + } + } + return { command, profile, options }; +} + +function option(input: ParsedArguments, name: string): string | undefined { + const value = input.options.get(name); + return typeof value === "string" ? value : undefined; +} + +function required(value: string | undefined, message: string): string { + if (value === undefined || value.trim() === "") throw new Error(message); + return value; +} + +function matchesExpectedUrl(actual: string, expected: string): boolean { + const expression = new RegExp( + `^${expected.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replaceAll("*", ".*")}$`, + ); + return expression.test(actual); +} + +async function setup(input: ParsedArguments, dataDir: string, executablePath: string) { + const name = required(input.profile, "setup requires a profile name"); + const setupUrl = option(input, "url") ?? "about:blank"; + const verifyUrl = option(input, "verify-url"); + const expectUrl = option(input, "expect-url"); + const paths = profilePaths(dataDir, name); + await ensureProfileDirectories(paths); + const release = await acquireProfileLock(paths); + try { + const context = await chromium.launchPersistentContext(paths.userDataDir, { + executablePath, + headless: false, + viewport: { width: 1_440, height: 900 }, + }); + try { + const page = context.pages()[0] ?? (await context.newPage()); + if (setupUrl !== "about:blank") await page.goto(setupUrl, { waitUntil: "domcontentloaded" }); + const prompt = NodeReadlinePromises.createInterface({ + input: NodeProcess.stdin, + output: NodeProcess.stdout, + }); + try { + await prompt.question( + "Complete login in the browser, then press Enter to verify and save. ", + ); + } finally { + prompt.close(); + } + + let verifiedAt: string | undefined; + if (verifyUrl !== undefined && expectUrl !== undefined) { + await page.goto(verifyUrl, { waitUntil: "domcontentloaded" }); + if (!matchesExpectedUrl(page.url(), expectUrl)) { + throw new Error(`Verification failed: ${page.url()} does not match ${expectUrl}`); + } + verifiedAt = new Date().toISOString(); + } + const now = new Date().toISOString(); + let previous: BrowserProfileMetadata | undefined; + try { + previous = await readProfileMetadata(paths); + } catch { + previous = undefined; + } + await writeProfileMetadata(paths, { + name, + createdAt: previous?.createdAt ?? now, + updatedAt: now, + setupUrl, + ...(verifyUrl === undefined ? {} : { verifyUrl }), + ...(expectUrl === undefined ? {} : { expectUrl }), + ...(verifiedAt === undefined ? {} : { verifiedAt }), + browserExecutablePath: executablePath, + }); + NodeProcess.stdout.write( + verifiedAt === undefined + ? `Saved unverified profile ${name}. Add --verify-url and --expect-url for login checks.\n` + : `Saved and verified profile ${name}.\n`, + ); + } finally { + await context.close(); + } + } finally { + await release(); + } +} + +async function verify(input: ParsedArguments, dataDir: string, executablePath: string) { + const name = required(input.profile, "verify requires a profile name"); + const paths = profilePaths(dataDir, name); + const metadata = await readProfileMetadata(paths); + if (metadata.browserExecutablePath !== executablePath) { + throw new Error( + `Profile was created with ${metadata.browserExecutablePath}; use the same executable.`, + ); + } + const verifyUrl = required(metadata.verifyUrl, "Profile has no verification URL; rerun setup."); + const expectUrl = required(metadata.expectUrl, "Profile has no expected URL; rerun setup."); + const release = await acquireProfileLock(paths); + try { + const context = await chromium.launchPersistentContext(paths.userDataDir, { + executablePath, + headless: true, + viewport: { width: 1_440, height: 900 }, + }); + try { + const page = context.pages()[0] ?? (await context.newPage()); + await page.goto(verifyUrl, { waitUntil: "domcontentloaded" }); + if (!matchesExpectedUrl(page.url(), expectUrl)) { + throw new Error(`Profile ${name} is no longer authenticated; rerun headed setup.`); + } + await writeProfileMetadata(paths, { + ...metadata, + updatedAt: new Date().toISOString(), + verifiedAt: new Date().toISOString(), + }); + NodeProcess.stdout.write(`Profile ${name} is authenticated.\n`); + } finally { + await context.close(); + } + } finally { + await release(); + } +} + +function usage(): never { + throw new Error( + "Usage: browser-profile [name] [--data-dir PATH] [--executable-path PATH]", + ); +} + +async function main() { + const input = parseArguments(NodeProcess.argv.slice(2)); + const dataDir = + option(input, "data-dir") ?? NodeProcess.env["T3_DISCORD_BOT_DATA_DIR"] ?? "~/.t3/discord-bot"; + if (input.command === "list") { + const profiles = await listProfiles(dataDir); + if (profiles.length === 0) NodeProcess.stdout.write("No browser profiles configured.\n"); + for (const profile of profiles) { + NodeProcess.stdout.write( + `${profile.name}\t${profile.verifiedAt ? `verified ${profile.verifiedAt}` : "unverified"}\n`, + ); + } + return; + } + if (input.command === "clear") { + if (input.options.get("yes") !== true) throw new Error("clear requires --yes"); + await clearProfile(dataDir, required(input.profile, "clear requires a profile name")); + NodeProcess.stdout.write(`Cleared profile ${input.profile}.\n`); + return; + } + const executablePath = required( + option(input, "executable-path") ?? NodeProcess.env["T3_DISCORD_BROWSER_EXECUTABLE_PATH"], + "Set --executable-path or T3_DISCORD_BROWSER_EXECUTABLE_PATH.", + ); + if (input.command === "setup") return setup(input, dataDir, executablePath); + if (input.command === "verify") return verify(input, dataDir, executablePath); + return usage(); +} + +if ( + NodeProcess.argv[1] !== undefined && + import.meta.url === NodeURL.pathToFileURL(NodeProcess.argv[1]).href +) { + main().catch((cause: unknown) => { + const message = cause instanceof Error ? cause.message : String(cause); + NodeProcess.stderr.write(`browser-profile: ${message}\n`); + NodeProcess.exit(1); + }); +} diff --git a/apps/discord-bot/src/config.test.ts b/apps/discord-bot/src/config.test.ts new file mode 100644 index 00000000000..bbe7d0fb785 --- /dev/null +++ b/apps/discord-bot/src/config.test.ts @@ -0,0 +1,199 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { preferredModelSelection, type DiscordBotConfig } from "./config.ts"; + +const baseConfig = { + discordToken: "x", + t3HttpBaseUrl: "http://127.0.0.1:8080", + t3BootstrapCredential: undefined, + t3BearerToken: undefined, + t3DefaultInstanceId: "codex", + t3DefaultModel: "gpt-5.4", + t3DefaultBaseBranch: "main", + t3DefaultRuntimeMode: "full-access" as const, + dataDir: "~/.t3/discord-bot", + webUiBaseUrl: undefined, + projectAliasesPath: undefined, + identityMapPath: undefined, + honeycombTraceUrlTemplate: undefined, + alertsChannelId: undefined, + alertProcessRulesPath: undefined, + stateSqlitePath: "/var/lib/t3/userdata/state.sqlite", + browserEnabled: false, + browserProfile: "default", + browserExecutablePath: undefined, + browserFfmpegPath: "ffmpeg", + browserAllowedOrigins: [], + jiraBrowseBaseUrl: "https://example.atlassian.net", + teamsEnabled: false, + teamsTenantId: undefined, + teamsClientId: undefined, + teamsClientSecret: undefined, + teamsChannelsPath: undefined, + teamsPollIntervalSeconds: 60, + teamsBotDisplayName: undefined, + teamsNativeEnabled: false, + teamsPort: 3978, + teamsMessagingEndpoint: "/api/messages" as const, + teamsDefaultProjectShortName: undefined, +} satisfies DiscordBotConfig; + +describe("preferredModelSelection", () => { + it("defaults to codex gpt-5.4 over project grok", () => { + const selection = preferredModelSelection({ + config: baseConfig, + projectDefault: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + models: [{ slug: "gpt-5.4", name: "GPT-5.4" }], + }, + { + instanceId: ProviderInstanceId.make("grok"), + driver: "grok", + enabled: true, + installed: true, + models: [{ slug: "grok-build", name: "Grok Build" }], + }, + ], + }); + + expect(selection.instanceId).toBe("codex"); + expect(selection.model).toBe("gpt-5.4"); + }); + + it("honors explicit mention overrides", () => { + const selection = preferredModelSelection({ + config: baseConfig, + overrideInstanceId: "cursor", + overrideModel: "composer-2", + providers: [ + { + instanceId: ProviderInstanceId.make("cursor"), + driver: "cursor", + enabled: true, + installed: true, + models: [{ slug: "composer-2", name: "Composer 2" }], + }, + ], + }); + + expect(selection).toEqual({ + instanceId: "cursor", + model: "composer-2", + }); + }); + + it("uses the current thread provider as the sticky base for model-only overrides", () => { + const selection = preferredModelSelection({ + config: baseConfig, + stickyModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + overrideModel: "claude-opus-4-6", + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + models: [{ slug: "gpt-5.4", name: "GPT-5.4" }], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: "claudeAgent", + enabled: true, + installed: true, + models: [ + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + ], + }, + ], + }); + + expect(selection).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-6", + }); + }); + + it("matches model-only overrides to the native provider instead of sticky default", () => { + const selection = preferredModelSelection({ + config: { + ...baseConfig, + t3DefaultInstanceId: "grok", + t3DefaultModel: "grok-build", + }, + stickyModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + overrideModel: "gpt-5.6", + providers: [ + { + instanceId: ProviderInstanceId.make("grok"), + driver: "grok", + enabled: true, + installed: true, + models: [{ slug: "grok-build", name: "Grok Build" }], + }, + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + models: [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.6", name: "GPT-5.6" }, + ], + }, + ], + }); + + expect(selection).toEqual({ + instanceId: "codex", + model: "gpt-5.6", + }); + }); + + it("keeps the current thread model as the sticky default when only the provider override changes", () => { + const selection = preferredModelSelection({ + config: baseConfig, + stickyModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + overrideInstanceId: "codex_work", + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + models: [{ slug: "gpt-5.4", name: "GPT-5.4" }], + }, + { + instanceId: ProviderInstanceId.make("codex_work"), + driver: "codex", + enabled: true, + installed: true, + models: [{ slug: "gpt-5.4", name: "GPT-5.4" }], + }, + ], + }); + + expect(selection).toEqual({ + instanceId: "codex_work", + model: "gpt-5.4", + }); + }); +}); diff --git a/apps/discord-bot/src/config.ts b/apps/discord-bot/src/config.ts new file mode 100644 index 00000000000..198a6480681 --- /dev/null +++ b/apps/discord-bot/src/config.ts @@ -0,0 +1,301 @@ +import { + DEFAULT_MODEL, + type ModelSelection, + type ProviderInstanceId, + type RuntimeMode, +} from "@t3tools/contracts"; +import { resolveProviderModelSelection } from "@t3tools/shared/providerModelSelection"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +export interface DiscordBotConfig { + readonly discordToken: string | undefined; + readonly t3HttpBaseUrl: string; + readonly t3BootstrapCredential: string | undefined; + readonly t3BearerToken: string | undefined; + readonly t3DefaultInstanceId: string | undefined; + readonly t3DefaultModel: string | undefined; + readonly t3DefaultBaseBranch: string; + readonly t3DefaultRuntimeMode: RuntimeMode; + readonly dataDir: string; + readonly webUiBaseUrl: string | undefined; + /** Bot-local shortName → workspaceRoot map (not on the T3 server). */ + readonly projectAliasesPath: string | undefined; + /** + * Optional Discord → GitHub/Jira identity map path for commit/PR co-authorship. + * Staged like project aliases (e.g. /run/secrets/identity-map.yaml). + */ + readonly identityMapPath: string | undefined; + /** + * Optional Honeycomb trace URL template for bootstrap prompts. + * Placeholders: {traceId}, {environment}, {dataset}, {team} + */ + readonly honeycombTraceUrlTemplate: string | undefined; + /** + * Discord channel id for guest ops alerts (load, mem, runaway MCP, long turns). + * Unset → watchdog does not post. + */ + readonly alertsChannelId: string | undefined; + /** + * Optional YAML/JSON rules file for per-process Discord alert ceilings. + * Lets operators raise/lower RSS, CPU, and sustained-duration thresholds + * for named guest processes without changing code. + */ + readonly alertProcessRulesPath: string | undefined; + /** + * Path to t3code `state.sqlite` for long-turn detection (guest: /var/lib/t3/userdata/state.sqlite). + */ + readonly stateSqlitePath: string; + readonly browserEnabled: boolean; + readonly browserProfile: string; + readonly browserExecutablePath: string | undefined; + readonly browserFfmpegPath: string; + readonly browserAllowedOrigins: ReadonlyArray; + /** + * Optional Jira site base for browse links on pinned thread-info messages + * (e.g. `https://example.atlassian.net`). + */ + readonly jiraBrowseBaseUrl: string | undefined; + /** Optional Microsoft Teams intake module (Graph channel polling → Discord/T3). */ + readonly teamsEnabled: boolean; + readonly teamsTenantId: string | undefined; + readonly teamsClientId: string | undefined; + readonly teamsClientSecret: string | undefined; + readonly teamsChannelsPath: string | undefined; + readonly teamsPollIntervalSeconds: number; + readonly teamsBotDisplayName: string | undefined; + /** Run the native Teams SDK activity/message-extension endpoint. */ + readonly teamsNativeEnabled: boolean; + readonly teamsPort: number; + readonly teamsMessagingEndpoint: `/${string}`; + /** Fallback alias for personal/group chats that do not map to a configured channel. */ + readonly teamsDefaultProjectShortName: string | undefined; +} + +const RuntimeModeConfig = Schema.Literals([ + "full-access", + "auto-accept-edits", + "approval-required", +]); + +/** Match T3 Code web defaults (Codex / GPT-5.4). */ +const DEFAULT_BOT_INSTANCE_ID = "codex"; +const DEFAULT_BOT_MODEL = DEFAULT_MODEL; + +export const DiscordBotConfig: Effect.Effect = Effect.gen( + function* () { + const discordToken = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe( + Config.option, + Config.map((value) => (Option.isSome(value) ? Redacted.value(value.value) : undefined)), + ); + const t3HttpBaseUrl = yield* Config.string("T3_HTTP_BASE_URL").pipe( + Config.withDefault("http://127.0.0.1:3773"), + ); + const t3BootstrapCredential = yield* Config.string("T3_BOOTSTRAP_CREDENTIAL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const t3BearerToken = yield* Config.string("T3_BEARER_TOKEN").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + // Align with T3 web default unless operator pins another instance/model. + const t3DefaultInstanceId = yield* Config.string("T3_DEFAULT_INSTANCE_ID").pipe( + Config.withDefault(DEFAULT_BOT_INSTANCE_ID), + ); + const t3DefaultModel = yield* Config.string("T3_DEFAULT_MODEL").pipe( + Config.withDefault(DEFAULT_BOT_MODEL), + ); + const t3DefaultBaseBranch = yield* Config.string("T3_DEFAULT_BASE_BRANCH").pipe( + Config.withDefault("main"), + ); + const t3DefaultRuntimeMode = yield* Config.schema( + RuntimeModeConfig, + "T3_DEFAULT_RUNTIME_MODE", + ).pipe(Config.withDefault("full-access" as const)); + const dataDir = yield* Config.string("T3_DISCORD_BOT_DATA_DIR").pipe( + Config.withDefault("~/.t3/discord-bot"), + ); + const webUiBaseUrl = yield* Config.string("T3_WEB_UI_BASE_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const projectAliasesPath = yield* Config.string("T3_PROJECT_ALIASES_PATH").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const identityMapPath = yield* Config.string("T3_IDENTITY_MAP_PATH").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const honeycombTraceUrlTemplate = yield* Config.string("T3_HONEYCOMB_TRACE_URL_TEMPLATE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const alertsChannelId = yield* Config.string("DISCORD_ALERTS_CHANNEL_ID").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const alertProcessRulesPath = yield* Config.string("T3_DISCORD_ALERT_PROCESS_RULES_PATH").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const stateSqlitePath = yield* Config.string("T3_STATE_SQLITE_PATH").pipe( + Config.withDefault("/var/lib/t3/userdata/state.sqlite"), + ); + const browserEnabled = yield* Config.boolean("T3_DISCORD_BROWSER_ENABLED").pipe( + Config.withDefault(false), + ); + const browserProfile = yield* Config.string("T3_DISCORD_BROWSER_PROFILE").pipe( + Config.withDefault("default"), + ); + const browserExecutablePath = yield* Config.string("T3_DISCORD_BROWSER_EXECUTABLE_PATH").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const browserFfmpegPath = yield* Config.string("T3_DISCORD_BROWSER_FFMPEG_PATH").pipe( + Config.withDefault("ffmpeg"), + ); + const browserAllowedOrigins = yield* Config.string("T3_DISCORD_BROWSER_ALLOWED_ORIGINS").pipe( + Config.withDefault(""), + Config.map((value) => + value + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean), + ), + ); + const jiraBrowseBaseUrl = yield* Config.string("T3_JIRA_BROWSE_BASE_URL").pipe( + Config.orElse(() => Config.string("JIRA_BROWSE_BASE_URL")), + Config.option, + Config.map((value) => { + const raw = Option.getOrUndefined(value); + if (raw === undefined) return undefined; + const trimmed = raw.trim().replace(/\/+$/u, ""); + if (trimmed.length === 0) return undefined; + const site = trimmed.match(/^(https?:\/\/[a-z0-9.-]+\.atlassian\.net)(?:\/.*)?$/iu); + if (site?.[1] !== undefined) return site[1]; + if (/^https?:\/\//iu.test(trimmed) && !trimmed.includes("/ex/jira/")) return trimmed; + return undefined; + }), + ); + const teamsEnabled = yield* Config.boolean("TEAMS_ENABLED").pipe(Config.withDefault(false)); + const teamsTenantId = yield* Config.string("TEAMS_TENANT_ID").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const teamsClientId = yield* Config.string("TEAMS_CLIENT_ID").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const teamsClientSecret = yield* Config.redacted("TEAMS_CLIENT_SECRET").pipe( + Config.option, + Config.map((value) => (Option.isSome(value) ? Redacted.value(value.value) : undefined)), + ); + const teamsChannelsPath = yield* Config.string("TEAMS_CHANNELS_PATH").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const teamsPollIntervalSeconds = yield* Config.int("TEAMS_POLL_INTERVAL_SECONDS").pipe( + Config.withDefault(60), + ); + const teamsBotDisplayName = yield* Config.string("TEAMS_BOT_DISPLAY_NAME").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const teamsNativeEnabled = yield* Config.boolean("TEAMS_NATIVE_ENABLED").pipe( + Config.withDefault(false), + ); + const teamsPort = yield* Config.int("TEAMS_PORT").pipe(Config.withDefault(3978)); + const teamsMessagingEndpoint = yield* Config.string("TEAMS_MESSAGING_ENDPOINT").pipe( + Config.withDefault("/api/messages"), + Config.map((value) => { + const normalized = value.trim(); + return (normalized.startsWith("/") ? normalized : `/${normalized}`) as `/${string}`; + }), + ); + const teamsDefaultProjectShortName = yield* Config.string( + "TEAMS_DEFAULT_PROJECT_SHORT_NAME", + ).pipe(Config.option, Config.map(Option.getOrUndefined)); + + return { + discordToken, + t3HttpBaseUrl, + t3BootstrapCredential, + t3BearerToken, + t3DefaultInstanceId, + t3DefaultModel, + t3DefaultBaseBranch, + t3DefaultRuntimeMode, + dataDir, + webUiBaseUrl, + projectAliasesPath, + identityMapPath, + honeycombTraceUrlTemplate, + alertsChannelId, + alertProcessRulesPath, + stateSqlitePath, + browserEnabled, + browserProfile, + browserExecutablePath, + browserFfmpegPath, + browserAllowedOrigins, + jiraBrowseBaseUrl, + teamsEnabled, + teamsTenantId, + teamsClientId, + teamsClientSecret, + teamsChannelsPath, + teamsPollIntervalSeconds, + teamsBotDisplayName, + teamsNativeEnabled, + teamsPort, + teamsMessagingEndpoint, + teamsDefaultProjectShortName, + } satisfies DiscordBotConfig; + }, +); + +/** + * Discord bot model selection (conservative defaults): + * 1. Explicit mention overrides (`--provider` / `--model`) + * - Model-only (`--model` without `--provider`) picks the best cataloged + * native provider for that model (e.g. gpt-5.6 → codex, not sticky Grok) + * - Sticky/current provider still wins when it catalogs the requested model + * 2. Bot env defaults (default: codex + gpt-5.4, same as T3 web) + * 3. Project defaultModelSelection if that provider is available + * 4. First enabled installed provider + * + * Note: Grok Build previously hit provider turn-start decode failures for large + * Discord bootstrap prompts; prefer Codex unless the operator pins Grok/Composer. + */ +export function preferredModelSelection(input: { + readonly config: DiscordBotConfig; + readonly providers: Parameters[0]["providers"]; + readonly projectDefault?: ModelSelection | null; + readonly stickyModelSelection?: ModelSelection | null; + readonly overrideInstanceId?: string; + readonly overrideModel?: string; +}): ModelSelection { + const fallbackSelection = { + instanceId: DEFAULT_BOT_INSTANCE_ID as ProviderInstanceId, + model: DEFAULT_BOT_MODEL, + }; + const preferredSelection = input.stickyModelSelection ?? { + instanceId: (input.config.t3DefaultInstanceId ?? DEFAULT_BOT_INSTANCE_ID) as ProviderInstanceId, + model: input.config.t3DefaultModel ?? DEFAULT_BOT_MODEL, + }; + return resolveProviderModelSelection({ + providers: input.providers, + ...(input.projectDefault === undefined ? {} : { projectDefault: input.projectDefault }), + preferredSelection, + fallbackSelection, + ...(input.overrideInstanceId === undefined + ? {} + : { overrideInstanceId: input.overrideInstanceId }), + ...(input.overrideModel === undefined ? {} : { overrideModel: input.overrideModel }), + }); +} diff --git a/apps/discord-bot/src/discord/DiscordLive.ts b/apps/discord-bot/src/discord/DiscordLive.ts new file mode 100644 index 00000000000..68060862474 --- /dev/null +++ b/apps/discord-bot/src/discord/DiscordLive.ts @@ -0,0 +1,52 @@ +import { NodeHttpClient, NodeSocket } from "@effect/platform-node"; +import { Discord, DiscordConfig, Intents } from "dfx"; +import * as Redacted from "effect/Redacted"; +import { DiscordIxLive } from "dfx/gateway"; +import * as Config from "effect/Config"; +import * as Layer from "effect/Layer"; + +/** Intents required for channel mentions → agent turns. */ +export const BOT_GATEWAY_INTENTS = Intents.fromList([ + "Guilds", + "GuildMessages", + "MessageContent", + "GuildMessageReactions", +]); + +export const makeDiscordLayer = (token: string) => + DiscordIxLive.pipe( + // provideMerge keeps DiscordConfig in the runtime (not only as a hidden dep). + // Bridge multipart uploads yield DiscordConfig for token + REST baseUrl. + Layer.provideMerge( + DiscordConfig.layer({ + token: Redacted.make(token), + gateway: { + // Explicit bitfield (also loggable at boot). + intents: BOT_GATEWAY_INTENTS, + }, + }), + ), + Layer.provide([NodeHttpClient.layerUndici, NodeSocket.layerWebSocketConstructor]), + ); + +export function describeIntents(bitfield: number): string { + const names = Object.entries(Discord.GatewayIntentBits) + .filter(([, bit]) => typeof bit === "number" && (bitfield & bit) === bit) + .map(([name]) => name); + return `${bitfield} [${names.join(", ")}]`; +} + +/** Config-based variant when token comes from env via Config. */ +export const DiscordLayerFromEnv = DiscordIxLive.pipe( + Layer.provideMerge( + DiscordConfig.layerConfig({ + token: Config.redacted("DISCORD_BOT_TOKEN"), + gateway: { + intents: Config.succeed( + Intents.fromList(["Guilds", "GuildMessages", "MessageContent", "GuildMessageReactions"]), + ), + }, + }), + ), + Layer.provide([NodeHttpClient.layerUndici, NodeSocket.layerWebSocketConstructor]), +); diff --git a/apps/discord-bot/src/features/Alerts.test.ts b/apps/discord-bot/src/features/Alerts.test.ts new file mode 100644 index 00000000000..363a720fd0a --- /dev/null +++ b/apps/discord-bot/src/features/Alerts.test.ts @@ -0,0 +1,280 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as Cause from "effect/Cause"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("dfx", () => ({ + DiscordConfig: { DiscordConfig: {} }, + DiscordREST: {}, +})); + +import { + bridgeAlertDelivery, + classifySessionLastError, + fatalAlertDelivery, + formatAlertCause, + isExpectedSessionLastError, + listSessionErrors, + selectSessionErrorsForAlert, + sessionErrorAlertDelivery, + sessionErrorAlertKey, + trackSustainedHotProcesses, + type ProcInfo, + type ProcSustainState, +} from "./Alerts.ts"; + +const TICK_MS = 60_000; +const CPU_THRESHOLD = 50; +const RSS_THRESHOLD = 768; +const SUSTAINED_FOR_MS = 4 * TICK_MS; + +const proc = (over: Partial & { pid: number }): ProcInfo => ({ + pid: over.pid, + rssMb: over.rssMb ?? 100, + cpuSeconds: over.cpuSeconds ?? 0, + cmd: over.cmd ?? `/bin/proc-${over.pid}`, + label: over.label ?? `proc-${over.pid}`, +}); + +/** Replay a fixed per-tick behaviour and return the final tick's hot list. */ +function run( + ticks: ReadonlyArray>, + startMs = 1_000_000, +): { + hot: ReturnType["hot"]; + state: ReadonlyMap; +} { + let state: ReadonlyMap = new Map(); + let hot: ReturnType["hot"] = []; + ticks.forEach((procs, index) => { + const result = trackSustainedHotProcesses({ + prev: state, + procs, + nowMs: startMs + index * TICK_MS, + resolveRule: () => ({ + id: "default", + cpuPercentThreshold: CPU_THRESHOLD, + rssMbThreshold: RSS_THRESHOLD, + sustainedForMs: SUSTAINED_FOR_MS, + }), + }); + state = result.next; + hot = result.hot; + }); + return { hot, state }; +} + +describe("formatAlertCause", () => { + it("pretty-prints Effect Cause failures instead of [Object]", () => { + const cause = Cause.fail(new Error("stream tip update failed")); + const rendered = formatAlertCause(cause); + expect(rendered).toContain("stream tip update failed"); + expect(rendered).not.toContain("[Object]"); + }); + + it("falls back for plain Errors and strings", () => { + expect(formatAlertCause(new Error("boom"))).toContain("boom"); + expect(formatAlertCause("plain")).toBe("plain"); + }); + + it("keeps complete causes by default while honoring explicit preview limits", () => { + const cause = `start\n${"x".repeat(4_000)}\nend`; + expect(formatAlertCause(cause)).toBe(cause); + expect(formatAlertCause(cause, 20)).toBe(`${cause.slice(0, 20)}…`); + }); +}); + +describe("Discord alert content", () => { + it("reads the complete persisted session error before attaching it", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-alert-trace-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + const trace = `Error: Invalid params\n${" at decodeFrame (file:///long/path.js:1:1)\n".repeat(80)}`; + + try { + NodeChildProcess.execFileSync( + "python3", + [ + "-c", + [ + "import sqlite3, sys", + "db = sqlite3.connect(sys.argv[1])", + "db.execute('CREATE TABLE projection_thread_sessions (thread_id TEXT, last_error TEXT, status TEXT, updated_at TEXT)')", + "db.execute('INSERT INTO projection_thread_sessions VALUES (?, ?, ?, ?)', ('thread-1', sys.argv[2], 'error', '2026-07-28T00:00:00Z'))", + "db.commit()", + ].join("\n"), + dbPath, + trace, + ], + { encoding: "utf8" }, + ); + + expect(trace.length).toBeGreaterThan(300); + expect(listSessionErrors(dbPath)).toEqual([ + { threadId: "thread-1", lastError: trace, status: "error" }, + ]); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("attaches the complete T3 session stack as one text file", () => { + const trace = [ + "Error: Invalid params", + ...Array.from( + { length: 80 }, + (_, index) => + ` at decodeFrame${index} (file:///var/lib/t3/src/t3code/node_modules/effect/frame-${index}.js:877:8)`, + ), + ].join("\n"); + const delivery = sessionErrorAlertDelivery("2d9ccf35-a36a-41bc-a762-523f5e423f41", trace); + + expect(delivery.content).toContain("thread=`2d9ccf35-a36a-41bc-a762-523f5e423f41`"); + expect(delivery.content).not.toContain("Invalid params"); + expect(delivery.files).toHaveLength(1); + expect(delivery.files[0]?.name).toBe( + "t3-session-error-2d9ccf35-a36a-41bc-a762-523f5e423f41.txt", + ); + expect(delivery.files[0]?.mimeType).toBe("text/plain;charset=utf-8"); + expect(new TextDecoder().decode(delivery.files[0]?.data)).toBe(trace); + }); + + it("keeps fatal and bridge traces out of message content and intact in attachments", () => { + const trace = `start\n${"x".repeat(4_000)}\nend`; + for (const delivery of [ + fatalAlertDelivery("failure", trace), + bridgeAlertDelivery("failure", trace), + ]) { + expect(delivery.content).not.toContain(trace); + expect(delivery.files).toHaveLength(1); + expect(delivery.files[0]?.name.endsWith(".txt")).toBe(true); + expect(new TextDecoder().decode(delivery.files[0]?.data)).toBe(trace); + } + }); +}); + +describe("session last_error alert classification", () => { + it("treats orphan / server-restart recover text as expected (not fatal)", () => { + expect( + isExpectedSessionLastError( + "Recovered orphan session (server_restart). Send a follow-up to resume.", + ), + ).toBe(true); + expect( + isExpectedSessionLastError( + "Server restarted while the agent was working. Send a follow-up to resume it.", + ), + ).toBe(true); + expect(classifySessionLastError({ lastError: "Recovered orphan session (reaper)." })).toBe( + "ignore", + ); + }); + + it("keeps real provider failures as fatal", () => { + expect( + classifySessionLastError({ + lastError: "ProviderAdapterProcessError: Failed to spawn ACP process for command: grok", + status: "error", + }), + ).toBe("fatal"); + }); + + it("filters recoveries and caps fatals for posting", () => { + const selected = selectSessionErrorsForAlert( + [ + { + threadId: "t1", + lastError: "Recovered orphan session (server_restart). Send a follow-up to resume.", + }, + { + threadId: "t2", + lastError: "Recovered orphan session (server_restart). Send a follow-up to resume.", + }, + { threadId: "t3", lastError: "Provider adapter process error (grok)" }, + { threadId: "t4", lastError: "Provider adapter process error (codex)" }, + ], + 1, + ); + expect(selected.ignoredRecoveryCount).toBe(2); + expect(selected.fatals).toHaveLength(1); + expect(selected.fatals[0]?.threadId).toBe("t3"); + }); + + it("shares cooldown keys across threads with the same failure signature", () => { + const a = sessionErrorAlertKey( + "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb", + "Failed to spawn ACP process for thread aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb", + ); + const b = sessionErrorAlertKey( + "cccccccc-1111-2222-3333-dddddddddddd", + "Failed to spawn ACP process for thread cccccccc-1111-2222-3333-dddddddddddd", + ); + expect(a).toBe(b); + expect(a.startsWith("session-error-sig:")).toBe(true); + }); +}); + +describe("trackSustainedHotProcesses", () => { + it("does not alert on a long-lived but idle process", () => { + // The reported bug: a process with lots of cumulative CPU time that now barely + // moves (a few seconds every tick) must never alert. +2s of CPU per 60s tick + // is ~3% of a core. + const ticks = Array.from({ length: 12 }, (_unused, i) => [ + proc({ pid: 773, rssMb: 141, cpuSeconds: 232 + i * 2 }), + ]); + expect(run(ticks).hot).toEqual([]); + }); + + it("alerts once a process stays CPU-hot for the sustained window", () => { + // A full core busy: +60s of CPU per 60s tick = ~100%. + const ticks = Array.from({ length: 6 }, (_unused, i) => [ + proc({ pid: 900, rssMb: 200, cpuSeconds: i * 60 }), + ]); + const hot = run(ticks).hot; + expect(hot).toHaveLength(1); + expect(hot[0]!.pid).toBe(900); + expect(hot[0]!.cpuPercent).toBeGreaterThanOrEqual(CPU_THRESHOLD); + expect(Math.round(hot[0]!.sustainedMs / TICK_MS)).toBe(4); + }); + + it("does not alert before the sustained window elapses", () => { + const ticks = Array.from({ length: 5 }, (_unused, i) => [ + proc({ pid: 900, cpuSeconds: i * 60 }), + ]); + expect(run(ticks).hot).toEqual([]); + }); + + it("resets the streak when CPU drops back to idle", () => { + const hotTick = (i: number) => [proc({ pid: 900, cpuSeconds: i * 60 })]; + const idleTick = (cpu: number) => [proc({ pid: 900, cpuSeconds: cpu })]; + // Three hot ticks, then idle — must clear, not alert. + const ticks = [hotTick(0), hotTick(1), hotTick(2), hotTick(3), idleTick(181), idleTick(182)]; + expect(run(ticks).hot).toEqual([]); + }); + + it("alerts on sustained high RSS even at zero CPU", () => { + const ticks = Array.from({ length: 5 }, () => [proc({ pid: 950, rssMb: 900, cpuSeconds: 5 })]); + const hot = run(ticks).hot; + expect(hot).toHaveLength(1); + expect(hot[0]!.rssMb).toBe(900); + expect(hot[0]!.cpuPercent).toBe(0); + }); + + it("treats a reused pid as a new process and resets its streak", () => { + const busy = Array.from({ length: 4 }, (_unused, i) => [ + proc({ pid: 900, cpuSeconds: i * 60 }), + ]); + // pid 900 reappears with a lower cumulative counter → different process. + const reused = [proc({ pid: 900, cpuSeconds: 1 })]; + expect(run([...busy, reused]).hot).toEqual([]); + }); + + it("prunes state for processes that have exited", () => { + const { state } = run([[proc({ pid: 900 })], [proc({ pid: 901 })]]); + expect(state.has(900)).toBe(false); + expect(state.has(901)).toBe(true); + }); +}); diff --git a/apps/discord-bot/src/features/Alerts.ts b/apps/discord-bot/src/features/Alerts.ts new file mode 100644 index 00000000000..798d634e0b8 --- /dev/null +++ b/apps/discord-bot/src/features/Alerts.ts @@ -0,0 +1,978 @@ +// @effect-diagnostics nodeBuiltinImport:off missingEffectContext:off anyUnknownInErrorContext:off +/** + * Guest-side ops alerts → a dedicated Discord channel. + * + * - Host: load, CPU%, memory, disk free + * - Runaways: legacy stdio Sentry MCP proliferation / high RSS → alert only (never kill) + * - T3: long-running turns; **real** session errors only (not orphan-restart recover text) + * - App: postFatalAlert() / postBridgeAlert() for hard + bridge failures + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import { DiscordConfig, DiscordREST } from "dfx"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; + +import { loadAlertProcessRulesFromFileSync, type AlertProcessRule } from "../alertProcessRules.ts"; +import type { DiscordBotConfig } from "../config.ts"; +import { + createMessageWithAttachments, + DiscordUploadError, + textFile, + type DiscordUploadFile, +} from "../presentation/discordFiles.ts"; + +const POLL_MS = 60 * 1000; +const POLL = "60 seconds"; +const COOLDOWN_MS = 10 * 60 * 1000; +/** Fatal errors use a shorter cooldown so distinct keys still surface quickly. */ +const FATAL_COOLDOWN_MS = 2 * 60 * 1000; +/** + * Session last_error fatals used to re-post every 2m per thread after restarts. + * Longer window + signature keys keep real provider failures visible without spam. + */ +const SESSION_ERROR_FATAL_COOLDOWN_MS = 30 * 60 * 1000; +/** Cap distinct session-error posts per watchdog tick. */ +const SESSION_ERROR_ALERT_MAX = 5; +/** Leave headroom below Discord's 2,000-character message-content limit. */ +const DISCORD_ALERT_MESSAGE_LIMIT = 1900; + +const LOAD_RATIO = 0.75; +const CPU_PERCENT_ALERT = 85; +const MEM_AVAILABLE_MIN_MB = 1024; +const DISK_FREE_MIN_PERCENT = 10; +const DISK_FREE_MIN_GB = 2; +/** Alert when a legacy stdio Sentry MCP process exceeds this RSS. */ +const SENTRY_RSS_ALERT_MB = 512; +const SENTRY_COUNT_ALERT = 2; +/** + * Default generic process rule for "unexpectedly hot" processes. + * The sustained duration preserves the prior five-sample window semantics: + * the first sample establishes the CPU-rate baseline, then four 60s intervals + * must stay hot before alerting. + * + * This measures a *rate* (Δcpu / Δwall between ticks), not cumulative CPU time: + * a long-lived-but-idle process (e.g. one that gathered 200s of CPU over hours + * yet now moves a few seconds per 10 min) is not a problem and used to re-alert + * forever. What we want to catch is a process actually pegging CPU or memory for + * a sustained stretch. + */ +const DEFAULT_PROCESS_CPU_PERCENT = 50; // percent of a single core, averaged over the tick gap +const DEFAULT_PROCESS_RSS_ALERT_MB = 768; +const DEFAULT_PROCESS_SUSTAINED_FOR_MS = 4 * POLL_MS; +const TURN_RUNNING_MIN_MS = 15 * 60 * 1000; + +/** Paths to check for free space (guest rootfs is tiny; data volume is the real store). */ +const DISK_PATHS = ["/", "/var/lib/t3"] as const; + +/** + * Processes we watch as "runaways" (alert only — never auto-kill). + * Targets legacy local `@sentry/mcp-server` stdio children. Excludes the shared + * `shared-sentry-mcp-proxy` HTTP proxy (contains "sentry-mcp" in the path). + */ +const RUNAWAY_PATTERNS: ReadonlyArray<{ + readonly id: string; + readonly match: (cmd: string) => boolean; +}> = [ + { + id: "sentry-mcp", + match: (cmd) => { + if (cmd.includes("shared-sentry-mcp-proxy") || cmd.includes("t3-watchdog")) return false; + return cmd.includes("@sentry/mcp-server") || /\bsentry-mcp\b/.test(cmd); + }, + }, +]; + +export interface ProcInfo { + readonly pid: number; + readonly rssMb: number; + readonly cpuSeconds: number; + readonly cmd: string; + readonly label: string; +} + +/** Per-process tracker state carried between ticks to derive a CPU rate. */ +export interface ProcSustainState { + readonly cpuSeconds: number; + readonly sampledAtMs: number; + readonly wasHot: boolean; + /** When the current hot streak began, for reporting how long it has lasted. */ + readonly hotSinceMs: number; +} + +/** A process that has been hot (high CPU rate or RSS) for long enough to alert. */ +export interface SustainedHotProcess { + readonly pid: number; + readonly rssMb: number; + /** Average CPU over the last tick gap, as percent of a single core. */ + readonly cpuPercent: number; + readonly ruleId: string; + readonly rssMbThreshold: number | null; + readonly cpuPercentThreshold: number | null; + readonly sustainedForMs: number; + /** How long it has been continuously hot. */ + readonly sustainedMs: number; + readonly label: string; +} + +interface ResolvedProcessAlertRule { + readonly id: string; + readonly rssMbThreshold: number | null; + readonly cpuPercentThreshold: number | null; + readonly sustainedForMs: number; +} + +const DEFAULT_PROCESS_ALERT_RULE: ResolvedProcessAlertRule = { + id: "default", + rssMbThreshold: DEFAULT_PROCESS_RSS_ALERT_MB, + cpuPercentThreshold: DEFAULT_PROCESS_CPU_PERCENT, + sustainedForMs: DEFAULT_PROCESS_SUSTAINED_FOR_MS, +}; + +/** + * Advance the per-process hotness tracker by one tick. + * + * Pure so the streak/rate logic is testable without /proc. Only pids present in + * `procs` survive into the returned state, which prunes exited processes; a pid + * whose CPU counter went backwards is treated as reused and its streak resets. + */ +export function trackSustainedHotProcesses(input: { + readonly prev: ReadonlyMap; + readonly procs: ReadonlyArray; + readonly nowMs: number; + readonly resolveRule: (proc: ProcInfo) => ResolvedProcessAlertRule; +}): { + readonly next: Map; + readonly hot: ReadonlyArray; +} { + const next = new Map(); + const hot: SustainedHotProcess[] = []; + + for (const proc of input.procs) { + const rule = input.resolveRule(proc); + const prior = input.prev.get(proc.pid); + // A counter that went backwards means the pid was reused; ignore the prior. + const reused = prior !== undefined && proc.cpuSeconds < prior.cpuSeconds; + const previous = reused ? undefined : prior; + + const elapsedMs = previous ? input.nowMs - previous.sampledAtMs : 0; + const cpuPercent = + previous && elapsedMs > 0 + ? (Math.max(0, proc.cpuSeconds - previous.cpuSeconds) / (elapsedMs / 1_000)) * 100 + : null; + + const isHot = + (rule.cpuPercentThreshold !== null && + cpuPercent !== null && + cpuPercent >= rule.cpuPercentThreshold) || + (rule.rssMbThreshold !== null && proc.rssMb >= rule.rssMbThreshold); + const hotSinceMs = isHot ? (previous?.wasHot ? previous.hotSinceMs : input.nowMs) : input.nowMs; + + next.set(proc.pid, { + cpuSeconds: proc.cpuSeconds, + sampledAtMs: input.nowMs, + wasHot: isHot, + hotSinceMs, + }); + + if (isHot && input.nowMs - hotSinceMs >= rule.sustainedForMs) { + hot.push({ + pid: proc.pid, + rssMb: proc.rssMb, + cpuPercent: cpuPercent ?? 0, + ruleId: rule.id, + rssMbThreshold: rule.rssMbThreshold, + cpuPercentThreshold: rule.cpuPercentThreshold, + sustainedForMs: rule.sustainedForMs, + sustainedMs: input.nowMs - hotSinceMs, + label: proc.label, + }); + } + } + + hot.sort((a, b) => b.cpuPercent - a.cpuPercent || b.rssMb - a.rssMb); + return { next, hot: hot.slice(0, 8) }; +} + +export interface DiskInfo { + readonly path: string; + readonly totalGb: number; + readonly freeGb: number; + readonly freePercent: number; +} + +export interface HostSnapshot { + readonly load1: number; + readonly load5: number; + readonly nproc: number; + readonly cpuPercent: number | null; + readonly memTotalMb: number; + readonly memAvailableMb: number; + readonly disks: ReadonlyArray; + readonly runaways: ReadonlyArray; + readonly fatProcesses: ReadonlyArray; + readonly longTurns: ReadonlyArray<{ + readonly threadId: string; + readonly turnId: string; + readonly ageMin: number; + }>; + readonly sessionErrors: ReadonlyArray<{ + readonly threadId: string; + readonly lastError: string; + readonly status?: string | null; + }>; + readonly failedUnits: ReadonlyArray; +} + +export type SessionLastErrorKind = "ignore" | "fatal"; + +/** + * Operational recover text that must not page as FATAL. + * Written by orphan settle after server restart / reaper — expected, high volume. + */ +export function isExpectedSessionLastError(lastError: string): boolean { + const text = lastError.trim().toLowerCase(); + if (text === "") return true; + if (text.includes("recovered orphan session")) return true; + if (text.includes("server restarted while the agent was working")) return true; + if (text.includes("send a follow-up to resume")) return true; + return false; +} + +/** + * Classify a session last_error for the ops watchdog. + * - ignore: expected recover / empty + * - fatal: real provider / process / hard session failure + */ +export function classifySessionLastError(input: { + readonly lastError: string; + readonly status?: string | null | undefined; +}): SessionLastErrorKind { + if (isExpectedSessionLastError(input.lastError)) return "ignore"; + // Prefer true error rows; still allow non-empty last_error on other statuses when + // the text is not an expected recover (e.g. ACP spawn failure left on interrupted). + if (input.status === "error" || input.status === "interrupted" || input.status == null) { + return "fatal"; + } + // ready/idle/stopped with a leftover last_error string — still worth a quiet fatal + // once, but not recover spam (already ignored above). + return "fatal"; +} + +/** + * Filter + cap session errors for Discord posting. + * Groups ignored recoveries for optional summary; never emits them as FATAL lines. + */ +export function selectSessionErrorsForAlert( + errors: ReadonlyArray<{ + readonly threadId: string; + readonly lastError: string; + readonly status?: string | null | undefined; + }>, + maxFatals: number = SESSION_ERROR_ALERT_MAX, +): { + readonly fatals: ReadonlyArray<{ readonly threadId: string; readonly lastError: string }>; + readonly ignoredRecoveryCount: number; +} { + let ignoredRecoveryCount = 0; + const fatals: Array<{ threadId: string; lastError: string }> = []; + for (const entry of errors) { + const kind = classifySessionLastError(entry); + if (kind === "ignore") { + ignoredRecoveryCount += 1; + continue; + } + if (fatals.length < Math.max(0, maxFatals)) { + fatals.push({ threadId: entry.threadId, lastError: entry.lastError }); + } + } + return { fatals, ignoredRecoveryCount }; +} + +/** Stable-ish key so identical failure text across threads shares one cooldown bucket. */ +export function sessionErrorAlertKey(threadId: string, lastError: string): string { + const signature = lastError + .trim() + .toLowerCase() + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/giu, "") + .replace(/\b\d{4,}\b/gu, "") + .slice(0, 120); + // Prefer signature-first so N threads with the same spawn error share cooldown. + // Fall back to thread id when the body is empty/unique. + if (signature.length >= 12) { + return `session-error-sig:${signature}`; + } + return `session-error:${threadId}`; +} + +// --- /proc helpers ----------------------------------------------------------- + +function readLoad(): { load1: number; load5: number } { + const raw = NodeFS.readFileSync("/proc/loadavg", "utf8"); + const parts = raw.split(/\s+/); + return { load1: Number(parts[0] ?? "0"), load5: Number(parts[1] ?? "0") }; +} + +function readNproc(): number { + try { + return NodeFS.readdirSync("/sys/devices/system/cpu").filter((name) => /^cpu\d+$/.test(name)) + .length; + } catch { + return 1; + } +} + +function readMemMb(): { total: number; available: number } { + const raw = NodeFS.readFileSync("/proc/meminfo", "utf8"); + const get = (key: string) => { + const match = new RegExp(`^${key}:\\s+(\\d+)`, "m").exec(raw); + return match ? Number(match[1]) / 1024 : 0; + }; + return { total: get("MemTotal"), available: get("MemAvailable") }; +} + +/** Sample total jiffies from /proc/stat (all cpus line). */ +function readCpuJiffies(): { idle: number; total: number } | null { + try { + const line = NodeFS.readFileSync("/proc/stat", "utf8").split("\n")[0] ?? ""; + // cpu user nice system idle iowait irq softirq steal ... + const parts = line.trim().split(/\s+/).slice(1).map(Number); + if (parts.length < 4) return null; + const idle = (parts[3] ?? 0) + (parts[4] ?? 0); // idle + iowait + const total = parts.reduce((a, b) => a + b, 0); + return { idle, total }; + } catch { + return null; + } +} + +let lastCpuSample: { idle: number; total: number } | null = null; + +function sampleCpuPercent(): number | null { + const now = readCpuJiffies(); + if (now === null) return null; + const prev = lastCpuSample; + lastCpuSample = now; + if (prev === null) return null; + const dTotal = now.total - prev.total; + const dIdle = now.idle - prev.idle; + if (dTotal <= 0) return null; + return Math.max(0, Math.min(100, (1 - dIdle / dTotal) * 100)); +} + +function readDisk(path: string): DiskInfo | null { + try { + if (!NodeFS.existsSync(path)) return null; + const s = NodeFS.statfsSync(path); + // Node types: bsize, blocks, bfree, bavail + const bsize = Number(s.bsize); + const total = Number(s.blocks) * bsize; + const free = Number(s.bavail) * bsize; + if (total <= 0) return null; + return { + path, + totalGb: total / 1024 ** 3, + freeGb: free / 1024 ** 3, + freePercent: (free / total) * 100, + }; + } catch { + return null; + } +} + +function readRssMb(pid: number): number { + try { + const status = NodeFS.readFileSync(`/proc/${pid}/status`, "utf8"); + const match = /^VmRSS:\s+(\d+)\s+kB/m.exec(status); + return match ? Number(match[1]) / 1024 : 0; + } catch { + return 0; + } +} + +/** utime + stime from /proc/pid/stat (clock ticks → seconds). */ +function readCpuSeconds(pid: number): number { + try { + const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); + // comm can contain spaces/parens — split after last ") " + const idx = stat.lastIndexOf(") "); + if (idx < 0) return 0; + const fields = stat.slice(idx + 2).split(/\s+/); + const utime = Number(fields[11] ?? 0); // 14th field overall, 12th after state + const stime = Number(fields[12] ?? 0); + const ticks = + Number(NodeChildProcess.execFileSync("getconf", ["CLK_TCK"], { encoding: "utf8" }).trim()) || + 100; + return (utime + stime) / ticks; + } catch { + try { + // fields: after ") " → state ppid ... utime is index 11 (0-based) in the remainder + const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); + const idx = stat.lastIndexOf(") "); + const fields = stat.slice(idx + 2).split(/\s+/); + return (Number(fields[11] ?? 0) + Number(fields[12] ?? 0)) / 100; + } catch { + return 0; + } + } +} + +function readCmdline(pid: number): string { + try { + return NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").join(" ").trim(); + } catch { + return ""; + } +} + +function shortCmd(cmd: string): string { + if (cmd.includes("shared-sentry-mcp-proxy")) return "shared-sentry-mcp-proxy"; + if (cmd.includes("@sentry/mcp-server") || /\bsentry-mcp\b/.test(cmd)) return "sentry-mcp"; + const base = cmd.split(/\s+/).find((p) => p.includes("/")) ?? cmd; + return base.slice(-80); +} + +function listProcesses(): ReadonlyArray { + const out: ProcInfo[] = []; + for (const ent of NodeFS.readdirSync("/proc")) { + if (!/^\d+$/.test(ent)) continue; + const pid = Number(ent); + if (pid === process.pid) continue; + const cmd = readCmdline(pid); + if (cmd === "") continue; + out.push({ + pid, + rssMb: readRssMb(pid), + cpuSeconds: readCpuSeconds(pid), + cmd, + label: shortCmd(cmd), + }); + } + return out; +} + +function listRunaways(procs: ReadonlyArray): ReadonlyArray { + return procs.filter((p) => RUNAWAY_PATTERNS.some((rule) => rule.match(p.cmd))); +} + +// Per-tick tracker state for sustained-hotness detection. Module-level because +// it must persist across `collectHostSnapshot` calls; the logic itself lives in +// the pure `trackSustainedHotProcesses`. +let sustainState: ReadonlyMap = new Map(); + +function listFatProcesses( + procs: ReadonlyArray, + nowMs: number, + rules: ReadonlyArray, +): ReadonlyArray { + // Generic sustained high RSS / CPU alerts (never auto-kill). Our own long-lived + // services are excluded — they are expected to run hot and are handled by + // dedicated checks, not this generic catch-all. + const skip = (cmd: string) => + cmd.includes("t3code") || + cmd.includes("apps/server") || + cmd.includes("discord-bot") || + cmd.includes("shared-sentry-mcp-proxy") || + cmd.includes("codex app-server") || + cmd.includes("cloud-hypervisor") || + cmd.includes("virtiofsd"); + + const resolveRule = (proc: ProcInfo): ResolvedProcessAlertRule => { + const normalizedCmd = proc.cmd.toLowerCase(); + const normalizedLabel = proc.label.toLowerCase(); + const custom = rules.find((rule) => { + const match = rule.match.toLowerCase(); + return normalizedCmd.includes(match) || normalizedLabel.includes(match); + }); + if (custom === undefined) return DEFAULT_PROCESS_ALERT_RULE; + return { + id: custom.id, + rssMbThreshold: custom.rssMbThreshold ?? null, + cpuPercentThreshold: custom.cpuPercentThreshold ?? null, + sustainedForMs: custom.sustainedForMs, + }; + }; + + const { next, hot } = trackSustainedHotProcesses({ + prev: sustainState, + procs: procs.filter((p) => !skip(p.cmd)), + nowMs, + resolveRule, + }); + sustainState = next; + return hot; +} + +function querySqliteJson(dbPath: string, scriptBody: string, extraArgs: string[] = []): unknown { + if (!NodeFS.existsSync(dbPath)) return null; + try { + const script = ` +import json, sqlite3, sys, time +from datetime import datetime +db_path = sys.argv[1] +db = sqlite3.connect("file:" + db_path + "?mode=ro", uri=True) +${scriptBody} +`; + const raw = NodeChildProcess.execFileSync("python3", ["-c", script, dbPath, ...extraArgs], { + encoding: "utf8", + timeout: 5_000, + }); + return JSON.parse(raw); + } catch { + return null; + } +} + +function listLongRunningTurns( + dbPath: string, + minAgeMs: number, +): ReadonlyArray<{ threadId: string; turnId: string; ageMin: number }> { + const parsed = querySqliteJson( + dbPath, + ` +min_age_ms = float(sys.argv[2]) +cur = db.execute( + "SELECT thread_id, turn_id, requested_at FROM projection_turns " + "WHERE state = 'running' AND turn_id IS NOT NULL ORDER BY requested_at ASC LIMIT 10" +) +now = time.time() +out = [] +for thread_id, turn_id, requested_at in cur: + try: + started = datetime.fromisoformat(requested_at.replace("Z", "+00:00")).timestamp() + except Exception: + started = now + age_ms = (now - started) * 1000 + if age_ms >= min_age_ms: + out.append({"threadId": thread_id, "turnId": turn_id, "ageMin": int(age_ms // 60000)}) +print(json.dumps(out)) +`, + [String(minAgeMs)], + ); + return (parsed as Array<{ threadId: string; turnId: string; ageMin: number }>) ?? []; +} + +export function listSessionErrors(dbPath: string): ReadonlyArray<{ + threadId: string; + lastError: string; + status: string | null; +}> { + const parsed = querySqliteJson( + dbPath, + ` +cur = db.execute( + "SELECT thread_id, last_error, status FROM projection_thread_sessions " + "WHERE last_error IS NOT NULL AND TRIM(last_error) != '' " + "ORDER BY updated_at DESC LIMIT 40" +) +print(json.dumps([ + {"threadId": r[0], "lastError": r[1] or "", "status": r[2]} + for r in cur +])) +`, + ); + return (parsed as Array<{ threadId: string; lastError: string; status: string | null }>) ?? []; +} + +function listFailedSystemdUnits(): ReadonlyArray { + try { + const raw = NodeChildProcess.execFileSync( + "systemctl", + ["list-units", "--failed", "--no-legend", "--no-pager", "--plain"], + { encoding: "utf8", timeout: 5_000 }, + ); + return raw + .split("\n") + .map((line) => line.trim().split(/\s+/)[0] ?? "") + .filter((u) => u.endsWith(".service") || u.endsWith(".timer")); + } catch { + return []; + } +} + +export function collectHostSnapshot(input: { + readonly stateSqlitePath: string | undefined; + readonly nowMs: number; + readonly alertProcessRules: ReadonlyArray; +}): HostSnapshot { + const mem = readMemMb(); + const load = readLoad(); + const procs = listProcesses(); + const disks = DISK_PATHS.map(readDisk).filter((d): d is DiskInfo => d !== null); + const db = input.stateSqlitePath ?? ""; + return { + load1: load.load1, + load5: load.load5, + nproc: Math.max(1, readNproc()), + cpuPercent: sampleCpuPercent(), + memTotalMb: mem.total, + memAvailableMb: mem.available, + disks, + runaways: listRunaways(procs), + fatProcesses: listFatProcesses(procs, input.nowMs, input.alertProcessRules), + longTurns: listLongRunningTurns(db, TURN_RUNNING_MIN_MS), + sessionErrors: listSessionErrors(db), + failedUnits: listFailedSystemdUnits(), + }; +} + +// --- Fatal / bridge alert bus (callable from bridge / main) ------------------ + +type Poster = ( + key: string, + content: string, + cooldownMs?: number, + files?: ReadonlyArray, +) => Effect.Effect; + +let poster: Poster | null = null; + +/** Bridge snapshot handler failures: short enough to notice, long enough to avoid spam. */ +const BRIDGE_ALERT_COOLDOWN_MS = 3 * 60 * 1000; +const TRACE_MIME_TYPE = "text/plain;charset=utf-8"; + +export interface AlertTraceDelivery { + readonly content: string; + readonly files: ReadonlyArray; +} + +function alertTraceDelivery(content: string, filename: string, trace: string): AlertTraceDelivery { + return { + content: `${content}\n_Complete trace attached as \`${filename}\`._`, + files: [textFile(filename, trace, TRACE_MIME_TYPE)], + }; +} + +export function fatalAlertDelivery(title: string, trace: string): AlertTraceDelivery { + return alertTraceDelivery(`**FATAL: ${title}**`, "fatal-trace.txt", trace); +} + +export function bridgeAlertDelivery(title: string, trace: string): AlertTraceDelivery { + return alertTraceDelivery(`**BRIDGE: ${title}**`, "bridge-trace.txt", trace); +} + +export function sessionErrorAlertDelivery(threadId: string, trace: string): AlertTraceDelivery { + const filename = `t3-session-error-${threadId}.txt`; + return alertTraceDelivery( + ["**FATAL: T3 session error**", `thread=\`${threadId}\``].join("\n"), + filename, + trace, + ); +} + +/** + * Render an Effect `Cause` (or any thrown value) for Discord / logs. + * Logging `{ cause }` alone shows `{ _id: 'Cause', failures: [ [Object] ] }`. + */ +export function formatAlertCause(cause: unknown, maxLen?: number): string { + let text: string; + try { + if (Cause.isCause(cause)) { + text = Cause.pretty(cause); + } else if (cause instanceof Error) { + text = cause.stack?.trim() || cause.message || String(cause); + } else if (typeof cause === "string") { + text = cause; + } else { + text = JSON.stringify(cause, null, 2) ?? String(cause); + } + } catch { + text = String(cause); + } + const trimmed = text.replace(/\s+$/u, "").trim(); + if (trimmed === "") return "(empty cause)"; + return maxLen !== undefined && trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed; +} + +/** + * Post a **fatal** ops alert (short cooldown). Safe no-op if watchdog not started + * or channel unset. Does not require DiscordREST in the caller — uses the + * watchdog-held poster. + */ +export const postFatalAlert = (key: string, title: string, detail: string) => + Effect.gen(function* () { + const p = poster; + if (p === null) { + yield* Effect.logError(`Fatal (no alerts channel): ${title}`, { detail }); + return; + } + const delivery = fatalAlertDelivery(title, detail); + yield* p(`fatal:${key}`, delivery.content, FATAL_COOLDOWN_MS, delivery.files); + }); + +/** + * Post a **bridge** ops alert (medium cooldown). Use for onThread failures, + * stream/heartbeat Discord errors, and other bridge soft-failures that leave + * Discord threads desynced while T3 still advances. + */ +export const postBridgeAlert = (key: string, title: string, detail: string) => + Effect.gen(function* () { + const p = poster; + if (p === null) { + yield* Effect.logError(`Bridge alert (no alerts channel): ${title}`, { detail }); + return; + } + const delivery = bridgeAlertDelivery(title, detail); + yield* p(`bridge:${key}`, delivery.content, BRIDGE_ALERT_COOLDOWN_MS, delivery.files); + }); + +// --- Watchdog ---------------------------------------------------------------- + +/** + * Periodic guest watchdog → Discord alerts channel. + * No-op (log only) when `alertsChannelId` is unset. + */ +export const runAlertWatchdog = (botConfig: DiscordBotConfig) => + Effect.gen(function* () { + const channelId = botConfig.alertsChannelId; + if (channelId === undefined || channelId.trim() === "") { + yield* Effect.logInfo( + "Discord alerts channel unset (DISCORD_ALERTS_CHANNEL_ID); watchdog idle", + ); + return; + } + + const rest = yield* DiscordREST; + const discordConfig = yield* DiscordConfig.DiscordConfig; + const alertProcessRules = loadAlertProcessRulesFromFileSync(botConfig.alertProcessRulesPath); + const lastSent = new Map(); + + const postAlert: Poster = (key, content, cooldownMs = COOLDOWN_MS, files = []) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const prev = lastSent.get(key) ?? 0; + if (now - prev < cooldownMs) return; + lastSent.set(key, now); + const body = + content.length > DISCORD_ALERT_MESSAGE_LIMIT + ? `${content.slice(0, DISCORD_ALERT_MESSAGE_LIMIT)}…` + : content; + yield* Effect.gen(function* () { + if (files.length === 0) { + yield* rest.createMessage(channelId, { content: body }); + } else { + yield* Effect.tryPromise({ + try: () => + createMessageWithAttachments({ + baseUrl: discordConfig.rest.baseUrl, + botToken: Redacted.value(discordConfig.token), + channelId, + content: body, + files, + }), + catch: (cause) => + cause instanceof DiscordUploadError + ? cause + : new DiscordUploadError(cause instanceof Error ? cause.message : String(cause)), + }); + } + yield* Effect.logInfo("Posted Discord ops alert", { + key, + channelId, + fileCount: files.length, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Failed to post Discord ops alert").pipe( + Effect.andThen(Effect.logError(cause)), + ), + ), + ); + }); + + poster = postAlert; + + // Prime CPU sample so the next tick has a delta. No boot/idle chatter — + // only post when a check fails. + sampleCpuPercent(); + + yield* Effect.repeat( + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + const snap = collectHostSnapshot({ + stateSqlitePath: botConfig.stateSqlitePath, + nowMs, + alertProcessRules, + }); + const loadLimit = snap.nproc * LOAD_RATIO; + + // --- load --- + if (snap.load1 >= loadLimit && snap.load1 >= 2) { + yield* postAlert( + "load", + [ + "**High load**", + `load1=${snap.load1.toFixed(2)} load5=${snap.load5.toFixed(2)}`, + `threshold≈${loadLimit.toFixed(2)} on ${snap.nproc} CPUs`, + `cpu≈${snap.cpuPercent?.toFixed(0) ?? "?"}%; mem avail=${snap.memAvailableMb.toFixed(0)} MiB`, + ].join("\n"), + ); + } + + // --- cpu --- + if (snap.cpuPercent !== null && snap.cpuPercent >= CPU_PERCENT_ALERT) { + yield* postAlert( + "cpu", + [ + "**High CPU**", + `cpu≈${snap.cpuPercent.toFixed(0)}% (alert ≥${CPU_PERCENT_ALERT}%)`, + `load1=${snap.load1.toFixed(2)} nproc=${snap.nproc}`, + snap.fatProcesses.length > 0 + ? `sustained:\n${snap.fatProcesses + .slice(0, 5) + .map( + (p) => + `• pid=${p.pid} rss=${p.rssMb.toFixed(0)}MiB cpu≈${p.cpuPercent.toFixed(0)}% ${p.label}`, + ) + .join("\n")}` + : "", + ] + .filter(Boolean) + .join("\n"), + ); + } + + // --- memory --- + if (snap.memAvailableMb > 0 && snap.memAvailableMb < MEM_AVAILABLE_MIN_MB) { + yield* postAlert( + "mem", + [ + "**Low memory**", + `available=${snap.memAvailableMb.toFixed(0)} MiB (min ${MEM_AVAILABLE_MIN_MB})`, + `total=${snap.memTotalMb.toFixed(0)} MiB`, + snap.runaways.length > 0 + ? `runaways: ${snap.runaways.map((s) => `pid=${s.pid} ${s.rssMb.toFixed(0)}MiB`).join(", ")}` + : "", + ] + .filter(Boolean) + .join("\n"), + ); + } + + // --- disk --- + for (const d of snap.disks) { + if (d.freePercent < DISK_FREE_MIN_PERCENT || d.freeGb < DISK_FREE_MIN_GB) { + yield* postAlert( + `disk:${d.path}`, + [ + "**Low disk space**", + `path=\`${d.path}\``, + `free=${d.freeGb.toFixed(1)} GiB (${d.freePercent.toFixed(0)}%) of ${d.totalGb.toFixed(1)} GiB`, + `thresholds: <${DISK_FREE_MIN_PERCENT}% or <${DISK_FREE_MIN_GB} GiB`, + ].join("\n"), + ); + } + } + + // --- runaway MCP (legacy stdio Sentry) — alert only, never kill --- + const runaways = snap.runaways; + if (runaways.length >= SENTRY_COUNT_ALERT) { + yield* postAlert( + "runaway-count", + [ + `**Legacy Sentry MCP stdio process(es)** (${runaways.length})`, + ...runaways.map( + (s) => + `• pid=${s.pid} rss=${s.rssMb.toFixed(0)} MiB cpuTime=${s.cpuSeconds.toFixed(0)}s ${s.label}`, + ), + "_Not auto-killed. Prefer shared proxy `shared-sentry-mcp-proxy` + `/etc/shared-mcp-setup`._", + ].join("\n"), + ); + } else { + const fat = runaways.filter((s) => s.rssMb >= SENTRY_RSS_ALERT_MB); + if (fat.length > 0) { + yield* postAlert( + "runaway-rss", + [ + "**Legacy Sentry MCP high RSS**", + ...fat.map( + (s) => + `• pid=${s.pid} rss=${s.rssMb.toFixed(0)} MiB (alert ≥${SENTRY_RSS_ALERT_MB}) ${s.label}`, + ), + "_Not auto-killed. Check agent MCP config points at http://127.0.0.1:7391/mcp._", + ].join("\n"), + ); + } + } + + // --- other stuck/fat processes (alert only) --- + const fatNonRunaway = snap.fatProcesses.filter( + (p) => !runaways.some((k) => k.pid === p.pid), + ); + if (fatNonRunaway.length > 0) { + yield* postAlert( + "stuck-proc", + [ + "**Sustained high RSS / CPU process(es)**", + ...fatNonRunaway.map( + (p) => + `• pid=${p.pid} rss=${p.rssMb.toFixed(0)}MiB cpu≈${p.cpuPercent.toFixed(0)}% ` + + `for ${Math.round(p.sustainedMs / 60_000)}m ${p.label}`, + ), + ...fatNonRunaway.map((p) => { + const parts = []; + if (p.rssMbThreshold !== null) parts.push(`RSS≥${p.rssMbThreshold.toFixed(0)}MiB`); + if (p.cpuPercentThreshold !== null) { + parts.push(`CPU≥${p.cpuPercentThreshold.toFixed(0)}% of a core`); + } + return `_rule=${p.ruleId}; sustained ≥${Math.round(p.sustainedForMs / 60_000)}m; ${parts.join(" or ")}._`; + }), + ].join("\n"), + ); + } + + // --- long T3 turns --- + for (const turn of snap.longTurns) { + yield* postAlert( + `turn:${turn.turnId}`, + [ + "**Long-running T3 turn**", + `thread=\`${turn.threadId}\``, + `turn=\`${turn.turnId}\``, + `age≈${turn.ageMin} min (alert after ${TURN_RUNNING_MIN_MS / 60_000} min)`, + ].join("\n"), + ); + } + + // --- session last_error (real fatals only; skip orphan-restart recover spam) --- + const sessionSelection = selectSessionErrorsForAlert(snap.sessionErrors); + for (const err of sessionSelection.fatals) { + const delivery = sessionErrorAlertDelivery(err.threadId, err.lastError); + yield* postAlert( + sessionErrorAlertKey(err.threadId, err.lastError), + delivery.content, + SESSION_ERROR_FATAL_COOLDOWN_MS, + delivery.files, + ); + } + // Expected recoveries are intentionally not posted — high volume after restarts + // and already covered by Wake Required UX when work was actually mid-turn. + if (sessionSelection.ignoredRecoveryCount > 0) { + yield* Effect.logDebug("Skipped expected session last_error recoveries", { + count: sessionSelection.ignoredRecoveryCount, + }); + } + + // --- failed systemd units --- + if (snap.failedUnits.length > 0) { + yield* postAlert( + "systemd-failed", + ["**FATAL: systemd failed units**", ...snap.failedUnits.map((u) => `• \`${u}\``)].join( + "\n", + ), + FATAL_COOLDOWN_MS, + ); + } + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Alert watchdog tick failed").pipe( + Effect.andThen(Effect.logError(cause)), + ), + ), + ), + Schedule.spaced(POLL), + ); + }); diff --git a/apps/discord-bot/src/features/BridgeHub.test.ts b/apps/discord-bot/src/features/BridgeHub.test.ts new file mode 100644 index 00000000000..6140113cfee --- /dev/null +++ b/apps/discord-bot/src/features/BridgeHub.test.ts @@ -0,0 +1,174 @@ +// @effect-diagnostics globalDateInEffect:off +/* oxlint-disable t3code/no-manual-effect-runtime-in-tests -- Legacy concurrency tests manually drive Effect fibers. */ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { + makeBridgeHub, + type BridgeControlSlot, + type BridgeEnsureInput, + type BridgeRunner, +} from "./BridgeHub.ts"; + +const baseInput = (id: string, t3 = `t3-${id}`): BridgeEnsureInput => ({ + discordChannelId: id, + t3ThreadId: t3, + mode: "interactive", +}); + +const runnerWithControl = ( + body: (input: BridgeEnsureInput, ready: Deferred.Deferred) => Effect.Effect, +): BridgeRunner => { + return (input, ready, _control: BridgeControlSlot) => body(input, ready); +}; + +describe("BridgeHub", () => { + it("ensure starts a fiber and listActive tracks it until drop", async () => { + const runner: BridgeRunner = runnerWithControl((_input, ready) => + Effect.gen(function* () { + yield* Deferred.succeed(ready, undefined); + yield* Effect.sleep("1 minute"); + }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const hub = yield* makeBridgeHub(runner); + yield* hub.ensure(baseInput("ch-1")); + expect(yield* hub.activeCount()).toBe(1); + expect(yield* hub.listActive()).toEqual([ + expect.objectContaining({ + discordChannelId: "ch-1", + t3ThreadId: "t3-ch-1", + }), + ]); + + yield* hub.drop("ch-1"); + expect(yield* hub.activeCount()).toBe(0); + }), + ); + }); + + it("rehydrate ensure is a no-op when the same t3 thread is already bridged", async () => { + let starts = 0; + const runner: BridgeRunner = runnerWithControl((_input, ready) => + Effect.gen(function* () { + starts += 1; + yield* Deferred.succeed(ready, undefined); + yield* Effect.sleep("1 minute"); + }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const hub = yield* makeBridgeHub(runner); + yield* hub.ensure({ ...baseInput("ch-1"), mode: "rehydrate" }); + yield* hub.ensure({ ...baseInput("ch-1"), mode: "rehydrate" }); + expect(starts).toBe(1); + expect(yield* hub.activeCount()).toBe(1); + yield* hub.drop("ch-1"); + }), + ); + }); + + it("interactive ensure reuses an existing fiber for the same t3 thread", async () => { + let starts = 0; + let adopted = 0; + const sentIds: string[] = []; + const runner: BridgeRunner = (input, ready, control) => + Effect.gen(function* () { + starts += 1; + control.noteSentUserMessageIds = (ids) => + Effect.sync(() => { + sentIds.push(...ids); + }); + control.adoptWorkingAckMessageId = () => + Effect.sync(() => { + adopted += 1; + }); + yield* Deferred.succeed(ready, undefined); + yield* Effect.sleep("1 minute"); + }); + + await Effect.runPromise( + Effect.gen(function* () { + const hub = yield* makeBridgeHub(runner); + yield* hub.ensure(baseInput("ch-1")); + yield* hub.ensure({ + ...baseInput("ch-1"), + sentDiscordUserMessageIds: ["user-2"], + workingAckMessageId: "ack-2", + }); + expect(starts).toBe(1); + expect(adopted).toBe(1); + expect(sentIds).toEqual(["user-2"]); + expect(yield* hub.activeCount()).toBe(1); + yield* hub.drop("ch-1"); + }), + ); + }); + + it("replace fiber when t3ThreadId changes", async () => { + const seen: string[] = []; + const runner: BridgeRunner = runnerWithControl((input, ready) => + Effect.gen(function* () { + seen.push(input.t3ThreadId); + yield* Deferred.succeed(ready, undefined); + yield* Effect.sleep("1 minute"); + }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const hub = yield* makeBridgeHub(runner); + yield* hub.ensure(baseInput("ch-1", "t3-a")); + yield* hub.ensure(baseInput("ch-1", "t3-b")); + expect(seen).toEqual(["t3-a", "t3-b"]); + expect(yield* hub.activeCount()).toBe(1); + expect((yield* hub.listActive())[0]?.t3ThreadId).toBe("t3-b"); + yield* hub.drop("ch-1"); + }), + ); + }); + + it("dropAll clears every live bridge", async () => { + const runner: BridgeRunner = runnerWithControl((_input, ready) => + Effect.gen(function* () { + yield* Deferred.succeed(ready, undefined); + yield* Effect.sleep("1 minute"); + }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const hub = yield* makeBridgeHub(runner); + yield* hub.ensure(baseInput("a")); + yield* hub.ensure(baseInput("b")); + expect(yield* hub.activeCount()).toBe(2); + yield* hub.dropAll(); + expect(yield* hub.activeCount()).toBe(0); + }), + ); + }); + + it("getLive returns control surface for a matching channel", async () => { + const runner: BridgeRunner = (input, ready, control) => + Effect.gen(function* () { + control.noteSentUserMessageIds = () => Effect.void; + yield* Deferred.succeed(ready, undefined); + yield* Effect.sleep("1 minute"); + }); + + await Effect.runPromise( + Effect.gen(function* () { + const hub = yield* makeBridgeHub(runner); + yield* hub.ensure(baseInput("ch-1", "t3-1")); + const live = yield* hub.getLive("ch-1", "t3-1"); + expect(live?.t3ThreadId).toBe("t3-1"); + expect(yield* hub.getLive("ch-1", "other")).toBeNull(); + yield* hub.drop("ch-1"); + }), + ); + }); +}); diff --git a/apps/discord-bot/src/features/BridgeHub.ts b/apps/discord-bot/src/features/BridgeHub.ts new file mode 100644 index 00000000000..0b3783b30d2 --- /dev/null +++ b/apps/discord-bot/src/features/BridgeHub.ts @@ -0,0 +1,379 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off missingEffectError:off globalDate:off +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { formatAlertCause, postFatalAlert } from "./Alerts.ts"; + +/** Hard cap on concurrent live Discord↔T3 bridges (design decision 4). */ +export const MAX_ACTIVE_BRIDGES = 50; + +export type BridgeEnsureMode = "interactive" | "rehydrate"; + +export type DiscordBridgePresentationMode = "full" | "final-only"; + +export type BridgeEnsureInput = { + readonly discordChannelId: string; + readonly t3ThreadId: string; + /** Pre-posted "_Working.._" message — reused as stream tip and deleted on finalize. */ + readonly workingAckMessageId?: string | null; + /** Discord-originated T3 user message ids that may appear immediately after subscribe. */ + readonly sentDiscordUserMessageIds?: ReadonlyArray; + /** Final-only avoids progress chatter for ambient conversational turns. */ + readonly presentationMode?: DiscordBridgePresentationMode; + /** + * `interactive` — mention path (may seed Working..). + * `rehydrate` — boot/reconnect restore; skips new Working.. unless needed. + */ + readonly mode?: BridgeEnsureMode; + /** + * Activity timestamp for eviction ranking (ISO). Defaults to now on ensure. + * Prefer the durable link's `lastActivityAt` when known. + */ + readonly lastActivityAt?: string; + /** + * When true, this bridge is preferred to keep under cap pressure (running/pending). + * Interactive ensures are always treated as preferred. + */ + readonly preferred?: boolean; +}; + +export type ActiveBridge = { + readonly discordChannelId: string; + readonly t3ThreadId: string; + readonly lastActivityAt: string; + readonly preferred: boolean; + readonly mode: BridgeEnsureMode; +}; + +/** Control surface filled by runBridge so mid-turn follow-ups can reuse the live fiber. */ +export type BridgeControlSlot = { + noteSentUserMessageIds: (ids: ReadonlyArray) => Effect.Effect; + adoptWorkingAckMessageId: (messageId: string) => Effect.Effect; + /** + * Force-refresh Discord thread title indicators (PR/VCS badges). + * Clears title settle cache, re-queries VCS/PR, and renames the Discord thread. + */ + refreshThreadIndicators: () => Effect.Effect; +}; + +export type RefreshThreadIndicatorsResult = + | { readonly ok: true; readonly title: string } + | { readonly ok: false; readonly error: string }; + +type BridgeEntry = { + readonly fiber: Fiber.Fiber; + readonly t3ThreadId: string; + /** Singleflight: concurrent ensure for the same channel waits on this. */ + readonly ready: Deferred.Deferred; + readonly lastActivityAt: string; + readonly preferred: boolean; + readonly mode: BridgeEnsureMode; + readonly control: BridgeControlSlot; +}; + +/** + * Starts a bridge fiber and waits until the first T3 snapshot (or failure/timeout). + * Injected so BridgeHub does not import the full ResponseBridge module graph. + * Requirements (T3Session, DiscordREST, …) come from the ambient ensure call context. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type BridgeRunner = ( + input: BridgeEnsureInput, + ready: Deferred.Deferred, + controlSlot: BridgeControlSlot, +) => Effect.Effect; + +export interface LiveDiscordBridge { + readonly t3ThreadId: string; + readonly noteSentUserMessageIds: (ids: ReadonlyArray) => Effect.Effect; + readonly adoptWorkingAckMessageId: (messageId: string) => Effect.Effect; + readonly refreshThreadIndicators: () => Effect.Effect; +} + +export interface BridgeHubService { + readonly ensure: (input: BridgeEnsureInput) => Effect.Effect; + readonly drop: (discordChannelId: string) => Effect.Effect; + /** Interrupt every live bridge fiber (T3 reconnect / ops). Durable links stay. */ + readonly dropAll: () => Effect.Effect; + readonly listActive: () => Effect.Effect>; + readonly activeCount: () => Effect.Effect; + /** Update activity timestamp used for eviction ranking. */ + readonly touch: (discordChannelId: string, at?: string) => Effect.Effect; + readonly getLive: ( + discordChannelId: string, + t3ThreadId?: string, + ) => Effect.Effect; +} + +export class BridgeHub extends Context.Service()( + "@t3tools/discord-bot/features/BridgeHub", +) {} + +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * Pick the best eviction victim under cap pressure. + * Prefer non-preferred (idle) bridges with oldest lastActivityAt. + * Falls back to oldest preferred when every active bridge is preferred. + */ +export function pickEvictionVictim( + active: ReadonlyArray, + exceptChannelId: string, +): ActiveBridge | null { + const candidates = active.filter((entry) => entry.discordChannelId !== exceptChannelId); + if (candidates.length === 0) return null; + const idle = candidates.filter((entry) => !entry.preferred); + const pool = idle.length > 0 ? idle : candidates; + return [...pool].sort((a, b) => a.lastActivityAt.localeCompare(b.lastActivityAt))[0] ?? null; +} + +/** + * In-memory registry of live bridge fibers with singleflight ensure / drop / cap eviction. + * + * Same-thread interactive ensure **reuses** the live fiber (preserves mid-turn stream tips). + * Rehydrate ensure is a no-op when the same t3ThreadId is already live. + * Different t3ThreadId always replaces the fiber. + */ +export const makeBridgeHub = (runBridge: BridgeRunner) => + Effect.gen(function* () { + const bridges = yield* Ref.make(new Map()); + + const dropInternal = (discordChannelId: string) => + Effect.gen(function* () { + const entry = yield* Ref.modify(bridges, (map) => { + const current = map.get(discordChannelId); + if (current === undefined) return [undefined, map] as const; + const copy = new Map(map); + copy.delete(discordChannelId); + return [current, copy] as const; + }); + if (entry === undefined) return; + yield* Fiber.interrupt(entry.fiber).pipe(Effect.ignore); + }); + + const listActiveInternal = () => + Ref.get(bridges).pipe( + Effect.map((map) => + [...map.entries()].map( + ([discordChannelId, entry]): ActiveBridge => ({ + discordChannelId, + t3ThreadId: entry.t3ThreadId, + lastActivityAt: entry.lastActivityAt, + preferred: entry.preferred, + mode: entry.mode, + }), + ), + ), + ); + + const enforceCap = (exceptChannelId: string) => + Effect.gen(function* () { + // Leave room for the bridge we are about to start. + while (true) { + const active = yield* listActiveInternal(); + if (active.length < MAX_ACTIVE_BRIDGES) return; + const victim = pickEvictionVictim(active, exceptChannelId); + if (victim === null) { + yield* Effect.logWarning("BridgeHub at hard capacity with no eviction victim", { + active: active.length, + cap: MAX_ACTIVE_BRIDGES, + exceptChannelId, + }); + return; + } + yield* Effect.logWarning("BridgeHub evicting bridge under capacity pressure", { + victim: victim.discordChannelId, + victimT3: victim.t3ThreadId, + lastActivityAt: victim.lastActivityAt, + preferred: victim.preferred, + active: active.length, + cap: MAX_ACTIVE_BRIDGES, + }); + yield* dropInternal(victim.discordChannelId); + } + }); + + const ensure = (input: BridgeEnsureInput): Effect.Effect => + Effect.gen(function* () { + const mode = input.mode ?? "interactive"; + const preferred = input.preferred ?? mode === "interactive"; + const activityAt = input.lastActivityAt ?? nowIso(); + const existing = yield* Ref.get(bridges).pipe( + Effect.map((map) => map.get(input.discordChannelId)), + ); + + // Same T3 thread already bridging: reuse live fiber (mid-turn steers + rehydrate). + if (existing !== undefined && existing.t3ThreadId === input.t3ThreadId) { + yield* Effect.logInfo("BridgeHub.ensure reusing live fiber", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + mode, + }); + yield* Ref.update(bridges, (map) => { + const current = map.get(input.discordChannelId); + if (current === undefined) return map; + const copy = new Map(map); + copy.set(input.discordChannelId, { + ...current, + lastActivityAt: activityAt, + preferred: preferred || current.preferred, + mode, + }); + return copy; + }); + if ( + input.sentDiscordUserMessageIds !== undefined && + input.sentDiscordUserMessageIds.length > 0 + ) { + yield* existing.control.noteSentUserMessageIds(input.sentDiscordUserMessageIds); + } + if ( + input.workingAckMessageId !== undefined && + input.workingAckMessageId !== null && + input.workingAckMessageId !== "" + ) { + yield* existing.control.adoptWorkingAckMessageId(input.workingAckMessageId); + } + yield* Deferred.await(existing.ready).pipe( + Effect.timeout("15 seconds"), + Effect.catch(() => Effect.void), + ); + return; + } + + if (existing !== undefined) { + yield* Effect.logInfo("BridgeHub.ensure interrupting previous fiber", { + discordChannelId: input.discordChannelId, + previousT3ThreadId: existing.t3ThreadId, + nextT3ThreadId: input.t3ThreadId, + mode, + }); + yield* dropInternal(input.discordChannelId); + } + + yield* enforceCap(input.discordChannelId); + + const ready = yield* Deferred.make(); + const controlSlot: BridgeControlSlot = { + noteSentUserMessageIds: () => Effect.void, + adoptWorkingAckMessageId: () => Effect.void, + refreshThreadIndicators: () => + Effect.succeed({ ok: false as const, error: "Bridge control not ready yet" }), + }; + + const fiber = yield* runBridge(input, ready, controlSlot).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const pretty = formatAlertCause(cause); + yield* Effect.logError("Discord bridge fiber failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: pretty, + }); + yield* postFatalAlert( + `bridge:${input.discordChannelId}`, + "Discord bridge fiber failed", + `channel=\`${input.discordChannelId}\` thread=\`${input.t3ThreadId}\`\n${pretty}`, + ); + yield* Deferred.succeed(ready, undefined).pipe(Effect.ignore); + }).pipe(Effect.asVoid), + ), + Effect.ensuring( + Ref.update(bridges, (map) => { + const current = map.get(input.discordChannelId); + if (current === undefined || current.ready !== ready) return map; + const copy = new Map(map); + copy.delete(input.discordChannelId); + return copy; + }), + ), + // forkDetach: mention handler returns after startTurn; forkChild would kill the + // bridge mid-upload and leave Working.. + partial attachments behind. + Effect.forkDetach, + ); + + yield* Ref.update(bridges, (map) => { + const copy = new Map(map); + copy.set(input.discordChannelId, { + fiber, + t3ThreadId: input.t3ThreadId, + ready, + lastActivityAt: activityAt, + preferred, + mode, + control: controlSlot, + }); + return copy; + }); + + yield* Effect.logInfo("BridgeHub fiber started; waiting for first snapshot", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + mode, + }); + + // Do not return until subscription is live — otherwise startTurn can finish + // before any events are observed (fast providers). + yield* Deferred.await(ready).pipe( + Effect.timeout("15 seconds"), + Effect.catch((error) => + Effect.logError("Timed out / failed waiting for T3 thread subscription snapshot", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + error: String(error), + }), + ), + ); + yield* Effect.logInfo("BridgeHub subscription ready (snapshot received)", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + }); + }) as Effect.Effect; + + return BridgeHub.of({ + ensure, + drop: dropInternal, + dropAll: () => + Effect.gen(function* () { + const active = yield* listActiveInternal(); + for (const entry of active) { + yield* dropInternal(entry.discordChannelId); + } + }), + listActive: listActiveInternal, + activeCount: () => Ref.get(bridges).pipe(Effect.map((map) => map.size)), + touch: (discordChannelId, at) => + Ref.update(bridges, (map) => { + const current = map.get(discordChannelId); + if (current === undefined) return map; + const copy = new Map(map); + copy.set(discordChannelId, { + ...current, + lastActivityAt: at ?? nowIso(), + }); + return copy; + }), + getLive: (discordChannelId, t3ThreadId) => + Ref.get(bridges).pipe( + Effect.map((map) => { + const entry = map.get(discordChannelId); + if (entry === undefined) return null; + if (t3ThreadId !== undefined && entry.t3ThreadId !== t3ThreadId) return null; + return { + t3ThreadId: entry.t3ThreadId, + noteSentUserMessageIds: entry.control.noteSentUserMessageIds, + adoptWorkingAckMessageId: entry.control.adoptWorkingAckMessageId, + refreshThreadIndicators: entry.control.refreshThreadIndicators, + } satisfies LiveDiscordBridge; + }), + ), + }); + }); + +export const layer = (runBridge: BridgeRunner) => Layer.effect(BridgeHub, makeBridgeHub(runBridge)); diff --git a/apps/discord-bot/src/features/ChannelInfoPin.ts b/apps/discord-bot/src/features/ChannelInfoPin.ts new file mode 100644 index 00000000000..a6dc1ff7f85 --- /dev/null +++ b/apps/discord-bot/src/features/ChannelInfoPin.ts @@ -0,0 +1,235 @@ +// @effect-diagnostics globalFetch:off globalFetchInEffect:off unknownInEffectCatch:off anyUnknownInErrorContext:off outdatedApi:off +import type { ModelSelection, ServerProvider } from "@t3tools/contracts"; +import { DiscordConfig } from "dfx"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; + +import { preferredModelSelection, type DiscordBotConfig } from "../config.ts"; +import { + CHANNEL_INFO_PIN_MARKER, + LEGACY_CHANNEL_INFO_PIN_MARKERS, + renderChannelInfoPin, + resolveGitHubUrlForWorkspace, +} from "../presentation/channelInfoPin.ts"; + +/** Detect the bot's own channel-info pin, including pre-rebrand (legacy) markers. */ +const isChannelInfoPin = (content: string | undefined): boolean => { + if (content === undefined) return false; + if (content.includes(CHANNEL_INFO_PIN_MARKER)) return true; + return LEGACY_CHANNEL_INFO_PIN_MARKERS.some((marker) => content.includes(marker)); +}; + +interface DiscordMessageSummary { + readonly id: string; + readonly content?: string; +} + +export interface ChannelInfoPinMessageRef { + readonly channelId: string; + readonly messageId: string; +} + +async function discordApiJson(input: { + readonly baseUrl: string; + readonly botToken: string; + readonly path: string; + readonly method?: string; + readonly body?: unknown; +}): Promise { + const response = await globalThis.fetch(`${input.baseUrl.replace(/\/+$/u, "")}${input.path}`, { + method: input.method ?? "GET", + headers: { + Authorization: `Bot ${input.botToken}`, + "Content-Type": "application/json", + "User-Agent": "DiscordBot (t3-discord-bot, 0.0.0)", + }, + ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Discord API ${input.method ?? "GET"} ${input.path} failed (${response.status}): ${body}`, + ); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} + +async function discordApiVoid(input: { + readonly baseUrl: string; + readonly botToken: string; + readonly path: string; + readonly method: string; +}): Promise { + const response = await globalThis.fetch(`${input.baseUrl.replace(/\/+$/u, "")}${input.path}`, { + method: input.method, + headers: { + Authorization: `Bot ${input.botToken}`, + "User-Agent": "DiscordBot (t3-discord-bot, 0.0.0)", + }, + }); + if (!response.ok && response.status !== 204) { + const body = await response.text().catch(() => ""); + throw new Error( + `Discord API ${input.method} ${input.path} failed (${response.status}): ${body}`, + ); + } +} + +export const ensureChannelInfoPin = (input: { + readonly channelId: string; + readonly workspaceRoot: string; + readonly providers: ReadonlyArray; + readonly projectDefaultModelSelection?: ModelSelection | null; + readonly botConfig: DiscordBotConfig; +}) => + Effect.gen(function* () { + const discordConfig = yield* DiscordConfig.DiscordConfig; + const botToken = Redacted.value(discordConfig.token); + const baseUrl = discordConfig.rest.baseUrl; + const githubUrl = yield* Effect.tryPromise({ + try: () => resolveGitHubUrlForWorkspace(input.workspaceRoot), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => null)); + const supportedProviders = input.providers + .filter((provider) => provider.models.length > 0) + .toSorted((left, right) => String(left.instanceId).localeCompare(String(right.instanceId))); + const availableProviders = input.providers.filter( + (provider) => + provider.enabled && + provider.installed && + provider.availability !== "unavailable" && + provider.models.length > 0, + ); + const defaultModelSelection = + availableProviders.length === 0 + ? null + : preferredModelSelection({ + config: input.botConfig, + providers: availableProviders, + projectDefault: input.projectDefaultModelSelection ?? null, + }); + const desiredContent = renderChannelInfoPin({ + githubUrl, + workspaceRoot: input.workspaceRoot, + providers: supportedProviders, + defaultModelSelection, + }); + + const pinned = yield* Effect.tryPromise({ + try: () => + discordApiJson>({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins`, + }), + catch: (cause) => cause, + }); + + const infoPins = pinned.filter((message) => isChannelInfoPin(message.content)); + const existing = infoPins[0] ?? null; + const stale = infoPins.slice(1); + let pinMessageId = existing?.id ?? null; + + if (existing !== null) { + if ((existing.content ?? "") !== desiredContent) { + const updated = yield* Effect.tryPromise({ + try: () => + discordApiJson({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/messages/${existing.id}`, + method: "PATCH", + body: { content: desiredContent }, + }), + catch: (cause) => cause, + }).pipe(Effect.result); + if (Result.isFailure(updated)) { + // Content was historically over 2000 chars; replace the pin instead of failing help. + yield* Effect.logWarning("Channel info pin PATCH failed; recreating pin message", { + channelId: input.channelId, + existingMessageId: existing.id, + contentLength: desiredContent.length, + error: String(updated.failure), + }); + const created = yield* Effect.tryPromise({ + try: () => + discordApiJson<{ readonly id: string }>({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/messages`, + method: "POST", + body: { content: desiredContent }, + }), + catch: (cause) => cause, + }); + pinMessageId = created.id; + yield* Effect.tryPromise({ + try: () => + discordApiVoid({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins/${created.id}`, + method: "PUT", + }), + catch: (cause) => cause, + }); + yield* Effect.tryPromise({ + try: () => + discordApiVoid({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins/${existing.id}`, + method: "DELETE", + }), + catch: (cause) => cause, + }).pipe(Effect.catch(() => Effect.void)); + } + } + } else { + const created = yield* Effect.tryPromise({ + try: () => + discordApiJson<{ readonly id: string }>({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/messages`, + method: "POST", + body: { content: desiredContent }, + }), + catch: (cause) => cause, + }); + pinMessageId = created.id; + yield* Effect.tryPromise({ + try: () => + discordApiVoid({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins/${created.id}`, + method: "PUT", + }), + catch: (cause) => cause, + }); + } + + for (const message of stale) { + if (message.id === pinMessageId) continue; + yield* Effect.tryPromise({ + try: () => + discordApiVoid({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins/${message.id}`, + method: "DELETE", + }), + catch: (cause) => cause, + }).pipe(Effect.catch(() => Effect.void)); + } + + return { + channelId: input.channelId, + messageId: pinMessageId ?? "", + } satisfies ChannelInfoPinMessageRef; + }); diff --git a/apps/discord-bot/src/features/DiscordDelivery.test.ts b/apps/discord-bot/src/features/DiscordDelivery.test.ts new file mode 100644 index 00000000000..c9f6c593812 --- /dev/null +++ b/apps/discord-bot/src/features/DiscordDelivery.test.ts @@ -0,0 +1,610 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + assistantMessagesForDelivery, + beginDeliveryEpoch, + decideAssistantDelivery, + decideHeartbeat, + deliveryTextFromAssistants, + excludeFinalizedAssistants, + initialDeliveryEpochState, + isGrownFinalizedText, + shouldRecreateTip, + type DeliveryEpochState, +} from "./DiscordDelivery.ts"; + +const msg = ( + id: string, + role: "user" | "assistant", + text: string, + turnId: string | null = null, +) => ({ id, role, text, turnId }); + +/** + * Drive idle delivery to finalize. + * Short status lines use settle grace (stream then finalize); substantial answers + * finalize on the first idle snapshot (avoids Working.. under full finals). + */ +const settleFinalize = ( + state: DeliveryEpochState, + input: { + readonly turnId: string | null; + readonly assistants: ReadonlyArray<{ readonly id: string; readonly text: string }>; + readonly messages?: ReadonlyArray<{ + readonly id: string; + readonly role: string; + readonly turnId: string | null; + readonly text: string; + }>; + }, +) => { + const first = decideAssistantDelivery({ + state, + turnId: input.turnId, + turnInProgress: false, + assistants: input.assistants, + ...(input.messages !== undefined ? { messages: input.messages } : {}), + streaming: false, + presentationFull: true, + }); + if (first.intent._tag === "finalize") { + return first; + } + expect(first.intent._tag).toBe("stream"); + expect(first.state.settleReady).toBe(true); + return decideAssistantDelivery({ + state: first.state, + turnId: input.turnId, + turnInProgress: false, + assistants: input.assistants, + ...(input.messages !== undefined ? { messages: input.messages } : {}), + streaming: false, + presentationFull: true, + }); +}; + +describe("assistantMessagesForDelivery", () => { + it("reproduces 2nd-message-after-restart: stale completed latestTurn + new user → empty", () => { + // Turn 1 completed; user 2 arrives; orchestration still reports latestTurn = turn-1. + const messages = [ + msg("u1", "user", "verify bot up to date", "t1"), + msg("a1", "assistant", "Yes — the running bot is up to date.\n\n| Check | Result |", "t1"), + msg("u2", "user", "perfect. Seems better now", null), + ]; + const assistants = assistantMessagesForDelivery({ + messages, + turnId: "t1", + turnInProgress: false, + hasLatestTurn: true, + }); + expect(assistants).toEqual([]); + expect(deliveryTextFromAssistants(assistants, "answer")).toBe(""); + }); + + it("keeps mid-turn pre-steer assistants while the turn is still running", () => { + const messages = [ + msg("u1", "user", "start", "t1"), + msg("a-pre", "assistant", "long findings…", "t1"), + msg("u-steer", "user", "also check X", null), + ]; + const assistants = assistantMessagesForDelivery({ + messages, + turnId: "t1", + turnInProgress: true, + hasLatestTurn: true, + }); + expect(assistants.map((a) => a.id)).toEqual(["a-pre"]); + }); + + it("returns empty when turn id advanced but no assistants yet", () => { + const messages = [ + msg("u1", "user", "first", "t1"), + msg("a1", "assistant", "first answer", "t1"), + msg("u2", "user", "second", "t2"), + ]; + expect( + assistantMessagesForDelivery({ + messages, + turnId: "t2", + turnInProgress: true, + hasLatestTurn: true, + }), + ).toEqual([]); + }); + + it("time-query race: new turnId in progress, new user not in snapshot yet → empty when prior was finalized", () => { + // Production: startTurn advanced latestTurn before the new user message appeared. + // afterLastUser would otherwise return a1 (PR #102 body) and re-stream it. + const messages = [ + msg("u1", "user", "show me what you got", "t1"), + msg("a1", "assistant", "PR #102 is already merged…", "t1"), + ]; + expect( + assistantMessagesForDelivery({ + messages, + turnId: "t2", + turnInProgress: true, + hasLatestTurn: true, + lastFinalizedAssistantId: "a1", + }), + ).toEqual([]); + }); + + it("comment-recover race: new turn in progress, no lastFinalized yet → still empty (no prior final under Working)", () => { + // Production: bridge recovered after a comment; startTurn opened t2 before durable + // lastFinalizedAssistantId was available. Falling back to afterLastUser re-streamed + // the previous final as `_Working.._` + Stop under the full prior answer. + const messages = [ + msg("u1", "user", "rebase and adversarial PR review", "t1"), + msg( + "a1", + "assistant", + "PR #1901 is rebased, green locally, and mergeable…\n\n## Done\n1. Rebased…", + "t1", + ), + ]; + expect( + assistantMessagesForDelivery({ + messages, + turnId: "t2", + turnInProgress: true, + hasLatestTurn: true, + // Intentionally omit lastFinalizedAssistantId — must not leak a1. + }), + ).toEqual([]); + }); + + it("drops multi-bubble prior turn when only the last id was finalized", () => { + const messages = [ + msg("u1", "user", "first", "t1"), + msg("a0", "assistant", "findings…", "t1"), + msg("a1", "assistant", "PR #102 is already merged…", "t1"), + msg("u2", "user", "what's the time", "t2"), + msg("a2", "assistant", "08:51 UTC", "t2"), + ]; + const assistants = assistantMessagesForDelivery({ + messages, + turnId: "t2", + turnInProgress: false, + hasLatestTurn: true, + lastFinalizedAssistantId: "a1", + }); + expect(assistants.map((a) => a.id)).toEqual(["a2"]); + expect(deliveryTextFromAssistants(assistants, "answer")).toBe("08:51 UTC"); + }); + + it("stale lastFinalized outside tip does not block newer after-last-user answer (dead Working tip)", () => { + // Production a3b2b737…: durable lastFinalized pointed at an older bubble that had + // rolled out of the retained tip. Drop-all on missing cursor zeroed deliveryAssistants + // so Discord stayed on `_Working.._` after the agent finished ("Yep — here"). + const messages = [ + msg("u1", "user", "yes proceed, use english copy", "t1"), + msg("a1", "assistant", "Sorry for the lag — finished and pushed. PR #872…", "t1"), + msg("u2", "user", "u there?", "t2"), + msg("a2", "assistant", "Yep — here. What do you need?", "t2"), + ]; + const assistants = assistantMessagesForDelivery({ + messages, + turnId: "t2", + turnInProgress: false, + hasLatestTurn: true, + lastFinalizedAssistantId: "assistant:ancient-not-in-retained-tip:segment:3", + }); + expect(assistants.map((a) => a.id)).toEqual(["a2"]); + expect(deliveryTextFromAssistants(assistants, "answer")).toBe("Yep — here. What do you need?"); + }); + + it("stale lastFinalized outside tip still allows in-progress stream of current turn", () => { + const messages = [ + msg("u1", "user", "implement backoffice.access", "t1"), + msg("a1", "assistant", "Implementing clean migration logic…", "t1"), + ]; + const assistants = assistantMessagesForDelivery({ + messages, + turnId: "t1", + turnInProgress: true, + hasLatestTurn: true, + lastFinalizedAssistantId: "assistant:ancient-not-in-retained-tip:segment:3", + }); + expect(assistants.map((a) => a.id)).toEqual(["a1"]); + }); +}); + +describe("excludeFinalizedAssistants", () => { + it("keeps only assistants after the finalized bubble in message order", () => { + const messages = [ + msg("u1", "user", "x"), + msg("a1", "assistant", "old"), + msg("a2", "assistant", "new"), + ]; + expect( + excludeFinalizedAssistants({ + messages, + assistants: [ + { id: "a1", text: "old" }, + { id: "a2", text: "new" }, + ], + lastFinalizedAssistantId: "a1", + }).map((a) => a.id), + ).toEqual(["a2"]); + }); + + it("keeps retained-window assistants when lastFinalized is outside the window", () => { + // Missing cursor cannot be ordered vs the tip (stale-behind vs ahead). Dropping the + // whole tip deadlocked live Discord threads on Working. Keep non-matching ids; + // reconnect re-seed is owned by DiscordThreadFollower resume-after. + const messages = [ + msg("u-old", "user", "implement sibling threads"), + msg("a-old", "assistant", "Done. PR #156 …"), + ]; + expect( + excludeFinalizedAssistants({ + messages, + assistants: [{ id: "a-old", text: "Done. PR #156 …" }], + lastFinalizedAssistantId: "a-later-not-in-window", + }).map((a) => a.id), + ).toEqual(["a-old"]); + }); + + it("still gates exact lastFinalized id when that id is the only candidate", () => { + // Cursor missing from messages list, but candidate id equals lastFinalized → drop + // unless text grew (growth reopen is covered elsewhere). + expect( + excludeFinalizedAssistants({ + messages: [msg("u1", "user", "x")], + assistants: [{ id: "a1", text: "already posted" }], + lastFinalizedAssistantId: "a1", + lastFinalizedText: "already posted", + }), + ).toEqual([]); + }); + + it("keeps same id when text grew past lastFinalizedText", () => { + expect( + isGrownFinalizedText("Checking PR and CI status now.", "Checking PR and CI status now."), + ).toBe(false); + expect( + isGrownFinalizedText( + "Checking PR and CI status now.\n\nDone and waiting on CI with full details about the PR.", + "Checking PR and CI status now.", + ), + ).toBe(true); + }); +}); + +describe("decideAssistantDelivery epoch FSM", () => { + it("after finalize, further stream/finalize of the same assistant is noop (no final+Working mess)", () => { + let state = beginDeliveryEpoch(initialDeliveryEpochState()); + const assistants = [{ id: "a1", text: "Good to hear.\n\nThe main issues…" }]; + + const fin = settleFinalize(state, { turnId: "t1", assistants }); + expect(fin.intent._tag).toBe("finalize"); + state = fin.state; + expect(state.phase).toBe("finalized"); + + // Same bubble only — must not reopen Working with the body. + const lateStream = decideAssistantDelivery({ + state, + turnId: "t1", + turnInProgress: true, + assistants, + streaming: true, + presentationFull: true, + }); + expect(lateStream.intent).toEqual({ _tag: "noop", reason: "epoch-finalized" }); + + const lateFinal = decideAssistantDelivery({ + state, + turnId: "t1", + turnInProgress: false, + assistants, + streaming: false, + presentationFull: true, + }); + expect(lateFinal.intent).toEqual({ _tag: "noop", reason: "epoch-finalized" }); + }); + + it("settle grace: first idle snapshot streams, second finalizes", () => { + let state = beginDeliveryEpoch(initialDeliveryEpochState()); + const assistants = [{ id: "a1", text: "Checking PR and CI status now." }]; + const first = decideAssistantDelivery({ + state, + turnId: "t1", + turnInProgress: false, + assistants, + streaming: false, + presentationFull: true, + }); + expect(first.intent._tag).toBe("stream"); + expect(first.state.settleReady).toBe(true); + expect(first.state.phase).toBe("streaming"); + + const second = decideAssistantDelivery({ + state: first.state, + turnId: "t1", + turnInProgress: false, + assistants, + streaming: false, + presentationFull: true, + }); + expect(second.intent._tag).toBe("finalize"); + }); + + it("skipSettleGrace finalizes on the first idle snapshot (rehydrate catch-up)", () => { + // Regression: idle-link rehydrate of a completed turn streamed the full answer as + // Working.. · N tool calls and waited for a second snapshot that never arrived. + const state = beginDeliveryEpoch(initialDeliveryEpochState()); + const assistants = [ + { + id: "a1", + text: "PR #1971 ready for review\n\nThis PR extracted pure plans…", + }, + ]; + const decision = decideAssistantDelivery({ + state, + turnId: "t1", + turnInProgress: false, + assistants, + streaming: false, + presentationFull: true, + skipSettleGrace: true, + }); + expect(decision.intent._tag).toBe("finalize"); + if (decision.intent._tag === "finalize") { + expect(decision.intent.text).toContain("PR #1971"); + } + expect(decision.state.phase).toBe("finalized"); + }); + + it("substantial settled answers finalize on first idle snapshot (no Working under full final)", () => { + // Recovery after a comment: previous turn body is huge and settled. Streaming it + // with Working.. + Stop under the answer is not allowed. + const state = beginDeliveryEpoch(initialDeliveryEpochState()); + const assistants = [ + { + id: "a1", + text: [ + "PR #1901 is rebased, green locally, and mergeable (no conflicts).", + "", + "## Done", + "1. Rebased onto latest origin/main (20 commits).", + "2. Conflict resolution in e2e specs (PR-side Engagement fixtures).", + "3. Rebase breakage fixed across PausePicking and API e2e paths.", + "4. pnpm validate:changed green (1,246 tests).", + "5. Pushed (45d366a70).", + ].join("\n"), + }, + ]; + const decision = decideAssistantDelivery({ + state, + turnId: "t1", + turnInProgress: false, + assistants, + streaming: false, + presentationFull: true, + }); + expect(decision.intent._tag).toBe("finalize"); + if (decision.intent._tag === "finalize") { + expect(decision.intent.text).toContain("PR #1901"); + expect(decision.intent.text).not.toMatch(/Working/i); + } + expect(decision.state.phase).toBe("finalized"); + }); + + it("reopens after premature finalize when a later assistant bubble is the real answer", () => { + // Production: finalized "Checking PR and CI status now." (30 chars), then + // "Done and waiting on CI…" never posted because epoch-finalized blocked it. + let state = beginDeliveryEpoch(initialDeliveryEpochState()); + const messages = [ + msg("u1", "user", "where do we stand?", "t1"), + msg("a1", "assistant", "Checking PR and CI status now.", "t1"), + ]; + const status = settleFinalize(state, { + turnId: "t1", + assistants: [{ id: "a1", text: "Checking PR and CI status now." }], + messages, + }); + expect(status.intent._tag).toBe("finalize"); + state = status.state; + expect(state.lastFinalizedAssistantId).toBe("a1"); + + const withAnswer = [ + ...messages, + msg("a2", "assistant", "Done and waiting on CI.\n\n- Fix shipped in code\n- PR open", "t1"), + ]; + const reopened = settleFinalize(state, { + turnId: "t1", + assistants: [ + { id: "a1", text: "Checking PR and CI status now." }, + { + id: "a2", + text: "Done and waiting on CI.\n\n- Fix shipped in code\n- PR open", + }, + ], + messages: withAnswer, + }); + expect(reopened.intent._tag).toBe("finalize"); + if (reopened.intent._tag === "finalize") { + expect(reopened.intent.assistantId).toBe("a2"); + expect(reopened.intent.text).toContain("Done and waiting on CI"); + expect(reopened.intent.text).not.toContain("Checking PR"); + } + expect(reopened.state.lastFinalizedAssistantId).toBe("a2"); + }); + + it("heartbeat is noop after finalize (prevents Working tip under final post)", () => { + let state = beginDeliveryEpoch(initialDeliveryEpochState()); + const fin = settleFinalize(state, { + turnId: "t1", + assistants: [{ id: "a1", text: "Good to hear." }], + }); + state = fin.state; + + expect( + decideHeartbeat({ + state, + turnInProgress: false, + hasOpenTip: false, + }), + ).toEqual({ _tag: "noop", reason: "heartbeat-inactive-phase" }); + + // Even if tip ids were lost and turn still looks running, do not recreate. + expect( + shouldRecreateTip({ + state, + updateFailed: true, + turnInProgress: true, + }), + ).toBe(false); + }); + + it("new epoch after Working ack allows a fresh stream (not prior final text)", () => { + let state = beginDeliveryEpoch(initialDeliveryEpochState()); + const first = settleFinalize(state, { + turnId: "t1", + assistants: [{ id: "a1", text: "Yes — the running bot is up to date." }], + }); + state = first.state; + expect(state.lastFinalizedAssistantId).toBe("a1"); + + // 2nd user message → new Working ack → new epoch. + state = beginDeliveryEpoch(state, { turnId: "t2" }); + expect(state.phase).toBe("awaiting"); + expect(state.streamText).toBe(""); + expect(state.epoch).toBe(2); + + // Stale snapshot still showing a1 while awaiting t2 (hold Working, never re-post). + const hold = decideAssistantDelivery({ + state, + turnId: "t1", // stale + turnInProgress: false, + assistants: [{ id: "a1", text: "Yes — the running bot is up to date." }], + streaming: false, + presentationFull: true, + }); + expect(hold.intent._tag).toBe("hold"); + expect(hold.intent).toMatchObject({ reason: "assistant-already-finalized" }); + + // Real new-turn content. + const stream = decideAssistantDelivery({ + state, + turnId: "t2", + turnInProgress: true, + assistants: [{ id: "a2", text: "Glad it is better." }], + streaming: true, + presentationFull: true, + }); + expect(stream.intent._tag).toBe("stream"); + if (stream.intent._tag === "stream") { + expect(stream.intent.text).toBe("Glad it is better."); + expect(stream.intent.assistantId).toBe("a2"); + } + }); + + it("time-query race: turnInProgress must not re-stream already-finalized prior answer", () => { + // Logs 2026-07-21: hold → stream a1 (PR#102) while turnInProgress for new turn → + // finalize PR#102; real time answer never posted (epoch already finalized). + let state = beginDeliveryEpoch(initialDeliveryEpochState()); + const prior = settleFinalize(state, { + turnId: "t1", + assistants: [{ id: "a1", text: "PR #102 is already merged…" }], + }); + state = beginDeliveryEpoch(prior.state, { turnId: "t2" }); + + const messages = [ + msg("u1", "user", "show me what you got", "t1"), + msg("a1", "assistant", "PR #102 is already merged…", "t1"), + ]; + + // Snapshot before new user lands; only prior assistant available. + const blocked = decideAssistantDelivery({ + state, + turnId: "t2", + turnInProgress: true, + assistants: [{ id: "a1", text: "PR #102 is already merged…" }], + messages, + streaming: true, + presentationFull: true, + }); + expect(blocked.intent._tag).toBe("hold"); + expect(blocked.intent).toMatchObject({ reason: "assistant-already-finalized" }); + expect(blocked.state.phase).not.toBe("finalized"); + + // Real time answer after new user + new assistant. + const withTime = [ + ...messages, + msg("u2", "user", "what's the time", "t2"), + msg("a2", "assistant", "08:51 UTC", "t2"), + ]; + const stream = decideAssistantDelivery({ + state, + turnId: "t2", + turnInProgress: true, + assistants: [{ id: "a2", text: "08:51 UTC" }], + messages: withTime, + streaming: true, + presentationFull: true, + }); + expect(stream.intent._tag).toBe("stream"); + if (stream.intent._tag === "stream") { + expect(stream.intent.text).toBe("08:51 UTC"); + expect(stream.intent.assistantId).toBe("a2"); + } + + const fin = settleFinalize(stream.state, { + turnId: "t2", + assistants: [{ id: "a2", text: "08:51 UTC" }], + messages: withTime, + }); + expect(fin.intent._tag).toBe("finalize"); + if (fin.intent._tag === "finalize") { + expect(fin.intent.text).toBe("08:51 UTC"); + } + }); + + it("awaiting with no assistants holds (Working dots only)", () => { + const state = beginDeliveryEpoch(initialDeliveryEpochState()); + const decision = decideAssistantDelivery({ + state, + turnId: "t2", + turnInProgress: true, + assistants: [], + streaming: true, + presentationFull: true, + }); + expect(decision.intent._tag).toBe("hold"); + + const hb = decideHeartbeat({ + state: decision.state, + turnInProgress: true, + hasOpenTip: true, + }); + expect(hb._tag).toBe("heartbeat"); + if (hb._tag === "heartbeat") { + expect(hb.tipBody).toBe(""); + } + }); + + it("streaming epoch heartbeat may show stream text, never after finalize", () => { + let state = beginDeliveryEpoch(initialDeliveryEpochState()); + const streamed = decideAssistantDelivery({ + state, + turnId: "t1", + turnInProgress: true, + assistants: [{ id: "a1", text: "partial…" }], + streaming: true, + presentationFull: true, + }); + state = streamed.state; + const hb = decideHeartbeat({ + state, + turnInProgress: true, + hasOpenTip: true, + }); + expect(hb).toEqual({ + _tag: "heartbeat", + tipBody: "partial…", + epoch: 1, + }); + }); +}); diff --git a/apps/discord-bot/src/features/DiscordDelivery.ts b/apps/discord-bot/src/features/DiscordDelivery.ts new file mode 100644 index 00000000000..3429cb5fcc3 --- /dev/null +++ b/apps/discord-bot/src/features/DiscordDelivery.ts @@ -0,0 +1,553 @@ +/** + * Structural Discord assistant delivery state machine. + * + * Band-aid flags (seededWorkingAckPending, finalizedTurnId, lastAssistantText) grew into + * conflicting rules. This module is the single source of truth for *whether* Discord may + * stream, finalize, or heartbeat — keyed by a monotonic **epoch** that advances on each + * new user Working ack (and only then). + * + * Phases per epoch: + * awaiting → Working tip exists, no assistant body for this epoch yet + * streaming → tip may show current-turn progress + * finalized → final message posted; **no** further stream/heartbeat until epoch bumps + * (unless a later assistant / grown text reopens — see settle grace) + * + * Effect integration stays in ResponseBridge; this file is pure and fully unit-tested. + */ + +export type DeliveryPhase = "idle" | "awaiting" | "streaming" | "finalized"; + +export type DeliveryEpochState = { + /** Monotonic; advances only on a new user Working ack. */ + readonly epoch: number; + readonly phase: DeliveryPhase; + /** Turn id bound to this epoch once known. */ + readonly turnId: string | null; + /** Assistant id currently (or last) streamed in this epoch. */ + readonly assistantId: string | null; + /** Last text applied to the stream tip this epoch (never used after finalized). */ + readonly streamText: string; + /** + * Assistant id whose final answer was posted for this epoch (or the previous one + * until a new epoch begins). Survives epoch bump as `lastFinalizedAssistantId`. + */ + readonly finalizedAssistantId: string | null; + /** Durable-style memory of the last assistant we successfully finalized (any epoch). */ + readonly lastFinalizedAssistantId: string | null; + /** + * Text of the last successful Discord final (any epoch). Used to reopen when the + * **same** assistant id grows after a premature finalize (status line → full answer). + */ + readonly lastFinalizedText: string | null; + /** + * Settle grace: multi-step agents often flip turn idle for one snapshot between a + * short status bubble and the real answer. Require one settled confirmation before + * finalize so we do not lock the epoch on "Checking PR…" style status lines. + */ + readonly settleReady: boolean; +}; + +export type DeliveryIntent = + | { readonly _tag: "noop"; readonly reason: string } + | { readonly _tag: "hold"; readonly reason: string } + | { + readonly _tag: "stream"; + readonly text: string; + readonly turnId: string | null; + readonly assistantId: string; + readonly epoch: number; + } + | { + readonly _tag: "finalize"; + readonly text: string; + readonly turnId: string | null; + readonly assistantId: string; + readonly epoch: number; + } + | { + readonly _tag: "heartbeat"; + /** Empty string → Working dots only. Never a prior final answer after finalize. */ + readonly tipBody: string; + readonly epoch: number; + }; + +export const initialDeliveryEpochState = ( + seed?: Partial, +): DeliveryEpochState => ({ + epoch: seed?.epoch ?? 0, + phase: seed?.phase ?? "idle", + turnId: seed?.turnId ?? null, + assistantId: seed?.assistantId ?? null, + streamText: seed?.streamText ?? "", + finalizedAssistantId: seed?.finalizedAssistantId ?? null, + lastFinalizedAssistantId: seed?.lastFinalizedAssistantId ?? null, + lastFinalizedText: seed?.lastFinalizedText ?? null, + settleReady: seed?.settleReady ?? false, +}); + +/** + * New user turn (Discord Working ack). Bumps epoch and enters awaiting. + * Clears stream body so heartbeat cannot repaint the previous final answer. + */ +export function beginDeliveryEpoch( + state: DeliveryEpochState, + input?: { readonly turnId?: string | null }, +): DeliveryEpochState { + return { + epoch: state.epoch + 1, + phase: "awaiting", + turnId: input?.turnId ?? null, + assistantId: null, + streamText: "", + finalizedAssistantId: null, + lastFinalizedAssistantId: state.lastFinalizedAssistantId, + lastFinalizedText: state.lastFinalizedText, + settleReady: false, + }; +} + +/** + * Drop assistants at or before the last Discord-finalized bubble. + * + * Race this prevents: new epoch + turnInProgress, but the snapshot still only has + * prior-turn assistants (new user not in messages yet, or forTurn empty → afterLastUser + * falls back to them). Without this filter we re-stream/re-finalize the previous answer + * (e.g. "what's the time" re-posting PR #102). + * + * Multi-bubble prior turns: we only persist the last finalized id, so filtering by + * message order (not just exact id match) drops the whole prior answer set. + * + * Same-id growth: if the finalized bubble's text grew past lastFinalizedText, keep it + * so a premature status finalize can be replaced by the real answer on the same id. + * + * Cursor outside retained window: when `lastFinalizedAssistantId` is set but not present + * in the message list, we **cannot** order the watermark vs the retained tip. + * Treating the whole tip as already delivered (drop-all) deadlocks live threads whose + * durable cursor points at an older bubble that rolled out of the tip — Discord stays + * on `_Working.._` forever (`deliveryAssistants: 0`, `awaiting-first-assistant`). + * + * Missing cursor therefore only gates the exact finalized id (growth reopen); all other + * assistant ids are kept. Reconnect re-post of old finals is handled by the thread + * follower (`resume-after` skips warm re-seed) and by turnId / after-last-user selection + * in `assistantMessagesForDelivery`. + */ +export function excludeFinalizedAssistants< + T extends { readonly id: string; readonly text?: string }, +>(input: { + readonly messages: ReadonlyArray<{ readonly id: string }>; + readonly assistants: ReadonlyArray; + readonly lastFinalizedAssistantId: string | null; + readonly lastFinalizedText?: string | null; +}): ReadonlyArray { + const { messages, assistants, lastFinalizedAssistantId } = input; + const lastFinalizedText = input.lastFinalizedText ?? null; + if (lastFinalizedAssistantId === null || assistants.length === 0) return assistants; + + const finalizedIdx = messages.findIndex((message) => message.id === lastFinalizedAssistantId); + if (finalizedIdx < 0) { + // Cursor not in this tip/candidate list — keep new ids; only gate exact match. + return assistants.filter((assistant) => { + if (assistant.id !== lastFinalizedAssistantId) return true; + return isGrownFinalizedText(assistant.text ?? "", lastFinalizedText); + }); + } + return assistants.filter((assistant) => { + const idx = messages.findIndex((message) => message.id === assistant.id); + if (idx > finalizedIdx) return true; + if (assistant.id === lastFinalizedAssistantId) { + return isGrownFinalizedText(assistant.text ?? "", lastFinalizedText); + } + return false; + }); +} + +/** True when current text is a clear expansion of a prior premature final. */ +export function isGrownFinalizedText( + currentText: string, + lastFinalizedText: string | null, +): boolean { + const current = currentText.trimEnd(); + if (current === "") return false; + if (lastFinalizedText === null) return false; + const prior = lastFinalizedText.trimEnd(); + if (prior === "") return current.length >= 40; + if (current === prior) return false; + // Grown body: longer by a meaningful margin, or prior was a short status prefix. + if (current.length >= Math.max(prior.length + 40, Math.ceil(prior.length * 1.5))) { + return true; + } + if ( + prior.length <= 120 && + current.length > prior.length && + current.startsWith(prior.slice(0, 20)) + ) { + return true; + } + return false; +} + +/** + * Assistants that may appear on Discord for this snapshot. + * + * Structural rules (in order): + * 1. Prefer turnId match when the turn is still running (mid-turn steer keeps pre-steer). + * 2. If a newer **user** message sits after every turnId-matched assistant and the turn + * is **not** running, treat that as the next Discord turn starting with a stale + * latestTurn — return only assistants after that user (usually empty). + * 3. If turnId is set but no assistants match and the turn is **running**, return empty + * (never after-last-user prior bodies — that re-streams the previous final under + * `_Working.._` when a comment recovers the bridge before the new user lands). + * 4. If turnId is set, no assistants match, and the turn is **settled**, prefer + * after-last-user for catch-up finalize (filter via lastFinalized). + * 5. Fall back to after-last-user only when messages lack turn ids entirely. + * 6. Always drop assistants at/before `lastFinalizedAssistantId` in message order + * (unless same-id text grew past lastFinalizedText). + */ +export function assistantMessagesForDelivery(input: { + readonly messages: ReadonlyArray<{ + readonly id: string; + readonly role: string; + readonly turnId: string | null; + readonly text: string; + }>; + readonly turnId: string | null; + readonly turnInProgress: boolean; + readonly hasLatestTurn: boolean; + /** Durable id of the last assistant Discord successfully finalized (any epoch). */ + readonly lastFinalizedAssistantId?: string | null; + readonly lastFinalizedText?: string | null; +}): ReadonlyArray<{ + readonly id: string; + readonly role: string; + readonly turnId: string | null; + readonly text: string; +}> { + const { messages, turnId, turnInProgress, hasLatestTurn } = input; + const lastFinalizedAssistantId = input.lastFinalizedAssistantId ?? null; + const lastFinalizedText = input.lastFinalizedText ?? null; + void hasLatestTurn; + + let lastUserIdx = -1; + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role === "user") { + lastUserIdx = index; + break; + } + } + const afterLastUser = (lastUserIdx >= 0 ? messages.slice(lastUserIdx + 1) : messages).filter( + (message) => message.role === "assistant", + ); + + let selected: ReadonlyArray<{ + readonly id: string; + readonly role: string; + readonly turnId: string | null; + readonly text: string; + }>; + + if (turnId !== null) { + const forTurn = messages.filter( + (message) => message.role === "assistant" && message.turnId === turnId, + ); + if (forTurn.length > 0) { + const lastForTurn = forTurn[forTurn.length - 1]!; + const lastForTurnIdx = messages.findLastIndex((message) => message.id === lastForTurn.id); + // Stale latestTurn after a completed turn: a newer user message means a new Discord + // turn is starting — never re-surface the completed turn's body. + if (lastUserIdx > lastForTurnIdx && !turnInProgress) { + selected = afterLastUser; + } else { + selected = forTurn; + } + } else if (turnInProgress) { + // New turn has no assistants yet. Never fall back to prior-turn afterLastUser + // bodies — that re-streams the previous final under _Working.._ + Stop when a + // comment recovers the bridge before the new user message is in the snapshot + // (or before lastFinalizedAssistantId is known). Hold empty until this turn + // produces its own assistant content. + selected = []; + } else { + // Settled catch-up: after-last-user only (usually the previous answer for finalize). + // Finalized filter below strips already-delivered bubbles. + selected = afterLastUser; + } + } else { + selected = afterLastUser; + } + + return excludeFinalizedAssistants({ + messages, + assistants: selected, + lastFinalizedAssistantId, + lastFinalizedText, + }); +} + +export function deliveryTextFromAssistants( + assistants: ReadonlyArray<{ readonly text: string }>, + mode: "progress" | "answer", +): string { + const texts = assistants + .map((message) => message.text.trimEnd()) + .filter((text) => text.trim() !== ""); + if (texts.length === 0) return ""; + if (mode === "progress" || texts.length === 1) return texts.join("\n\n").trimEnd(); + + // Mirror finalAnswerText: prefer last bubble unless it is a short trailer after Findings. + const last = texts[texts.length - 1]!; + const longest = texts.reduce((a, b) => (a.length >= b.length ? a : b)); + const SHORT_TRAILER_MAX_CHARS = 400; + if ( + longest.length >= 800 && + last.length < SHORT_TRAILER_MAX_CHARS && + last.length < longest.length * 0.35 + ) { + return longest; + } + return last; +} + +/** + * Decide what Discord may do for this thread snapshot. + * + * Hard invariants: + * - Never stream/finalize assistants at/before lastFinalizedAssistantId — even while + * turnInProgress (unless same-id text grew past lastFinalizedText). + * - Heartbeat after finalize is always noop. + * - `finalized` epoch reopens when a **new** assistant appears after lastFinalized, or + * the finalized bubble's text grew into a real answer. + * - Settle grace: first idle snapshot with content streams/holds; second idle snapshot + * finalizes (avoids locking on multi-step status lines). + */ +export function decideAssistantDelivery(input: { + readonly state: DeliveryEpochState; + readonly turnId: string | null; + readonly turnInProgress: boolean; + readonly assistants: ReadonlyArray<{ + readonly id: string; + readonly text: string; + }>; + readonly streaming: boolean; + readonly presentationFull: boolean; + /** + * Full thread message order (optional). When provided with lastFinalizedAssistantId, + * drops every assistant at/before that bubble — not only the exact id. + */ + readonly messages?: ReadonlyArray<{ readonly id: string }>; + /** + * When true, skip the two-snapshot settle grace and finalize on the first idle snapshot. + * Used for rehydrate/catch-up of already-settled turns so Discord does not leave a + * permanent `_Working.. · N tool calls_` tip waiting for a second snapshot that never + * arrives on idle threads. + */ + readonly skipSettleGrace?: boolean; +}): { readonly state: DeliveryEpochState; readonly intent: DeliveryIntent } { + const { turnId, turnInProgress, streaming, presentationFull } = input; + const skipSettleGrace = input.skipSettleGrace === true; + + // Drop already-finalized bubbles first (works for both open and reopened epochs). + const assistants = excludeFinalizedAssistants({ + messages: input.messages ?? input.assistants, + assistants: input.assistants, + lastFinalizedAssistantId: input.state.lastFinalizedAssistantId, + lastFinalizedText: input.state.lastFinalizedText, + }); + + // After a premature finalize, late assistants after lastFinalized reopen delivery. + let state = input.state; + if (state.phase === "finalized") { + if (assistants.length === 0) { + return { + state, + intent: { _tag: "noop", reason: "epoch-finalized" }, + }; + } + // Reopen same epoch so stream/finalize can post the real answer without a new Working ack. + state = { + ...state, + phase: turnInProgress || streaming ? "streaming" : "awaiting", + streamText: "", + assistantId: null, + finalizedAssistantId: null, + settleReady: false, + }; + } + + // In-progress work always resets settle grace. + if (turnInProgress || streaming) { + state = { ...state, settleReady: false }; + } + + const assistantId = assistants.at(-1)?.id ?? null; + const progressText = deliveryTextFromAssistants(assistants, "progress"); + const answerText = deliveryTextFromAssistants(assistants, "answer"); + + // Only already-finalized content was available (filtered out). Hold while the turn + // is running / awaiting; when settled, noop without posting. + if (assistants.length === 0 && input.assistants.length > 0) { + if (turnInProgress || streaming || state.phase === "awaiting") { + return { + state: { + ...state, + phase: state.phase === "idle" ? "awaiting" : state.phase, + turnId: turnId ?? state.turnId, + settleReady: false, + }, + intent: { _tag: "hold", reason: "assistant-already-finalized" }, + }; + } + return { + state, + intent: { _tag: "noop", reason: "assistant-already-finalized" }, + }; + } + + // Awaiting Working: no assistant body for this epoch yet → hold. + if (state.phase === "awaiting" && assistants.length === 0) { + return { + state: { ...state, turnId: turnId ?? state.turnId, settleReady: false }, + intent: { _tag: "hold", reason: "awaiting-first-assistant" }, + }; + } + + if (!presentationFull && streaming) { + return { state, intent: { _tag: "noop", reason: "final-only-suppress-stream" } }; + } + + if (streaming || turnInProgress) { + if (assistants.length === 0) { + return { + state: { + ...state, + phase: state.phase === "idle" ? "awaiting" : state.phase, + turnId: turnId ?? state.turnId, + settleReady: false, + }, + intent: { _tag: "hold", reason: "in-progress-no-assistant-text" }, + }; + } + if (assistantId === null) { + return { state, intent: { _tag: "noop", reason: "missing-assistant-id" } }; + } + const next: DeliveryEpochState = { + ...state, + phase: "streaming", + turnId: turnId ?? state.turnId, + assistantId, + streamText: progressText, + settleReady: false, + }; + return { + state: next, + intent: { + _tag: "stream", + text: progressText, + turnId: next.turnId, + assistantId, + epoch: state.epoch, + }, + }; + } + + // Turn settled. + if (assistants.length === 0 || answerText.trim() === "") { + return { + state, + intent: { _tag: "noop", reason: "settled-without-content" }, + }; + } + if (assistantId === null) { + return { state, intent: { _tag: "noop", reason: "missing-assistant-id" } }; + } + + // Settle grace: first idle snapshot with *short* content keeps streaming the tip body + // and arms settleReady (status lines like "Checking PR…" often grow into a real answer). + // Substantial settled answers finalize immediately — otherwise recovery/rehydrate streams + // the full previous final under `_Working.._` + Stop for one+ snapshots (or forever if a + // new turn starts before the second idle tick). + // Skip grace entirely on rehydrate/catch-up (`skipSettleGrace`) when no second snapshot. + const SETTLE_GRACE_MAX_CHARS = 250; + if ( + !state.settleReady && + !skipSettleGrace && + answerText.trim().length <= SETTLE_GRACE_MAX_CHARS + ) { + const next: DeliveryEpochState = { + ...state, + phase: "streaming", + turnId: turnId ?? state.turnId, + assistantId, + streamText: answerText, + settleReady: true, + }; + return { + state: next, + intent: { + _tag: "stream", + text: answerText, + turnId: next.turnId, + assistantId, + epoch: state.epoch, + }, + }; + } + + const next: DeliveryEpochState = { + ...state, + phase: "finalized", + turnId: turnId ?? state.turnId, + assistantId, + streamText: "", + finalizedAssistantId: assistantId, + lastFinalizedAssistantId: assistantId, + lastFinalizedText: answerText, + settleReady: false, + }; + return { + state: next, + intent: { + _tag: "finalize", + text: answerText, + turnId: next.turnId, + assistantId, + epoch: state.epoch, + }, + }; +} + +/** + * Heartbeat may only pulse while awaiting/streaming for the current epoch. + * Never recreates Working after finalize (that produced final + Working duplicates). + */ +export function decideHeartbeat(input: { + readonly state: DeliveryEpochState; + readonly turnInProgress: boolean; + readonly hasOpenTip: boolean; +}): Extract { + const { state, turnInProgress } = input; + if (state.phase === "finalized" || state.phase === "idle") { + return { _tag: "noop", reason: "heartbeat-inactive-phase" }; + } + if (!turnInProgress && state.phase !== "awaiting" && !state.settleReady) { + return { _tag: "noop", reason: "heartbeat-turn-idle" }; + } + // Awaiting: dots only. Streaming / settle-grace: may show streamText (current epoch only). + const tipBody = state.phase === "awaiting" ? "" : state.streamText; + // Note: the `finalized` phase is already short-circuited to a noop at the top of this + // function, so no post-finalize recreate guard is reachable here. + return { _tag: "heartbeat", tipBody, epoch: state.epoch }; +} + +/** + * Whether a stream tip update failure should create a replacement tip. + * Never after the epoch is finalized. + */ +export function shouldRecreateTip(input: { + readonly state: DeliveryEpochState; + readonly updateFailed: boolean; + readonly turnInProgress: boolean; +}): boolean { + if (!input.updateFailed) return false; + if (!input.turnInProgress && !input.state.settleReady) return false; + return input.state.phase === "awaiting" || input.state.phase === "streaming"; +} diff --git a/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.test.ts b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.test.ts new file mode 100644 index 00000000000..985e31f7da2 --- /dev/null +++ b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.test.ts @@ -0,0 +1,130 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createDiscordQueuedPromptRegistry, + formatSteernowEmptyQueueMessage, + resolveSteernowMessageIds, +} from "./DiscordQueuedPromptRegistry.ts"; + +describe("DiscordQueuedPromptRegistry", () => { + it("remembers entries by Discord message and thread", () => { + const registry = createDiscordQueuedPromptRegistry(); + const entry = { + discordChannelId: "channel-1", + discordMessageId: "discord-1", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-1"), + authorUserId: "user-1", + }; + registry.remember(entry); + expect(registry.getByDiscordMessageId("discord-1")).toEqual(entry); + expect(registry.listForThread(ThreadId.make("thread-1"))).toEqual([entry]); + }); + + it("forgets by Discord message id and by T3 message id", () => { + const registry = createDiscordQueuedPromptRegistry(); + registry.remember({ + discordChannelId: "channel-1", + discordMessageId: "discord-1", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-1"), + authorUserId: null, + }); + registry.remember({ + discordChannelId: "channel-1", + discordMessageId: "discord-2", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-2"), + authorUserId: null, + }); + + expect(registry.forgetDiscordMessage("discord-1")?.t3MessageId).toBe("msg-1"); + expect(registry.getByDiscordMessageId("discord-1")).toBeNull(); + expect(registry.listForThread(ThreadId.make("thread-1"))).toHaveLength(1); + + expect( + registry.forgetT3Message(ThreadId.make("thread-1"), MessageId.make("msg-2")) + ?.discordMessageId, + ).toBe("discord-2"); + expect(registry.listForThread(ThreadId.make("thread-1"))).toEqual([]); + }); + + it("clears an entire thread", () => { + const registry = createDiscordQueuedPromptRegistry(); + registry.remember({ + discordChannelId: "channel-1", + discordMessageId: "discord-1", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-1"), + authorUserId: null, + }); + const cleared = registry.clearThread(ThreadId.make("thread-1")); + expect(cleared).toHaveLength(1); + expect(registry.listForThread(ThreadId.make("thread-1"))).toEqual([]); + }); +}); + +describe("resolveSteernowMessageIds", () => { + it("prefers the server queue when present", () => { + const result = resolveSteernowMessageIds({ + serverQueued: [ + { messageId: MessageId.make("server-1") }, + { messageId: MessageId.make("server-2") }, + ], + localPending: [{ t3MessageId: MessageId.make("local-1") }], + detailLoaded: true, + }); + expect(result).toEqual({ + messageIds: [MessageId.make("server-1"), MessageId.make("server-2")], + source: "server", + snapshotMissing: false, + }); + }); + + it("falls back to the local registry when the server queue is empty", () => { + const result = resolveSteernowMessageIds({ + serverQueued: [], + localPending: [ + { t3MessageId: MessageId.make("local-1") }, + { t3MessageId: MessageId.make("local-1") }, + { t3MessageId: MessageId.make("local-2") }, + ], + detailLoaded: false, + }); + expect(result).toEqual({ + messageIds: [MessageId.make("local-1"), MessageId.make("local-2")], + source: "local", + snapshotMissing: true, + }); + }); + + it("reports empty with snapshotMissing when both sources are empty and detail failed", () => { + expect( + resolveSteernowMessageIds({ + serverQueued: [], + localPending: [], + detailLoaded: false, + }), + ).toEqual({ + messageIds: [], + source: "empty", + snapshotMissing: true, + }); + }); +}); + +describe("formatSteernowEmptyQueueMessage", () => { + it("mentions steer path when the queue is truly empty", () => { + const text = formatSteernowEmptyQueueMessage({ snapshotMissing: false }); + expect(text).toContain("Nothing is queued"); + expect(text).toContain("/agent steer"); + expect(text).toContain("/agent steernow"); + }); + + it("mentions snapshot failure when the HTTP detail is missing", () => { + const text = formatSteernowEmptyQueueMessage({ snapshotMissing: true }); + expect(text).toContain("thread snapshot unavailable"); + expect(text).toContain("/agent steer"); + }); +}); diff --git a/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.ts b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.ts new file mode 100644 index 00000000000..af358b04ce7 --- /dev/null +++ b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.ts @@ -0,0 +1,163 @@ +import type { MessageId, ThreadId } from "@t3tools/contracts"; + +/** + * Tracks Discord user messages that are parked in the server follow-up queue so + * we can badge them (📥), remove on delete, and flush via /omegent steernow. + * + * In-memory only: process restart drops badges (server queue remains authoritative). + */ +export type PendingQueuedPrompt = { + readonly discordChannelId: string; + readonly discordMessageId: string; + readonly t3ThreadId: ThreadId; + readonly t3MessageId: MessageId; + readonly authorUserId: string | null; +}; + +/** Discord unicode used as the "queued" badge on the user's message. */ +export const QUEUED_PROMPT_REACTION_EMOJI = "📥"; + +/** + * Resolve which message ids `/omegent steernow` should inject. + * + * Prefer the server queue (authoritative after restart). Fall back to the + * in-memory Discord registry when the HTTP snapshot is missing/lagging so a + * just-queued mid-turn follow-up is still steerable. + */ +export function resolveSteernowMessageIds(input: { + readonly serverQueued: ReadonlyArray<{ readonly messageId: MessageId }>; + readonly localPending: ReadonlyArray<{ readonly t3MessageId: MessageId }>; + /** True when `fetchThreadDetail` returned a snapshot (even if queue empty). */ + readonly detailLoaded: boolean; +}): { + readonly messageIds: ReadonlyArray; + readonly source: "server" | "local" | "empty"; + readonly snapshotMissing: boolean; +} { + if (input.serverQueued.length > 0) { + // Dedupe while preserving server order. + const seen = new Set(); + const messageIds: MessageId[] = []; + for (const entry of input.serverQueued) { + const key = String(entry.messageId); + if (seen.has(key)) continue; + seen.add(key); + messageIds.push(entry.messageId); + } + return { messageIds, source: "server", snapshotMissing: false }; + } + + if (input.localPending.length > 0) { + const seen = new Set(); + const messageIds: MessageId[] = []; + for (const entry of input.localPending) { + const key = String(entry.t3MessageId); + if (seen.has(key)) continue; + seen.add(key); + messageIds.push(entry.t3MessageId); + } + return { + messageIds, + source: "local", + snapshotMissing: !input.detailLoaded, + }; + } + + return { + messageIds: [], + source: "empty", + snapshotMissing: !input.detailLoaded, + }; +} + +/** User-facing reply when steernow has nothing to inject. */ +export function formatSteernowEmptyQueueMessage(input: { + readonly snapshotMissing: boolean; +}): string { + if (input.snapshotMissing) { + return [ + "Could not load the server queue (thread snapshot unavailable), and nothing is parked in this bot process.", + "Use `/agent steer prompt:…` (or `@Omegent --steer …`) to inject mid-turn, then try `/agent steernow` again if you park follow-ups.", + ].join(" "); + } + return [ + "Nothing is queued on this thread.", + "Mid-turn follow-ups park with 📥 by default — then `/agent steernow` flushes them.", + "To inject immediately, use `/agent steer prompt:…` or `@Omegent --steer …`.", + ].join(" "); +} + +export function createDiscordQueuedPromptRegistry() { + const byDiscordMessageId = new Map(); + const byT3ThreadId = new Map>(); + + const remember = (entry: PendingQueuedPrompt): void => { + byDiscordMessageId.set(entry.discordMessageId, entry); + const key = String(entry.t3ThreadId); + const set = byT3ThreadId.get(key) ?? new Set(); + set.add(entry.discordMessageId); + byT3ThreadId.set(key, set); + }; + + const forgetDiscordMessage = (discordMessageId: string): PendingQueuedPrompt | null => { + const entry = byDiscordMessageId.get(discordMessageId); + if (entry === undefined) return null; + byDiscordMessageId.delete(discordMessageId); + const key = String(entry.t3ThreadId); + const set = byT3ThreadId.get(key); + if (set !== undefined) { + set.delete(discordMessageId); + if (set.size === 0) byT3ThreadId.delete(key); + } + return entry; + }; + + const forgetT3Message = ( + t3ThreadId: ThreadId, + t3MessageId: MessageId, + ): PendingQueuedPrompt | null => { + const key = String(t3ThreadId); + const set = byT3ThreadId.get(key); + if (set === undefined) return null; + for (const discordMessageId of set) { + const entry = byDiscordMessageId.get(discordMessageId); + if (entry !== undefined && entry.t3MessageId === t3MessageId) { + return forgetDiscordMessage(discordMessageId); + } + } + return null; + }; + + const listForThread = (t3ThreadId: ThreadId): ReadonlyArray => { + const set = byT3ThreadId.get(String(t3ThreadId)); + if (set === undefined) return []; + const out: PendingQueuedPrompt[] = []; + for (const discordMessageId of set) { + const entry = byDiscordMessageId.get(discordMessageId); + if (entry !== undefined) out.push(entry); + } + return out; + }; + + const getByDiscordMessageId = (discordMessageId: string): PendingQueuedPrompt | null => + byDiscordMessageId.get(discordMessageId) ?? null; + + const clearThread = (t3ThreadId: ThreadId): ReadonlyArray => { + const listed = listForThread(t3ThreadId); + for (const entry of listed) { + forgetDiscordMessage(entry.discordMessageId); + } + return listed; + }; + + return { + remember, + forgetDiscordMessage, + forgetT3Message, + listForThread, + getByDiscordMessageId, + clearThread, + }; +} + +export type DiscordQueuedPromptRegistry = ReturnType; diff --git a/apps/discord-bot/src/features/DiscordThreadTurnCoordinator.test.ts b/apps/discord-bot/src/features/DiscordThreadTurnCoordinator.test.ts new file mode 100644 index 00000000000..5049aab1b97 --- /dev/null +++ b/apps/discord-bot/src/features/DiscordThreadTurnCoordinator.test.ts @@ -0,0 +1,107 @@ +import { it } from "@effect/vitest"; +import { describe, expect } from "vite-plus/test"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { makeDiscordThreadTurnCoordinator } from "./DiscordThreadTurnCoordinator.ts"; + +describe("DiscordThreadTurnCoordinator", () => { + it.live("prevents overlapping first-link creation for the same Discord thread", () => + Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* makeDiscordThreadTurnCoordinator; + const linked = yield* Ref.make(false); + const createdCount = yield* Ref.make(0); + const firstStarted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const secondFinished = yield* Deferred.make(); + + const handleMention = (isFirst: boolean) => + coordinator.withLock( + "discord-thread-1", + Effect.gen(function* () { + if (yield* Ref.get(linked)) return; + yield* Ref.update(createdCount, (count) => count + 1); + if (isFirst) { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(releaseFirst); + } + yield* Ref.set(linked, true); + }), + ); + + yield* Effect.forkChild(handleMention(true)); + yield* Deferred.await(firstStarted); + yield* Effect.forkChild( + handleMention(false).pipe( + Effect.ensuring(Deferred.succeed(secondFinished, undefined).pipe(Effect.orDie)), + ), + ); + yield* Effect.yieldNow; + + expect(yield* Ref.get(createdCount)).toBe(1); + + yield* Deferred.succeed(releaseFirst, undefined); + yield* Deferred.await(secondFinished); + + expect(yield* Ref.get(createdCount)).toBe(1); + }), + ), + ); + + it.live("does not serialize unrelated Discord threads", () => + Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* makeDiscordThreadTurnCoordinator; + const firstStarted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const secondRan = yield* Ref.make(false); + + yield* Effect.forkChild( + coordinator.withLock( + "discord-thread-1", + Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + ), + ), + ); + yield* Deferred.await(firstStarted); + + yield* coordinator.withLock("discord-thread-2", Ref.set(secondRan, true)); + expect(yield* Ref.get(secondRan)).toBe(true); + + yield* Deferred.succeed(releaseFirst, undefined); + }), + ), + ); + + it.live("can reject work immediately when a thread lock is occupied", () => + Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* makeDiscordThreadTurnCoordinator; + const firstStarted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + + yield* Effect.forkChild( + coordinator.withLock( + "discord-thread-1", + Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + ), + ), + ); + yield* Deferred.await(firstStarted); + + const result = yield* coordinator.tryWithLock( + "discord-thread-1", + Effect.succeed("unexpected"), + ); + expect(Option.isNone(result)).toBe(true); + + yield* Deferred.succeed(releaseFirst, undefined); + }), + ), + ); +}); diff --git a/apps/discord-bot/src/features/DiscordThreadTurnCoordinator.ts b/apps/discord-bot/src/features/DiscordThreadTurnCoordinator.ts new file mode 100644 index 00000000000..8e603eb6123 --- /dev/null +++ b/apps/discord-bot/src/features/DiscordThreadTurnCoordinator.ts @@ -0,0 +1,45 @@ +import * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +export interface DiscordThreadTurnCoordinator { + readonly withLock: ( + discordThreadId: string, + effect: Effect.Effect, + ) => Effect.Effect; + readonly tryWithLock: ( + discordThreadId: string, + effect: Effect.Effect, + ) => Effect.Effect, E, R>; +} + +/** + * Serializes mention handling per Discord thread. In particular, a follow-up + * mention must not race the first mention between link lookup and persistence. + */ +export const makeDiscordThreadTurnCoordinator = Effect.gen(function* () { + const locksRef = yield* Ref.make>(new Map()); + + const getLock = (discordThreadId: string) => + Effect.gen(function* () { + const existing = (yield* Ref.get(locksRef)).get(discordThreadId); + if (existing !== undefined) return existing; + + const created = yield* Semaphore.make(1); + return yield* Ref.modify(locksRef, (locks) => { + const current = locks.get(discordThreadId); + if (current !== undefined) return [current, locks] as const; + const next = new Map(locks); + next.set(discordThreadId, created); + return [created, next] as const; + }); + }); + + return { + withLock: (discordThreadId, effect) => + Effect.flatMap(getLock(discordThreadId), (lock) => lock.withPermit(effect)), + tryWithLock: (discordThreadId, effect) => + Effect.flatMap(getLock(discordThreadId), (lock) => lock.withPermitsIfAvailable(1)(effect)), + } satisfies DiscordThreadTurnCoordinator; +}); diff --git a/apps/discord-bot/src/features/LinkedTurnRouter.ts b/apps/discord-bot/src/features/LinkedTurnRouter.ts new file mode 100644 index 00000000000..4119f50da30 --- /dev/null +++ b/apps/discord-bot/src/features/LinkedTurnRouter.ts @@ -0,0 +1,312 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off globalDate:off globalErrorInEffectFailure:off outdatedApi:off +import type { UploadChatAttachment } from "@t3tools/contracts"; +import { DiscordREST } from "dfx"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; + +import type { DiscordBotConfig } from "../config.ts"; +import { workingMessageFields } from "../presentation/messages.ts"; +import { + buildFirstTurnPrompt, + looksLikeSentryContext, + type DiscordMessageLike, +} from "../presentation/threadContext.ts"; +import { ProjectAliasStore } from "../projectAliases.ts"; +import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; +import { T3Session } from "../t3/T3Session.ts"; +import { discordSourceHint } from "../t3/sourceHint.ts"; +import { bridgeThreadToDiscord } from "./ResponseBridge.ts"; + +export interface LinkedTurnFlags { + readonly provider?: string | undefined; + readonly model?: string | undefined; + readonly base?: string | undefined; + readonly local?: boolean | undefined; + readonly plan?: boolean | undefined; +} + +export interface LinkedTurnSource { + readonly sourceKind: "discord" | "teams"; + readonly sourceThreadId: string; +} + +export interface LinkedTurnPromptContext { + readonly kind: "discord"; + readonly topic: string | null | undefined; + readonly mentionMessage?: DiscordMessageLike | undefined; + readonly starter?: DiscordMessageLike | null | undefined; +} + +export interface LinkedTurnInput { + readonly source: LinkedTurnSource; + readonly discordThreadId: string; + readonly discordParentChannelId: string; + readonly discordGuildId: string; + readonly projectShortName: string; + readonly prompt: string; + readonly flags: LinkedTurnFlags; + readonly stickyModelOnContinue?: boolean | undefined; + readonly attachments?: ReadonlyArray | undefined; + readonly announceLines: ReadonlyArray; + readonly promptContext?: + | LinkedTurnPromptContext + | { + readonly kind: "raw"; + readonly value: string; + } + | undefined; +} + +export interface TransportNeutralLinkedTurnInput { + readonly source: LinkedTurnSource; + /** Stable delivery/conversation id. Stored in the legacy discordThreadId field for v2 links. */ + readonly externalConversationId: string; + readonly externalParentId: string; + readonly externalTenantId: string; + readonly projectShortName: string; + readonly prompt: string; + readonly flags: LinkedTurnFlags; + readonly stickyModelOnContinue?: boolean | undefined; + readonly attachments?: ReadonlyArray | undefined; + readonly promptContext?: LinkedTurnInput["promptContext"]; +} + +/** + * Shared start/continue path for Discord mentions and Teams intake. + * Ported from aaaomega/t3code-pvt#1 and adapted to the current ThreadLinkStore + bridge APIs. + */ +export const resolveProjectFromShortName = Effect.fn("resolveProjectFromShortName")(function* ( + projectShortName: string, +) { + const aliases = yield* ProjectAliasStore; + const t3 = yield* T3Session; + + const alias = aliases.resolve(projectShortName); + if (alias === null) { + return yield* Effect.fail( + `Unknown project alias '${projectShortName}'. Add it to the bot aliases file (T3_PROJECT_ALIASES_PATH).` as const, + ); + } + const project = yield* t3.findProjectByWorkspaceRoot(alias.workspaceRoot); + if (project === null) { + return yield* Effect.fail( + `No T3 project registered at ${alias.workspaceRoot} (alias '${projectShortName}'). Add the project in T3 first.` as const, + ); + } + + return { alias, project }; +}); + +/** + * Transport-neutral T3 start/continue path. Discord and Teams own their presentation and + * delivery lifecycle, while this function owns project resolution, model selection, T3 + * orchestration, and the durable source-conversation link. + */ +export const startOrContinueT3Turn = Effect.fn("startOrContinueT3Turn")(function* ( + config: DiscordBotConfig, + input: TransportNeutralLinkedTurnInput, +) { + const t3 = yield* T3Session; + const links = yield* ThreadLinkStore; + const attachments = input.attachments ?? []; + const hasExplicitModelFlags = + input.flags.provider !== undefined || input.flags.model !== undefined; + const resolved = yield* resolveProjectFromShortName(input.projectShortName); + const existing = yield* links.getBySourceThread( + input.source.sourceKind, + input.source.sourceThreadId, + ); + + if (existing !== null) { + const continueModelSelection = + input.stickyModelOnContinue === true && !hasExplicitModelFlags + ? undefined + : yield* t3.resolveModelSelection({ + project: resolved.project, + ...(input.flags.provider === undefined + ? {} + : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }); + const mentionAuthor = + input.promptContext?.kind === "discord" ? input.promptContext.mentionMessage?.author : null; + const sourceHint = + input.source.sourceKind === "discord" + ? discordSourceHint({ + authorId: mentionAuthor?.id, + authorUsername: mentionAuthor?.username, + guildId: input.externalTenantId, + channelId: input.externalParentId, + discordThreadId: input.externalConversationId, + }) + : undefined; + yield* t3.startTurn({ + threadId: existing.t3ThreadId, + prompt: input.prompt, + ...(continueModelSelection === undefined ? {} : { modelSelection: continueModelSelection }), + ...(input.flags.plan ? { interactionMode: "plan" as const } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + ...(sourceHint === undefined ? {} : { sourceHint }), + }); + return { threadId: existing.t3ThreadId, isNew: false } as const; + } + + const modelSelection = yield* t3.resolveModelSelection({ + project: resolved.project, + ...(input.flags.provider === undefined ? {} : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }); + const enrichedPrompt = resolveInitialPrompt({ + config, + projectShortName: input.projectShortName, + workspaceRoot: resolved.project.workspaceRoot, + prompt: input.prompt, + promptContext: input.promptContext, + guildId: input.externalTenantId, + discordThreadId: input.externalConversationId, + }); + const mentionAuthor = + input.promptContext?.kind === "discord" ? input.promptContext.mentionMessage?.author : null; + const sourceHint = + input.source.sourceKind === "discord" + ? discordSourceHint({ + authorId: mentionAuthor?.id, + authorUsername: mentionAuthor?.username, + guildId: input.externalTenantId, + channelId: input.externalParentId, + discordThreadId: input.externalConversationId, + }) + : undefined; + const { threadId } = yield* t3.startTurnWithWorktree({ + project: resolved.project, + prompt: enrichedPrompt, + modelSelection, + interactionMode: input.flags.plan ? "plan" : "default", + baseBranch: input.flags.base ?? config.t3DefaultBaseBranch, + local: input.flags.local ?? false, + ...(attachments.length > 0 ? { attachments } : {}), + ...(sourceHint === undefined ? {} : { sourceHint }), + }); + + yield* links.put({ + sourceKind: input.source.sourceKind, + sourceThreadId: input.source.sourceThreadId, + discordThreadId: input.externalConversationId, + t3ThreadId: threadId, + projectId: resolved.project.id, + channelId: input.externalParentId, + guildId: input.externalTenantId, + createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + }); + return { threadId, isNew: true } as const; +}); + +function resolveInitialPrompt(input: { + readonly config: DiscordBotConfig; + readonly projectShortName: string; + readonly workspaceRoot: string; + readonly prompt: string; + readonly promptContext: LinkedTurnInput["promptContext"]; + readonly guildId: string; + readonly discordThreadId: string; +}): string { + if (input.promptContext?.kind === "raw") { + return input.promptContext.value; + } + + if (input.promptContext?.kind === "discord") { + const starter = input.promptContext.starter ?? input.promptContext.mentionMessage ?? null; + const sentryBootstrap = looksLikeSentryContext({ + starter, + mentionPrompt: input.prompt, + }); + if (sentryBootstrap || starter !== null) { + return buildFirstTurnPrompt({ + starter, + mentionMessage: input.promptContext.mentionMessage, + mentionPrompt: input.prompt, + projectShortName: input.projectShortName, + workspaceRoot: input.workspaceRoot, + honeycombTraceUrlTemplate: input.config.honeycombTraceUrlTemplate, + jiraBrowseBaseUrl: input.config.jiraBrowseBaseUrl, + guildId: input.guildId, + discordThreadId: input.discordThreadId, + }); + } + } + + return input.prompt; +} + +export const startOrContinueLinkedTurn = Effect.fn("startOrContinueLinkedTurn")(function* ( + config: DiscordBotConfig, + input: LinkedTurnInput, +) { + const rest = yield* DiscordREST; + const links = yield* ThreadLinkStore; + const existing = yield* links.getBySourceThread( + input.source.sourceKind, + input.source.sourceThreadId, + ); + if (existing !== null) { + const workingAckResult = yield* rest + .createMessage(input.discordThreadId, { + ...workingMessageFields("_Working.._", existing.t3ThreadId), + }) + .pipe( + Effect.map((message) => message.id as string), + Effect.tap((messageId) => Effect.logInfo("Posted Working.. ack", { messageId })), + Effect.result, + ); + if (Result.isFailure(workingAckResult)) { + yield* Effect.logError("Failed to post Working.. ack"); + yield* Effect.logError(workingAckResult.failure); + } + const workingAckMessageId = Result.isSuccess(workingAckResult) + ? workingAckResult.success + : null; + + yield* bridgeThreadToDiscord({ + discordChannelId: input.discordThreadId, + t3ThreadId: existing.t3ThreadId, + workingAckMessageId, + }); + } + + const turn = yield* startOrContinueT3Turn(config, { + source: input.source, + externalConversationId: input.discordThreadId, + externalParentId: input.discordParentChannelId, + externalTenantId: input.discordGuildId, + projectShortName: input.projectShortName, + prompt: input.prompt, + flags: input.flags, + ...(input.stickyModelOnContinue === undefined + ? {} + : { stickyModelOnContinue: input.stickyModelOnContinue }), + ...(input.attachments === undefined ? {} : { attachments: input.attachments }), + ...(input.promptContext === undefined ? {} : { promptContext: input.promptContext }), + }); + const threadId = turn.threadId; + + const webLink = + config.webUiBaseUrl === undefined + ? null + : `${config.webUiBaseUrl.replace(/\/$/u, "")}/?thread=${threadId}`; + + if (turn.isNew) { + yield* rest.createMessage(input.discordThreadId, { + content: [...input.announceLines, webLink === null ? null : `Open in Omegent: ${webLink}`] + .filter((line): line is string => line !== null && line.trim().length > 0) + .join("\n"), + }); + } + + yield* bridgeThreadToDiscord({ + discordChannelId: input.discordThreadId, + t3ThreadId: threadId, + }); + + return threadId; +}); diff --git a/apps/discord-bot/src/features/MentionRouter.test.ts b/apps/discord-bot/src/features/MentionRouter.test.ts new file mode 100644 index 00000000000..61ee9b2b1d2 --- /dev/null +++ b/apps/discord-bot/src/features/MentionRouter.test.ts @@ -0,0 +1,203 @@ +// @effect-diagnostics nodeBuiltinImport:off - existence contract reads router source on disk. +import * as NodeFS from "node:fs"; +import { ProjectId, ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createHandledDiscordMessageTracker, + findDiscordLinkForT3Target, + getContinuedConversationModelChangeError, + isIncompleteDiscordLink, + shouldShowThreadBootstrapReaction, +} from "./MentionRouter.ts"; + +const mentionRouterSource = NodeFS.readFileSync( + new URL("./MentionRouter.ts", import.meta.url), + "utf8", +); + +describe("shouldShowThreadBootstrapReaction", () => { + it("marks channel prompts that need a Discord/T3 thread bootstrap", () => { + expect( + shouldShowThreadBootstrapReaction({ + inThread: false, + intentKind: "prompt", + hasPromptOrAttachment: true, + }), + ).toBe(true); + }); + + it("does not mark existing threads, commands, or empty mentions", () => { + expect( + shouldShowThreadBootstrapReaction({ + inThread: true, + intentKind: "prompt", + hasPromptOrAttachment: true, + }), + ).toBe(false); + expect( + shouldShowThreadBootstrapReaction({ + inThread: false, + intentKind: "help", + hasPromptOrAttachment: true, + }), + ).toBe(false); + expect( + shouldShowThreadBootstrapReaction({ + inThread: false, + intentKind: "prompt", + hasPromptOrAttachment: false, + }), + ).toBe(false); + }); + + it("wires 👀 addition to channel intake and removal to initial-pin success", () => { + expect(mentionRouterSource).toContain( + ".addMyMessageReaction(\n pendingReadyReaction.channelId", + ); + expect(mentionRouterSource).toContain( + "Effect.tap(() =>\n input.pendingReadyReaction === undefined", + ); + expect(mentionRouterSource).toContain( + ".deleteMyMessageReaction(\n input.pendingReadyReaction.channelId", + ); + expect(mentionRouterSource).toContain("const discordThread = yield* openOrReuseThread("); + }); +}); + +describe("createHandledDiscordMessageTracker", () => { + it("marks handled messages and evicts the oldest ids beyond the limit", () => { + const tracker = createHandledDiscordMessageTracker(2); + + tracker.mark("message-1"); + tracker.mark("message-2"); + + expect(tracker.has("message-1")).toBe(true); + expect(tracker.has("message-2")).toBe(true); + + tracker.mark("message-3"); + + expect(tracker.has("message-1")).toBe(false); + expect(tracker.has("message-2")).toBe(true); + expect(tracker.has("message-3")).toBe(true); + }); + + it("claims a message id only once so create/update races cannot double-route", () => { + const tracker = createHandledDiscordMessageTracker(8); + + expect(tracker.claim("message-1")).toBe(true); + expect(tracker.claim("message-1")).toBe(false); + expect(tracker.has("message-1")).toBe(true); + + tracker.mark("message-2"); + expect(tracker.claim("message-2")).toBe(false); + }); +}); + +describe("isIncompleteDiscordLink", () => { + it("is incomplete when the thread-info pin was never stored", () => { + expect(isIncompleteDiscordLink({})).toBe(true); + expect(isIncompleteDiscordLink({ infoDiscordMessageId: undefined })).toBe(true); + expect(isIncompleteDiscordLink({ infoDiscordMessageId: "" })).toBe(true); + }); + + it("is complete when a pin message id exists", () => { + expect(isIncompleteDiscordLink({ infoDiscordMessageId: "msg-1" })).toBe(false); + }); +}); + +it("reuses a Discord link for the same canonical worktree", () => { + const linkedThreadId = ThreadId.make("linked-thread"); + const targetThreadId = ThreadId.make("github-thread"); + const link = { + discordThreadId: "discord-thread", + t3ThreadId: linkedThreadId, + projectId: ProjectId.make("project"), + channelId: "channel", + guildId: "guild", + createdAt: "2026-07-19T00:00:00.000Z", + updatedAt: "2026-07-19T00:00:00.000Z", + lastActivityAt: "2026-07-19T00:00:00.000Z", + status: "active" as const, + lastSeenTurnId: null, + lastFinalizedAssistantId: null, + lastThreadSnapshotSequence: null, + lastDeliveredSequence: null, + }; + expect( + findDiscordLinkForT3Target({ + links: [link], + threads: [ + { id: linkedThreadId, worktreePath: "/Worktrees/PR-42/" }, + { id: targetThreadId, worktreePath: "/worktrees/pr-42" }, + ], + target: { id: targetThreadId, worktreePath: "/worktrees/pr-42" }, + }), + ).toBe(link); +}); + +describe("getContinuedConversationModelChangeError", () => { + const providers = [ + { + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), + }, + { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + }, + { + driver: ProviderDriverKind.make("grok"), + instanceId: ProviderInstanceId.make("grok"), + requiresNewThreadForModelChange: true, + }, + ]; + + it("allows switching models mid-conversation within the same provider", () => { + expect( + getContinuedConversationModelChangeError({ + providers, + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.5", + }, + }), + ).toBeNull(); + }); + + it("rejects switching providers mid-conversation", () => { + expect( + getContinuedConversationModelChangeError({ + providers, + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + }), + ).toContain("within the same provider"); + }); + + it("rejects Grok model switches mid-conversation", () => { + expect( + getContinuedConversationModelChangeError({ + providers, + currentModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-other", + }, + }), + ).toContain("does not allow switching models"); + }); +}); diff --git a/apps/discord-bot/src/features/MentionRouter.ts b/apps/discord-bot/src/features/MentionRouter.ts new file mode 100644 index 00000000000..7065c60937e --- /dev/null +++ b/apps/discord-bot/src/features/MentionRouter.ts @@ -0,0 +1,3752 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off globalDate:off globalErrorInEffectFailure:off outdatedApi:off globalFetchInEffect:off +import { + MessageId, + type ModelSelection, + type OrchestrationThread, + type ServerProvider, + type ThreadId, + type UploadChatAttachment, +} from "@t3tools/contracts"; +import { DISCORD_LINK_REQUEST_MARKER } from "@t3tools/shared/providerModelSelection"; +import { Discord, DiscordREST, Ix } from "dfx"; +import { DiscordGateway, InteractionsRegistry } from "dfx/gateway"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import type { DiscordBotConfig } from "../config.ts"; +import { + appendDiscordAttachmentPromptBlock, + ATTACHMENT_ONLY_PROMPT, + downloadDiscordAttachmentsToWorkspace, +} from "../presentation/discordInboundFiles.ts"; +import { + downloadDiscordImagesAsUploadAttachments, + filterDiscordImageAttachments, + IMAGE_ONLY_PROMPT, + type DiscordInboundAttachment, +} from "../presentation/discordInboundImages.ts"; +import { + bridgedTurnTopicResolutionError, + missingProjectBindingMessage, + parseMentionFlags, + parseMentionIntent, + parseTopicShortName, + projectTopicFromParentLookup, + normalizeWorkspacePath, + resolveDiscordFollowUpDelivery, + type ProjectTopicLookup, +} from "../presentation/mentions.ts"; +import { + extractJiraIssueKeysFromDiscordMessage, + mergeJiraIssueKeys, +} from "../presentation/jiraLinks.ts"; +import { extractPullRequestUrlsFromDiscordMessage } from "../presentation/prLinks.ts"; +import { + resolveUniqueT3ThreadIdForWorkItems, + serverWorkItemsPathFromStateSqlite, +} from "../store/ServerWorkItemJoin.ts"; +import { + assignPullRequestAssignees, + formatAssignSlashReply, + resolveAssignGithubLogin, +} from "../presentation/prAssign.ts"; +import { + idleMessageFields, + stripBotMention, + truncateTitle, + turnContinueCustomId, + turnStopCustomId, + workingMessageFields, +} from "../presentation/messages.ts"; +import { + COMPACT_POLL_INTERVAL_MS, + COMPACT_WAIT_TIMEOUT_MS, + extractLatestContextWindowStats, + formatCompactionStatsReply, + hasNewContextCompaction, +} from "../presentation/compactContext.ts"; +import { + formatAskSlashAck, + isThreadTalkSlashAction, + OMEGENT_SLASH_COMMAND, + OMEGENT_SLASH_COMMAND_ALIAS, + OMEGENT_SLASH_COMMAND_NAME, + slashDefer, + slashReply, + threadTalkSlashReply, +} from "../presentation/slashCommands.ts"; +import { extractT3ThreadId } from "../presentation/t3ThreadRef.ts"; +import { + buildDiscordTurnPrompt, + buildFirstTurnPrompt, + looksLikeSentryContext, + type DiscordMessageLike, +} from "../presentation/threadContext.ts"; +import { IdentityMapStore } from "../identityMap.ts"; +import { discordSourceHint } from "../t3/sourceHint.ts"; +import { ProjectAliasStore } from "../projectAliases.ts"; +import { type ThreadLink, ThreadLinkStore } from "../store/ThreadLinkStore.ts"; +import { newMessageId } from "../t3/ids.ts"; +import { T3_STILL_CONNECTING_MESSAGE, T3Session, T3SessionError } from "../t3/T3Session.ts"; +import { formatAlertCause } from "./Alerts.ts"; +import { ensureChannelInfoPin } from "./ChannelInfoPin.ts"; +import { makeDiscordThreadTurnCoordinator } from "./DiscordThreadTurnCoordinator.ts"; +import { + createDiscordQueuedPromptRegistry, + formatSteernowEmptyQueueMessage, + QUEUED_PROMPT_REACTION_EMOJI, + resolveSteernowMessageIds, +} from "./DiscordQueuedPromptRegistry.ts"; +import { BridgeHub } from "./BridgeHub.ts"; +import { bridgeThreadToDiscord, getLiveDiscordBridge } from "./ResponseBridge.ts"; +import { upsertThreadInfoPin } from "./ThreadInfoPin.ts"; +import { + formatUnmentionedDiscordPrompt, + parseThreadTalkCommand, + threadTalkEnabled, +} from "./ThreadTalkPolicy.ts"; + +/** Marker service so the process must acquire the mention router layer. */ +export class DiscordBotRunning extends Context.Service< + DiscordBotRunning, + { readonly botUserId: string } +>()("@t3tools/discord-bot/features/MentionRouter/DiscordBotRunning") {} + +export const THREAD_BOOTSTRAP_REACTION_EMOJI = "👀"; + +export function shouldShowThreadBootstrapReaction(input: { + readonly inThread: boolean; + readonly intentKind: ReturnType["kind"]; + readonly hasPromptOrAttachment: boolean; +}): boolean { + return !input.inThread && input.intentKind === "prompt" && input.hasPromptOrAttachment; +} + +class DiscordImageDownloadError extends Schema.TaggedErrorClass()( + "DiscordImageDownloadError", + { cause: Schema.Defect() }, +) {} + +class DiscordAttachmentStageError extends Schema.TaggedErrorClass()( + "DiscordAttachmentStageError", + { cause: Schema.Defect() }, +) {} + +const MAX_HANDLED_MESSAGE_IDS = 2048; + +export function createHandledDiscordMessageTracker(limit = MAX_HANDLED_MESSAGE_IDS) { + const handled = new Set(); + const insertionOrder: string[] = []; + + const record = (messageId: string): boolean => { + if (handled.has(messageId)) return false; + handled.add(messageId); + insertionOrder.push(messageId); + while (insertionOrder.length > limit) { + const oldest = insertionOrder.shift(); + if (oldest !== undefined) handled.delete(oldest); + } + return true; + }; + + return { + has(messageId: string) { + return handled.has(messageId); + }, + /** Record that a message id was handled (idempotent). */ + mark(messageId: string) { + record(messageId); + }, + /** + * Atomically claim a Discord message id for routing. + * Returns false when another create/update path already claimed it. + */ + claim(messageId: string) { + return record(messageId); + }, + }; +} + +function isThreadChannel(type: number | undefined): boolean { + return type === 10 || type === 11 || type === 12; +} + +function mentionsBotInContent(content: string, botUserId: string): boolean { + return content.includes(`<@${botUserId}>`) || content.includes(`<@!${botUserId}>`); +} + +function mentionsBotInEvent( + event: { + readonly content?: string | null; + readonly mentions?: ReadonlyArray<{ readonly id?: string }> | null; + }, + botUserId: string, +): boolean { + if (mentionsBotInContent(event.content ?? "", botUserId)) return true; + return event.mentions?.some((user) => user.id === botUserId) ?? false; +} + +function discordMessageFromEvent(event: { + readonly id: string; + readonly content?: string | null | undefined; + readonly channel_id?: string | undefined; + readonly author?: + | { + readonly id?: string | undefined; + readonly username?: string | undefined; + readonly global_name?: string | null | undefined; + readonly bot?: boolean | undefined; + } + | undefined; + readonly member?: { readonly nick?: string | null | undefined } | undefined; + readonly embeds?: DiscordMessageLike["embeds"]; + readonly timestamp?: string | undefined; +}): DiscordMessageLike { + return { + id: event.id, + content: event.content, + author: { + id: event.author?.id, + username: event.author?.username, + displayName: + event.member?.nick ?? event.author?.global_name ?? event.author?.username ?? undefined, + bot: event.author?.bot, + }, + embeds: event.embeds, + timestamp: event.timestamp, + ...(typeof event.channel_id === "string" ? { channelId: event.channel_id } : {}), + }; +} + +function hasInterruptibleTurn( + thread: { + readonly latestTurn?: { readonly state?: string | null } | null; + readonly session?: { readonly status?: string | null } | null; + } | null, +): boolean { + return ( + thread?.latestTurn?.state === "running" || + thread?.session?.status === "running" || + thread?.session?.status === "starting" + ); +} + +function discordMessageUrl( + guildId: string | null | undefined, + channelId: string, + messageId: string, +): string { + return `https://discord.com/channels/${guildId ?? "@me"}/${channelId}/${messageId}`; +} + +type DiscordMessagePayload = { + readonly id: string; + readonly content?: string | null | undefined; + readonly channel_id?: string | undefined; + readonly author?: + | { + readonly id?: string | undefined; + readonly username?: string | undefined; + readonly global_name?: string | null | undefined; + readonly bot?: boolean | undefined; + } + | undefined; + readonly member?: { readonly nick?: string | null | undefined } | undefined; + readonly embeds?: DiscordMessageLike["embeds"]; + readonly timestamp?: string | undefined; +}; + +/** + * Resolve the message the user replied to / referenced. + * Gateway often includes `referenced_message` for REPLY; otherwise fetch via REST + * using `message_reference` so Sentry embeds and other context reach the agent. + */ +function resolveReferencedMessage(input: { + readonly event: { + readonly channel_id: string; + readonly guild_id?: string | null | undefined; + readonly referenced_message?: DiscordMessagePayload | null | undefined; + readonly message_reference?: + | { + readonly message_id?: string | null | undefined; + readonly channel_id?: string | null | undefined; + readonly guild_id?: string | null | undefined; + } + | null + | undefined; + }; + readonly rest: { + // Discord REST message shape is wider than DiscordMessagePayload; map at the boundary. + readonly getMessage: ( + channelId: string, + messageId: string, + ) => Effect.Effect; + }; +}): Effect.Effect<{ message: DiscordMessageLike; url: string } | null> { + return Effect.gen(function* () { + const gatewayRef = input.event.referenced_message; + if (gatewayRef !== null && gatewayRef !== undefined && typeof gatewayRef.id === "string") { + const channelId = + typeof gatewayRef.channel_id === "string" + ? gatewayRef.channel_id + : (input.event.message_reference?.channel_id ?? input.event.channel_id); + const guildId = input.event.message_reference?.guild_id ?? input.event.guild_id ?? null; + return { + message: discordMessageFromEvent({ + ...gatewayRef, + channel_id: channelId, + }), + url: discordMessageUrl(guildId, channelId, gatewayRef.id), + }; + } + + const refId = input.event.message_reference?.message_id; + if (refId === null || refId === undefined || refId === "") { + return null; + } + const channelId = input.event.message_reference?.channel_id ?? input.event.channel_id; + const guildId = input.event.message_reference?.guild_id ?? input.event.guild_id ?? null; + const fetched = yield* input.rest.getMessage(channelId, refId).pipe( + Effect.map( + (message): DiscordMessageLike => + discordMessageFromEvent({ + ...message, + channel_id: typeof message.channel_id === "string" ? message.channel_id : channelId, + }), + ), + Effect.catch((error) => + Effect.logWarning("Failed to fetch referenced Discord message", { + channelId, + messageId: refId, + error: String(error), + }).pipe(Effect.as(null as DiscordMessageLike | null)), + ), + ); + if (fetched === null) return null; + return { + message: fetched, + url: discordMessageUrl(guildId, channelId, refId), + }; + }); +} + +function discordChannelUrl(guildId: string | null | undefined, channelId: string): string { + return `https://discord.com/channels/${guildId ?? "@me"}/${channelId}`; +} + +function t3WebThreadUrl(webUiBaseUrl: string | undefined, threadId: string): string | null { + if (webUiBaseUrl === undefined) return null; + return `${webUiBaseUrl.replace(/\/$/u, "")}/?thread=${threadId}`; +} + +function jiraKeysFromMessages( + ...messages: ReadonlyArray< + DiscordMessageLike | null | undefined | { readonly content?: string | null } + > +): ReadonlyArray { + const keys: string[] = []; + for (const message of messages) { + if (message === null || message === undefined) continue; + keys.push(...extractJiraIssueKeysFromDiscordMessage(message)); + } + return keys; +} + +function prUrlsFromMessages( + ...messages: ReadonlyArray< + DiscordMessageLike | null | undefined | { readonly content?: string | null } + > +): ReadonlyArray { + const urls: string[] = []; + for (const message of messages) { + if (message === null || message === undefined) continue; + urls.push(...extractPullRequestUrlsFromDiscordMessage(message)); + } + return urls; +} + +export function findDiscordLinkForT3Target(input: { + readonly links: ReadonlyArray; + readonly threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly worktreePath: string | null; + }>; + readonly target: { readonly id: ThreadId; readonly worktreePath: string | null }; +}): ThreadLink | undefined { + const direct = input.links.find((link) => link.t3ThreadId === input.target.id); + if (direct !== undefined || input.target.worktreePath === null) return direct; + const targetWorktree = normalizeWorkspacePath(input.target.worktreePath); + return input.links.find((link) => { + const linkedThread = input.threads.find((thread) => thread.id === link.t3ThreadId); + return ( + linkedThread?.worktreePath !== null && + linkedThread?.worktreePath !== undefined && + normalizeWorkspacePath(linkedThread.worktreePath) === targetWorktree + ); + }); +} + +/** + * Durable link exists but setup never finished (e.g. slash `/link` died after + * `links.put` before bridge/pin). Re-link / re-attach should complete setup. + * + * Detected via missing thread-info pin id — not snapshot sequence (idle links + * often keep a null sequence until the first live bridge snapshot). + */ +export function isIncompleteDiscordLink(link: { + readonly infoDiscordMessageId?: string | undefined; +}): boolean { + return link.infoDiscordMessageId === undefined || link.infoDiscordMessageId.length === 0; +} + +/** + * `@Omegent` in Discord is ambiguous: it can resolve to the bot USER (`<@id>`) or to the + * app's managed ROLE (`<@&id>`), which render near-identically in the picker. Only the + * user form populates `mentions`, so a role ping used to be silently ignored. + * + * We match ONLY the app's own managed role -- the one Discord auto-creates, identified by + * `tags.bot_id === botUserId`. Matching any role the bot merely belongs to would make it + * respond to every `@engineers` message, which is much worse than the original bug. + */ +function botManagedRoleIdFrom( + roles: ReadonlyArray<{ + readonly id?: string; + readonly tags?: { readonly bot_id?: string | undefined } | null | undefined; + }>, + botUserId: string, +): string | null { + return roles.find((role) => role.tags?.bot_id === botUserId)?.id ?? null; +} + +export function getContinuedConversationModelChangeError(input: { + readonly providers: ReadonlyArray< + Pick + >; + readonly currentModelSelection: ModelSelection; + readonly nextModelSelection: ModelSelection; +}): string | null { + if ( + input.currentModelSelection.instanceId === input.nextModelSelection.instanceId && + input.currentModelSelection.model === input.nextModelSelection.model + ) { + return null; + } + + const currentProvider = input.providers.find( + (provider) => provider.instanceId === input.currentModelSelection.instanceId, + ); + const nextProvider = input.providers.find( + (provider) => provider.instanceId === input.nextModelSelection.instanceId, + ); + + if ( + currentProvider?.driver !== undefined && + nextProvider?.driver !== undefined && + currentProvider.driver !== nextProvider.driver + ) { + return "This Discord conversation can only switch models within the same provider. Start a new Discord thread to switch providers."; + } + + if ( + currentProvider?.requiresNewThreadForModelChange === true || + nextProvider?.requiresNewThreadForModelChange === true + ) { + return "This provider does not allow switching models after the conversation has started. Start a new Discord thread to use that model."; + } + + return null; +} + +type BridgedTurnInput = { + readonly discordThreadId: string; + readonly channelId: string; + readonly guildId: string; + readonly prompt: string; + readonly flags: ReturnType; + readonly topic: string | null | undefined; + /** True when parent-channel topic could not be fetched (Discord outage / API error). */ + readonly parentUnavailable?: boolean; + readonly parentChannelId: string | null; + readonly mentionMessage?: DiscordMessageLike; + /** Message the user replied to when addressing the bot (gateway or REST). */ + readonly referencedMessage?: DiscordMessageLike | null; + readonly referencedMessageUrl?: string; + readonly discordAttachments?: ReadonlyArray; + readonly attachments?: ReadonlyArray; + readonly presentationMode?: "full" | "final-only"; + /** Original channel mention carrying 👀 until the linked thread-info pin is ready. */ + readonly pendingReadyReaction?: { + readonly channelId: string; + readonly messageId: string; + }; +}; + +const make = (botConfig: DiscordBotConfig) => + Effect.gen(function* () { + const gateway = yield* DiscordGateway; + const rest = yield* DiscordREST; + const t3 = yield* T3Session; + const links = yield* ThreadLinkStore; + const aliases = yield* ProjectAliasStore; + const identityMap = yield* IdentityMapStore; + const registry = yield* InteractionsRegistry; + const bridgeHub = yield* BridgeHub; + const turnCoordinator = yield* makeDiscordThreadTurnCoordinator; + const queuedPrompts = createDiscordQueuedPromptRegistry(); + + const markDiscordPromptQueued = (input: { + readonly discordChannelId: string; + readonly discordMessageId: string; + readonly t3ThreadId: ThreadId; + readonly t3MessageId: MessageId; + readonly authorUserId: string | null; + }) => + Effect.gen(function* () { + queuedPrompts.remember(input); + yield* rest + .addMyMessageReaction( + input.discordChannelId, + input.discordMessageId, + QUEUED_PROMPT_REACTION_EMOJI, + ) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to add queued badge reaction", { + discordMessageId: input.discordMessageId, + error: String(error), + }), + ), + ); + }); + + const clearQueuedBadge = (entry: { + readonly discordChannelId: string; + readonly discordMessageId: string; + }) => + rest + .deleteMyMessageReaction( + entry.discordChannelId, + entry.discordMessageId, + QUEUED_PROMPT_REACTION_EMOJI, + ) + .pipe(Effect.catch(() => Effect.void)); + + // Mentions use the bot *user* id, not the application id. + const me = yield* rest.getMyUser(); + const botUserId = me.id; + yield* Effect.logInfo("Discord bot identity", { + botUserId, + username: me.username, + }); + + // Prove the sharder is alive (non-empty after READY). + yield* Effect.forkScoped( + Effect.gen(function* () { + yield* Effect.sleep("3 seconds"); + const shards = yield* gateway.shards; + yield* Effect.logInfo("Discord shard status", { + shardCount: shards.size, + }); + }), + ); + + /** + * Retry briefly on transient Discord REST failures (outages, 5xx, rate-limit blips). + * Three attempts with short backoff (~200ms, ~400ms) before treating the parent as unavailable. + */ + const getChannelWithRetry = (channelId: string) => + Effect.gen(function* () { + let lastFailure: unknown; + for (let attempt = 0; attempt < 3; attempt += 1) { + const result = yield* rest.getChannel(channelId).pipe(Effect.result); + if (Result.isSuccess(result)) return result.success; + lastFailure = result.failure; + if (attempt < 2) { + yield* Effect.sleep(`${200 * (attempt + 1)} millis` as const); + } + } + return yield* Effect.fail(lastFailure); + }); + + /** + * Resolve project-binding topic for a channel or thread. + * Retries parent GET; never confuses a failed parent fetch with a missing `t3-*` tag. + */ + const resolveProjectTopic = (channel: { + readonly type?: number | undefined; + readonly parent_id?: string | null | undefined; + readonly topic?: string | null | undefined; + }): Effect.Effect => + Effect.gen(function* () { + const inThread = isThreadChannel(channel.type); + const parentId = + inThread && typeof channel.parent_id === "string" ? channel.parent_id : null; + if (parentId === null) { + return projectTopicFromParentLookup({ + channel, + parentId: null, + parent: null, + }); + } + const parentResult = yield* getChannelWithRetry(parentId).pipe(Effect.result); + if (Result.isFailure(parentResult)) { + const cause = String(parentResult.failure); + yield* Effect.logWarning("Failed to fetch parent channel for project topic", { + parentId, + cause, + }); + return projectTopicFromParentLookup({ + channel, + parentId, + parent: { ok: false, cause }, + }); + } + return projectTopicFromParentLookup({ + channel, + parentId, + parent: { ok: true, channel: parentResult.success }, + }); + }); + + const resolveProjectFromTopic = (topic: string | null | undefined) => + Effect.gen(function* () { + const shortName = parseTopicShortName(topic); + if (shortName === null) { + return yield* Effect.fail("Channel topic has no t3- tag." as const); + } + const alias = aliases.resolve(shortName); + if (alias === null) { + return yield* Effect.fail( + `Unknown project alias '${shortName}'. Add it to the bot aliases file (T3_PROJECT_ALIASES_PATH).` as const, + ); + } + const project = yield* t3.findProjectByWorkspaceRoot(alias.workspaceRoot); + if (project === null) { + const ready = yield* t3.isReady(); + if (!ready) { + return yield* Effect.fail(T3_STILL_CONNECTING_MESSAGE); + } + return yield* Effect.fail( + `No T3 project registered at ${alias.workspaceRoot} (alias '${shortName}'). Add the project in T3 first.` as const, + ); + } + return { shortName, alias, project }; + }); + + const refreshChannelInfoPin = (channelId: string, topic: string | null | undefined) => + Effect.gen(function* () { + const shortName = parseTopicShortName(topic); + if (shortName === null) return null; + const alias = aliases.resolve(shortName); + if (alias === null) return null; + const project = yield* t3.findProjectByWorkspaceRoot(alias.workspaceRoot); + const serverConfig = yield* t3.serverConfig(); + return yield* ensureChannelInfoPin({ + channelId, + workspaceRoot: alias.workspaceRoot, + providers: serverConfig?.providers ?? [], + projectDefaultModelSelection: project?.defaultModelSelection ?? null, + botConfig, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to refresh channel info pin", { + channelId, + shortName, + error: String(error), + }).pipe(Effect.as(null)), + ), + ); + }); + + /** Resolve project binding for slash/mention help with specific failure reasons. */ + const resolveHelpChannelBinding = (channelId: string, topic: string | null | undefined) => + Effect.gen(function* () { + const shortName = parseTopicShortName(topic); + if (shortName === null) { + return { + kind: "no-topic" as const, + }; + } + const alias = aliases.resolve(shortName); + if (alias === null) { + return { + kind: "unknown-alias" as const, + shortName, + }; + } + const project = yield* t3.findProjectByWorkspaceRoot(alias.workspaceRoot); + if (project === null) { + const ready = yield* t3.isReady(); + if (!ready) { + return { + kind: "t3-connecting" as const, + shortName, + workspaceRoot: alias.workspaceRoot, + }; + } + return { + kind: "no-project" as const, + shortName, + workspaceRoot: alias.workspaceRoot, + }; + } + const serverConfig = yield* t3.serverConfig(); + const pin = yield* ensureChannelInfoPin({ + channelId, + workspaceRoot: alias.workspaceRoot, + providers: serverConfig?.providers ?? [], + projectDefaultModelSelection: project.defaultModelSelection ?? null, + botConfig, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to refresh channel info pin", { + channelId, + shortName, + error: String(error), + }).pipe(Effect.as(null)), + ), + ); + if (pin === null) { + return { + kind: "pin-failed" as const, + shortName, + workspaceRoot: alias.workspaceRoot, + }; + } + return { + kind: "ok" as const, + shortName, + pin, + }; + }); + + /** + * Request provider context compact and wait briefly for token stats. + * Always returns a user-facing public reply (stats or error). + */ + const runContextCompact = (t3ThreadId: ThreadId) => + Effect.gen(function* () { + const beforeDetail = yield* t3.fetchThreadDetail(t3ThreadId); + const beforeActivities = beforeDetail?.thread.activities ?? []; + const before = extractLatestContextWindowStats(beforeActivities); + const beforeMarkerId = + before?.activityId ?? + (beforeActivities.length > 0 + ? (beforeActivities[beforeActivities.length - 1]?.id ?? null) + : null); + + yield* t3.compact(t3ThreadId); + + const startedAt = yield* Clock.currentTimeMillis; + let after = before; + let compacted = false; + while ((yield* Clock.currentTimeMillis) - startedAt < COMPACT_WAIT_TIMEOUT_MS) { + yield* Effect.sleep(`${COMPACT_POLL_INTERVAL_MS} millis`); + const detail = yield* t3.fetchThreadDetail(t3ThreadId); + const activities = detail?.thread.activities ?? []; + after = extractLatestContextWindowStats(activities) ?? after; + if ( + hasNewContextCompaction(activities, beforeMarkerId) || + (before !== null && after !== null && after.usedTokens < before.usedTokens) + ) { + compacted = true; + break; + } + } + + return formatCompactionStatsReply({ + before, + after, + compacted, + }); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + formatCompactionStatsReply({ + before: null, + after: null, + compacted: false, + error: error instanceof Error ? error.message : String(error), + }), + ), + ), + ); + + /** + * For public threads created from a message, Discord uses the starter message id + * as the thread id. Prefer parent channel + thread id; fall back to oldest thread msgs. + */ + const loadThreadStarter = (input: { + readonly discordThreadId: string; + readonly parentChannelId: string | null; + /** When the mention itself started the thread, use it as starter. */ + readonly mentionMessage?: DiscordMessageLike; + }) => + Effect.gen(function* () { + if (input.parentChannelId !== null) { + const fromParent = yield* rest + .getMessage(input.parentChannelId, input.discordThreadId) + .pipe( + Effect.map((message): DiscordMessageLike => message), + Effect.orElseSucceed(() => null as DiscordMessageLike | null), + ); + if (fromParent !== null) return fromParent; + } + + const listed = yield* rest + .listMessages(input.discordThreadId, { limit: 5, after: "0" }) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + // listMessages returns newest-first by default; with after:0 we get oldest available. + const oldest = listed.at(-1) ?? listed[0]; + if (oldest !== undefined) return oldest; + + return input.mentionMessage ?? null; + }); + + const startBridgedTurnUnlocked = (input: BridgedTurnInput) => + Effect.gen(function* () { + // Only --provider / --model count as an explicit model change. Bare mentions must + // NOT re-apply bot defaults (codex/gpt-5.4) on continue — Grok (and others) refuse + // mid-thread model switches with "cannot switch models after the conversation has started". + const hasExplicitModelFlags = + input.flags.provider !== undefined || input.flags.model !== undefined; + const attachments = input.attachments ?? []; + const parentUnavailable = input.parentUnavailable === true; + + let existing = yield* links.getByDiscordThreadId(input.discordThreadId); + + // First-time Discord channel: if Jira/PR work items already map to a unique T3 thread + // (Jira webhook create, GitHub bridge, or another Discord link), join that session + // instead of forking a second agent world. + if (existing === null) { + const joinStarter = yield* loadThreadStarter({ + discordThreadId: input.discordThreadId, + parentChannelId: input.parentChannelId, + ...(input.mentionMessage === undefined ? {} : { mentionMessage: input.mentionMessage }), + }); + const joinJiraKeys = jiraKeysFromMessages( + joinStarter, + input.mentionMessage, + input.referencedMessage, + { content: input.prompt }, + ); + const joinPrUrls = prUrlsFromMessages( + joinStarter, + input.mentionMessage, + input.referencedMessage, + { content: input.prompt }, + ); + if (joinJiraKeys.length > 0 || joinPrUrls.length > 0) { + const allLinks = yield* links.list(); + const joinThreadId = resolveUniqueT3ThreadIdForWorkItems({ + jiraIssueKeys: joinJiraKeys, + prUrls: joinPrUrls, + discordLinks: allLinks, + serverWorkItemsPath: serverWorkItemsPathFromStateSqlite(botConfig.stateSqlitePath), + }); + if (joinThreadId !== null) { + const joinShell = yield* t3.getThreadShell(joinThreadId); + if (joinShell !== null) { + yield* Effect.logInfo("Joining existing T3 thread via work-item identity", { + discordThreadId: input.discordThreadId, + t3ThreadId: joinThreadId, + jiraIssueKeys: joinJiraKeys, + prUrls: joinPrUrls, + }); + yield* links.put({ + discordThreadId: input.discordThreadId, + t3ThreadId: joinThreadId, + projectId: joinShell.projectId, + channelId: input.channelId, + guildId: input.guildId, + createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + sentDiscordUserMessageIds: [], + jiraIssueKeys: joinJiraKeys, + prUrls: joinPrUrls, + }); + existing = yield* links.getByDiscordThreadId(input.discordThreadId); + } + } + } + } + + // Topic is preferred; during Discord outages fall back to the project already on the + // Discord↔T3 link so continue turns still work instead of "no t3- tag". + const fromTopic = yield* resolveProjectFromTopic(input.topic).pipe(Effect.result); + const resolved = yield* Effect.gen(function* () { + if (Result.isSuccess(fromTopic)) return fromTopic.success; + + if (existing !== null) { + const project = yield* t3.getProjectShell(existing.projectId); + if (project !== null) { + yield* Effect.logWarning( + "Channel topic unavailable; continuing with project from existing Discord link", + { + discordThreadId: input.discordThreadId, + projectId: existing.projectId, + parentUnavailable, + topicError: fromTopic.failure, + }, + ); + return { + shortName: parseTopicShortName(input.topic) ?? "linked", + alias: { + shortName: parseTopicShortName(input.topic) ?? "linked", + workspaceRoot: project.workspaceRoot, + }, + project, + }; + } + } + + const error = bridgedTurnTopicResolutionError({ + topicError: fromTopic.failure, + parentUnavailable, + hasExistingLink: existing !== null, + recoveredFromLink: false, + }); + return yield* Effect.fail( + (error ?? fromTopic.failure) as typeof fromTopic.failure | string, + ); + }); + + if (existing !== null) { + const rest = yield* DiscordREST; + const currentThread = yield* t3.getThreadShell(existing.t3ThreadId); + const stagedFiles = yield* Effect.tryPromise({ + try: () => + downloadDiscordAttachmentsToWorkspace({ + attachments: input.discordAttachments ?? [], + discordThreadId: input.discordThreadId, + messageId: input.mentionMessage?.id ?? "message", + }), + catch: (cause) => new DiscordAttachmentStageError({ cause }), + }); + const promptWithAttachments = appendDiscordAttachmentPromptBlock({ + prompt: input.prompt, + attachments: stagedFiles.saved, + }); + // Re-inject durable thread Jira keys so later turns (e.g. create PR) still see them. + const turnJiraIssueKeys = mergeJiraIssueKeys( + existing.jiraIssueKeys, + jiraKeysFromMessages(input.mentionMessage, input.referencedMessage, { + content: input.prompt, + }), + ); + // Re-load thread starter so co-author trailers stay available mid-thread. + const continueStarter = yield* loadThreadStarter({ + discordThreadId: input.discordThreadId, + parentChannelId: input.parentChannelId, + ...(input.mentionMessage === undefined ? {} : { mentionMessage: input.mentionMessage }), + }); + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: promptWithAttachments, + requester: input.mentionMessage, + starter: continueStarter, + referencedMessage: input.referencedMessage, + referencedMessageUrl: input.referencedMessageUrl, + jiraIssueKeys: turnJiraIssueKeys, + jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl, + identityPeople: identityMap.list(), + guildId: input.guildId, + discordThreadId: input.discordThreadId, + t3ThreadId: existing.t3ThreadId, + webUiBaseUrl: botConfig.webUiBaseUrl, + }); + if (stagedFiles.skipped.length > 0) { + yield* Effect.logWarning("Skipped some Discord file attachments", { + skipped: stagedFiles.skipped, + }); + } + const turnAlreadyRunning = hasInterruptibleTurn(currentThread); + const liveBridge = yield* getLiveDiscordBridge( + input.discordThreadId, + existing.t3ThreadId, + ); + // Mid-turn follow-up on a live bridge: keep steering via startTurn, but do not + // post a fresh Working.. tip or restart the bridge (that used to freeze old + // stream messages as "stale" and drop mid-turn Discord history). + const reuseLiveBridge = turnAlreadyRunning && liveBridge !== null; + yield* links.touch(input.discordThreadId).pipe(Effect.ignore); + + yield* Effect.logInfo("Resolved existing Discord↔T3 thread link", { + discordThreadId: input.discordThreadId, + t3ThreadId: existing.t3ThreadId, + projectId: existing.projectId, + createdAt: existing.createdAt, + persistedTaskDiscordMessageId: existing.taskDiscordMessageId ?? null, + persistedStreamDiscordMessageIds: existing.streamDiscordMessageIds ?? [], + currentThreadTitle: currentThread?.title ?? null, + currentThreadSessionStatus: currentThread?.session?.status ?? null, + currentThreadTurnState: currentThread?.latestTurn?.state ?? null, + turnAlreadyRunning, + reuseLiveBridge, + }); + yield* Effect.logInfo("Continuing linked T3 thread", { + discordThreadId: input.discordThreadId, + t3ThreadId: existing.t3ThreadId, + explicitModelFlags: hasExplicitModelFlags, + imageAttachments: attachments.length, + stagedAttachments: stagedFiles.saved.length, + turnAlreadyRunning, + reuseLiveBridge, + }); + + // Always post a fresh Working.. tip for interactive continues — including mid-turn + // steers while a live bridge is already streaming. Skipping the ack (old + // reuseLiveBridge path) left Discord editing the *previous* Working+Stop above + // the human message with no visible response to the new mention. + // Live bridge adopts this id: freezes/clears old tip, streams under the new one. + const workingAckMessageId = + input.presentationMode === "final-only" + ? null + : yield* rest + .createMessage(input.discordThreadId, { + ...workingMessageFields("_Working.._", existing.t3ThreadId), + }) + .pipe( + Effect.map((msg) => msg.id as string), + Effect.tap((messageId) => + Effect.logInfo("Posted Working.. ack", { + messageId, + midTurnSteer: reuseLiveBridge, + }), + ), + Effect.result, + Effect.flatMap((result) => { + if (Result.isSuccess(result)) return Effect.succeed(result.success); + return Effect.logError("Failed to post Working.. ack").pipe( + Effect.andThen(Effect.logError(result.failure)), + Effect.as(null), + ); + }), + ); + const pendingDiscordUserMessageId = newMessageId(); + + // Ensure bridge (reuses live fiber for same thread; only restarts when needed). + yield* bridgeThreadToDiscord({ + discordChannelId: input.discordThreadId, + t3ThreadId: existing.t3ThreadId, + workingAckMessageId, + sentDiscordUserMessageIds: [pendingDiscordUserMessageId], + ...(input.presentationMode === undefined + ? {} + : { presentationMode: input.presentationMode }), + }); + yield* Effect.logInfo("Discord bridge ensure-started; dispatching startTurn", { + t3ThreadId: existing.t3ThreadId, + reuseLiveBridge, + }); + + // Omit modelSelection unless the user explicitly asked to switch — startTurn then + // keeps thread.modelSelection (see T3Session.startTurn). + const continueModelSelection = hasExplicitModelFlags + ? yield* t3.resolveModelSelection({ + project: resolved.project, + stickyModelSelection: currentThread?.modelSelection ?? null, + ...(input.flags.provider === undefined + ? {} + : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }) + : undefined; + if (currentThread?.modelSelection !== undefined && continueModelSelection !== undefined) { + const serverConfig = yield* t3.serverConfig(); + const error = getContinuedConversationModelChangeError({ + providers: serverConfig?.providers ?? [], + currentModelSelection: currentThread.modelSelection, + nextModelSelection: continueModelSelection, + }); + if (error !== null) { + return yield* Effect.fail(new T3SessionError(error)); + } + } + + const startedTurn = yield* t3 + .startTurn({ + threadId: existing.t3ThreadId, + prompt, + messageId: pendingDiscordUserMessageId, + ...(continueModelSelection === undefined + ? {} + : { modelSelection: continueModelSelection }), + // Only --plan forces plan mode; bare mentions keep the thread's interaction mode. + ...(input.flags.plan ? { interactionMode: "plan" as const } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + sourceHint: discordSourceHint({ + authorId: input.mentionMessage?.author?.id, + authorUsername: input.mentionMessage?.author?.username, + guildId: input.guildId, + channelId: input.channelId, + discordThreadId: input.discordThreadId, + }), + }) + .pipe( + Effect.tap(({ messageId }) => + Effect.gen(function* () { + yield* links.setSentDiscordUserMessageIds(input.discordThreadId, [ + ...(existing.sentDiscordUserMessageIds ?? []), + messageId, + pendingDiscordUserMessageId, + ]); + // Always feed the real (and optimistic) user message id into the live + // bridge. We seed a placeholder before startTurn so the bridge is ready + // immediately; if T3 returns a different id (or we only updated the + // durable store), the bridge would treat this Discord turn as external + // input, mirror it back, and freeze the Working tip under that post. + const bridge = + liveBridge ?? + (yield* getLiveDiscordBridge(input.discordThreadId, existing.t3ThreadId)); + if (bridge !== null) { + yield* bridge.noteSentUserMessageIds([messageId, pendingDiscordUserMessageId]); + } + }), + ), + ); + // Server parks busy-thread follow-ups. Default Discord policy is **queue** + // (badge with 📥; delete user message to remove; /omegent steernow to flush). + // `--steer` / `/omegent steer` inject immediately after startTurn. + const followUpDelivery = resolveDiscordFollowUpDelivery(input.flags); + const t3MessageId = MessageId.make(startedTurn.messageId); + if (followUpDelivery === "steer" && turnAlreadyRunning) { + const steered = yield* t3 + .steerQueuedMessage({ + threadId: existing.t3ThreadId, + messageId: t3MessageId, + }) + .pipe( + Effect.tap(() => + Effect.logInfo("Steered mid-turn Discord follow-up into active turn", { + t3ThreadId: existing.t3ThreadId, + messageId: startedTurn.messageId, + }), + ), + Effect.as(true), + Effect.catch((error) => + Effect.logWarning( + "Steer after startTurn failed; message may remain server-queued", + { + t3ThreadId: existing.t3ThreadId, + messageId: startedTurn.messageId, + error: String(error), + }, + ).pipe(Effect.as(false)), + ), + ); + // If steer raced pending turn-start (or similar), keep the Discord-side + // registry + 📥 badge so /omegent steernow can flush without waiting on + // a lagging HTTP thread snapshot. + if (!steered) { + const discordMessageId = + typeof input.mentionMessage?.id === "string" ? input.mentionMessage.id : null; + if (discordMessageId !== null) { + const authorUserId = + typeof input.mentionMessage?.author?.id === "string" + ? input.mentionMessage.author.id + : null; + yield* markDiscordPromptQueued({ + discordChannelId: input.discordThreadId, + discordMessageId, + t3ThreadId: existing.t3ThreadId, + t3MessageId, + authorUserId, + }); + } + } + } else if (followUpDelivery === "queue" && turnAlreadyRunning) { + const discordMessageId = + typeof input.mentionMessage?.id === "string" ? input.mentionMessage.id : null; + if (discordMessageId !== null) { + const authorUserId = + typeof input.mentionMessage?.author?.id === "string" + ? input.mentionMessage.author.id + : null; + yield* markDiscordPromptQueued({ + discordChannelId: input.discordThreadId, + discordMessageId, + t3ThreadId: existing.t3ThreadId, + t3MessageId, + authorUserId, + }); + } + } + yield* Effect.logInfo("startTurn dispatched", { + t3ThreadId: existing.t3ThreadId, + messageId: startedTurn.messageId, + followUpDelivery, + turnAlreadyRunning, + modelSelection: continueModelSelection + ? `${continueModelSelection.instanceId}/${continueModelSelection.model}` + : "thread-sticky", + imageAttachments: attachments.length, + stagedAttachments: stagedFiles.saved.length, + }); + + // Refresh pinned thread-info (model/worktree/Open in Omegent + newly mentioned Jira/PR links). + const continueShell = currentThread ?? (yield* t3.getThreadShell(existing.t3ThreadId)); + yield* upsertThreadInfoPin({ + discordThreadId: input.discordThreadId, + t3ThreadId: existing.t3ThreadId, + botConfig, + incomingJiraKeys: jiraKeysFromMessages(input.mentionMessage, input.referencedMessage, { + content: input.prompt, + }), + incomingPrUrls: prUrlsFromMessages(input.mentionMessage, input.referencedMessage, { + content: input.prompt, + }), + modelSelection: continueModelSelection ?? continueShell?.modelSelection ?? null, + worktreePath: continueShell?.worktreePath ?? null, + local: continueShell?.worktreePath === null, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to refresh thread info pin", { + discordThreadId: input.discordThreadId, + error: String(error), + }), + ), + ); + + return existing.t3ThreadId; + } + + // New Discord thread / first link: pick model from flags or bot defaults. + const modelSelection = yield* t3.resolveModelSelection({ + project: resolved.project, + ...(input.flags.provider === undefined + ? {} + : { overrideInstanceId: input.flags.provider }), + ...(input.flags.model === undefined ? {} : { overrideModel: input.flags.model }), + }); + + // First link into this Discord thread: pull starter; Sentry bootstrap only when relevant. + const starter = yield* loadThreadStarter({ + discordThreadId: input.discordThreadId, + parentChannelId: input.parentChannelId, + ...(input.mentionMessage === undefined ? {} : { mentionMessage: input.mentionMessage }), + }); + const stagedFiles = yield* Effect.tryPromise({ + try: () => + downloadDiscordAttachmentsToWorkspace({ + attachments: input.discordAttachments ?? [], + discordThreadId: input.discordThreadId, + messageId: input.mentionMessage?.id ?? "message", + }), + catch: (cause) => new DiscordAttachmentStageError({ cause }), + }); + const prompt = appendDiscordAttachmentPromptBlock({ + prompt: input.prompt, + attachments: stagedFiles.saved, + }); + if (stagedFiles.skipped.length > 0) { + yield* Effect.logWarning("Skipped some Discord file attachments", { + skipped: stagedFiles.skipped, + }); + } + + const sentryBootstrap = looksLikeSentryContext({ + starter, + mentionPrompt: prompt, + referencedMessage: input.referencedMessage, + }); + const firstTurnJiraIssueKeys = jiraKeysFromMessages( + starter, + input.mentionMessage, + input.referencedMessage, + { content: input.prompt }, + ); + const enrichedPrompt = buildFirstTurnPrompt({ + starter, + mentionMessage: input.mentionMessage, + referencedMessage: input.referencedMessage, + referencedMessageUrl: input.referencedMessageUrl, + mentionPrompt: prompt, + projectShortName: resolved.shortName, + workspaceRoot: resolved.project.workspaceRoot, + honeycombTraceUrlTemplate: botConfig.honeycombTraceUrlTemplate, + jiraIssueKeys: firstTurnJiraIssueKeys, + jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl, + identityPeople: identityMap.list(), + guildId: input.guildId, + discordThreadId: input.discordThreadId, + }); + + yield* Effect.logInfo("Creating T3 thread with worktree bootstrap", { + shortName: resolved.shortName, + workspaceRoot: resolved.project.workspaceRoot, + model: `${modelSelection.instanceId}/${modelSelection.model}`, + hasStarterContext: starter !== null, + starterAuthor: starter?.author?.username ?? null, + hasReferencedMessage: input.referencedMessage != null, + referencedMessageId: input.referencedMessage?.id ?? null, + sentryBootstrap, + imageAttachments: attachments.length, + stagedAttachments: stagedFiles.saved.length, + }); + + const { threadId, messageId } = yield* t3.startTurnWithWorktree({ + project: resolved.project, + prompt: enrichedPrompt, + titleSeed: input.prompt, + modelSelection, + interactionMode: input.flags.plan ? "plan" : "default", + baseBranch: input.flags.base ?? botConfig.t3DefaultBaseBranch, + local: input.flags.local, + ...(attachments.length > 0 ? { attachments } : {}), + sourceHint: discordSourceHint({ + authorId: input.mentionMessage?.author?.id, + authorUsername: input.mentionMessage?.author?.username, + guildId: input.guildId, + channelId: input.channelId, + discordThreadId: input.discordThreadId, + }), + }); + yield* links.put({ + discordThreadId: input.discordThreadId, + t3ThreadId: threadId, + projectId: resolved.project.id, + channelId: input.channelId, + guildId: input.guildId, + createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + sentDiscordUserMessageIds: [messageId], + jiraIssueKeys: firstTurnJiraIssueKeys, + prUrls: prUrlsFromMessages(starter, input.mentionMessage, input.referencedMessage, { + content: input.prompt, + }), + }); + yield* Effect.logInfo("Persisted new Discord↔T3 thread link", { + discordThreadId: input.discordThreadId, + t3ThreadId: threadId, + projectId: resolved.project.id, + channelId: input.channelId, + guildId: input.guildId, + }); + + // Post the bare thread-info pin before the first Working tip so Discord shows the + // pinned message first, but skip expensive repo lookups on this latency-sensitive + // path. A richer refresh still runs after the bridge starts. + yield* upsertThreadInfoPin({ + discordThreadId: input.discordThreadId, + t3ThreadId: threadId, + botConfig, + incomingJiraKeys: firstTurnJiraIssueKeys, + incomingPrUrls: prUrlsFromMessages(starter, input.mentionMessage, { + content: input.prompt, + }), + modelSelection, + baseBranchLabel: input.flags.base ?? botConfig.t3DefaultBaseBranch, + local: input.flags.local, + skipChannelRepoLookup: true, + }).pipe( + Effect.tap(() => + input.pendingReadyReaction === undefined + ? Effect.void + : rest + .deleteMyMessageReaction( + input.pendingReadyReaction.channelId, + input.pendingReadyReaction.messageId, + THREAD_BOOTSTRAP_REACTION_EMOJI, + ) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to clear Discord thread bootstrap reaction", { + discordMessageId: input.pendingReadyReaction?.messageId, + error: String(error), + }), + ), + ), + ), + Effect.catch((error) => + Effect.logWarning("Failed to create initial thread info pin", { + discordThreadId: input.discordThreadId, + error: String(error), + }), + ), + ); + + // Critical path for first in-progress stream: Working tip + bridge MUST start + // immediately after the initial pin. Agent work begins at startTurnWithWorktree; + // keep the remaining path unchanged so the seeded Working tip and bridge delivery + // retain their existing reliability on new thread starts. + const workingAckMessageId = + input.presentationMode === "final-only" + ? null + : yield* rest + .createMessage(input.discordThreadId, { + ...workingMessageFields("_Working.._", threadId), + }) + .pipe( + Effect.map((msg) => msg.id as string), + Effect.tap((postedId) => + Effect.logInfo("Posted Working.. ack", { messageId: postedId }), + ), + Effect.result, + Effect.flatMap((result) => { + if (Result.isSuccess(result)) return Effect.succeed(result.success); + return Effect.logError("Failed to post Working.. ack").pipe( + Effect.andThen(Effect.logError(result.failure)), + Effect.as(null), + ); + }), + ); + + yield* bridgeThreadToDiscord({ + discordChannelId: input.discordThreadId, + t3ThreadId: threadId, + workingAckMessageId, + sentDiscordUserMessageIds: [messageId], + ...(input.presentationMode === undefined + ? {} + : { presentationMode: input.presentationMode }), + }); + + // Refresh after bridge start to add any slower enrichment without blocking the + // initial Working tip / stream subscription. + yield* upsertThreadInfoPin({ + discordThreadId: input.discordThreadId, + t3ThreadId: threadId, + botConfig, + incomingJiraKeys: firstTurnJiraIssueKeys, + incomingPrUrls: prUrlsFromMessages(starter, input.mentionMessage, { + content: input.prompt, + }), + modelSelection, + baseBranchLabel: input.flags.base ?? botConfig.t3DefaultBaseBranch, + local: input.flags.local, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to create thread info pin", { + discordThreadId: input.discordThreadId, + error: String(error), + }), + ), + ); + + return threadId; + }); + + const startBridgedTurn = (input: BridgedTurnInput) => + turnCoordinator.withLock(input.discordThreadId, startBridgedTurnUnlocked(input)); + + const waitForStartedTurnToBecomeVisible = Effect.fn( + "MentionRouter.waitForStartedTurnToBecomeVisible", + )(function* (threadId: ThreadId) { + for (let attempt = 0; attempt < 30; attempt += 1) { + const thread = yield* t3.getThreadShell(threadId); + if (hasInterruptibleTurn(thread)) return; + yield* Effect.sleep("100 millis"); + } + yield* Effect.logWarning("Started Discord thread-talk turn was not visible before timeout", { + threadId, + }); + }); + + const reportError = (channelId: string, error: unknown) => { + const raw = error instanceof Error ? error.message : String(error); + const content = + raw === T3_STILL_CONNECTING_MESSAGE || /not connected|still connecting/i.test(raw) + ? T3_STILL_CONNECTING_MESSAGE + : `Could not start T3 turn: ${raw}`; + return rest + .createMessage(channelId, { content }) + .pipe(Effect.catchCause(Effect.logError), Effect.asVoid); + }; + + /** + * dfx InteractionsRegistry runs Ix handlers with a Discord-only context — app + * services like {@link BridgeHub} are not ambient. Provide them explicitly for + * anything that calls `bridgeThreadToDiscord` / `getLiveDiscordBridge`. + */ + const withBridgeHub = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(BridgeHub, bridgeHub)); + + /** + * Background work from slash handlers. Must use `startImmediately: true` — dfx wraps + * handlers in a short-lived Scope, and default forkDetach only schedules on the parent + * dispatcher (often lost when the interaction fiber exits before the task runs). + * Also re-provides BridgeHub (Ix fibers do not inherit the router layer context). + */ + const forkSlashBackground = (effect: Effect.Effect) => + withBridgeHub(effect).pipe(Effect.forkDetach({ startImmediately: true })); + + /** + * Link Discord to an existing T3 thread (no new T3 thread / no new turn). + * Only from a project channel, or from a Discord thread that is not yet linked. + * - If a Discord bridge already exists for that T3 thread, point at it. + * - Else create a Discord thread from the mention (channel) or use the current + * unlinked Discord thread, then persist the link and ensure a bridge. + */ + const replyFields = (replyToMessageId: string | null | undefined) => + replyToMessageId === null || replyToMessageId === undefined + ? {} + : { message_reference: { message_id: replyToMessageId } }; + + /** + * Link Discord to an existing T3 thread (no new T3 thread / no new turn). + * Only from a project channel, or from a Discord thread that is not yet linked. + * Returns a short user-facing summary (mention path posts it; slash uses it as the ack). + */ + const linkExistingT3Thread = (input: { + readonly t3ThreadId: string; + readonly replyChannelId: string; + /** Null when invoked from a slash command (no source message). */ + readonly replyToMessageId: string | null; + readonly guildId: string; + readonly topic: string | null | undefined; + /** When already inside a Discord thread, link that thread instead of creating one. */ + readonly existingDiscordThreadId: string | null; + readonly parentChannelId: string; + /** + * When true, skip `createMessage` on the reply channel and only return the summary + * (caller responds via interaction ack). Still posts into a newly linked thread. + */ + readonly replyViaReturn?: boolean; + }) => + withBridgeHub( + Effect.gen(function* () { + const postReply = (content: string) => + input.replyViaReturn === true + ? Effect.succeed(content) + : rest + .createMessage(input.replyChannelId, { + content, + ...replyFields(input.replyToMessageId), + }) + .pipe(Effect.as(content)); + + // Refuse inside an already-linked Discord thread (same or different T3 id), + // unless that link is incomplete (failed mid-setup) — then finish setup. + if (input.existingDiscordThreadId !== null) { + const currentLink = yield* links.getByDiscordThreadId(input.existingDiscordThreadId); + if ( + currentLink !== null && + currentLink.status === "active" && + !isIncompleteDiscordLink(currentLink) + ) { + return yield* postReply( + [ + "This Discord thread is already linked to a T3 thread.", + "`link` / `pick-up` / `/omegent link` only works from a project channel, or from a Discord thread that is not linked yet.", + `Current T3 thread: \`${currentLink.t3ThreadId}\``, + ].join("\n"), + ); + } + } + + const t3ThreadId = input.t3ThreadId as ThreadId; + const shellThread = yield* t3.getThreadShell(t3ThreadId); + if (shellThread === null) { + return yield* postReply(`No T3 thread found for \`${input.t3ThreadId}\`.`); + } + + const ensureLinkedBridgeAndPin = (args: { + readonly discordThreadId: string; + readonly titleLine: string; + readonly extraLines?: ReadonlyArray; + }) => + Effect.gen(function* () { + yield* bridgeThreadToDiscord({ + discordChannelId: args.discordThreadId, + t3ThreadId, + }); + yield* upsertThreadInfoPin({ + discordThreadId: args.discordThreadId, + t3ThreadId, + botConfig, + incomingJiraKeys: [], + modelSelection: shellThread.modelSelection, + worktreePath: shellThread.worktreePath, + local: shellThread.worktreePath === null, + titleLine: args.titleLine, + extraLines: [ + "Mention me in this thread to continue the conversation.", + ...(args.extraLines ?? []), + ], + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to create thread info pin for linked thread", { + discordThreadId: args.discordThreadId, + t3ThreadId, + error: String(error), + }), + ), + ); + }); + + const shell = yield* t3.shell(); + const existingLinks = yield* links.list(); + const activeLinks = existingLinks.filter((link) => link.status === "active"); + const existingLink = + activeLinks.find((link) => link.t3ThreadId === t3ThreadId) ?? + (input.existingDiscordThreadId !== null + ? activeLinks.find( + (link) => + link.discordThreadId === input.existingDiscordThreadId && + isIncompleteDiscordLink(link), + ) + : undefined) ?? + findDiscordLinkForT3Target({ + links: activeLinks, + threads: shell?.threads ?? [], + target: { id: t3ThreadId, worktreePath: shellThread.worktreePath }, + }); + + if (existingLink !== undefined) { + if (existingLink.t3ThreadId !== t3ThreadId) { + // Worktree match or incomplete Discord thread pointed at a different T3 id. + yield* links.put({ + ...existingLink, + t3ThreadId, + projectId: shellThread.projectId, + }); + } + const recovering = isIncompleteDiscordLink(existingLink); + const titleLine = recovering + ? `Recovered link to existing T3 thread **${shellThread.title}**.` + : `Linked existing T3 thread **${shellThread.title}**.`; + yield* ensureLinkedBridgeAndPin({ + discordThreadId: existingLink.discordThreadId, + titleLine, + ...(recovering + ? { + extraLines: [ + "Previous link attempt did not finish (bridge/pin); setup is complete now.", + ], + } + : {}), + }); + const jump = discordChannelUrl(existingLink.guildId, existingLink.discordThreadId); + const webLink = t3WebThreadUrl(botConfig.webUiBaseUrl, t3ThreadId); + const content = recovering + ? [ + `Recovered link for **${shellThread.title}** → ${jump}`, + "Bridge + thread-info pin re-attached. Mention `@Omegent` to continue.", + webLink === null ? null : `Open in Omegent: ${webLink}`, + ] + .filter((line): line is string => line !== null) + .join("\n") + : [ + `T3 thread **${shellThread.title}** is already linked here: ${jump}`, + webLink === null ? null : `Open in Omegent: ${webLink}`, + ] + .filter((line): line is string => line !== null) + .join("\n"); + yield* Effect.logInfo( + recovering + ? "Recovered incomplete Discord↔T3 link" + : "Pointed at existing Discord↔T3 link", + { + t3ThreadId, + discordThreadId: existingLink.discordThreadId, + replyChannelId: input.replyChannelId, + recovering, + }, + ); + return yield* postReply(content); + } + + // Prefer binding to the project of the T3 thread; when the channel has a topic, + // require it to resolve to the same project so the Discord home is correct. + const shortName = parseTopicShortName(input.topic); + if (shortName !== null) { + const resolved = yield* resolveProjectFromTopic(input.topic).pipe(Effect.result); + if (Result.isFailure(resolved)) { + return yield* postReply(String(resolved.failure)); + } + if (resolved.success.project.id !== shellThread.projectId) { + return yield* postReply( + [ + `That T3 thread belongs to a different project than this channel.`, + `Thread project id: \`${shellThread.projectId}\``, + `Channel project (\`${resolved.success.shortName}\`): \`${resolved.success.project.id}\``, + ].join("\n"), + ); + } + } else if (input.existingDiscordThreadId === null) { + // Creating a Discord thread from a bare channel requires a project topic + // so the thread lives in the right place. + return yield* postReply( + "This channel is not linked to a T3 project. Set the channel topic to include `t3-` (e.g. `t3-example-project`).", + ); + } + + let discordThreadId = input.existingDiscordThreadId; + if (discordThreadId === null) { + let starterMessageId = input.replyToMessageId; + if (starterMessageId === null) { + const starter = yield* rest.createMessage(input.parentChannelId, { + content: `Linking existing T3 thread **${shellThread.title}**…`, + }); + starterMessageId = starter.id; + } + const discordThread = yield* openOrReuseThread( + input.parentChannelId, + starterMessageId, + truncateTitle(shellThread.title), + ); + discordThreadId = discordThread.id; + } + + yield* links.put({ + discordThreadId, + t3ThreadId, + projectId: shellThread.projectId, + channelId: input.parentChannelId, + guildId: input.guildId, + createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + sentDiscordUserMessageIds: [], + }); + yield* ensureLinkedBridgeAndPin({ + discordThreadId, + titleLine: `Linked existing T3 thread **${shellThread.title}**.`, + }); + + const jump = discordChannelUrl(input.guildId, discordThreadId); + const webLink = t3WebThreadUrl(botConfig.webUiBaseUrl, t3ThreadId); + const modelLine = `${shellThread.modelSelection.instanceId}/${shellThread.modelSelection.model}`; + const linkedContent = [ + `Linked existing T3 thread **${shellThread.title}**.`, + `Model: \`${modelLine}\``, + shellThread.worktreePath === null + ? "Mode: local (no worktree)" + : `Worktree: \`${shellThread.worktreePath}\``, + webLink === null ? null : `Open in Omegent: ${webLink}`, + "Mention `@Omegent` in this thread to continue the conversation.", + ] + .filter((line): line is string => line !== null) + .join("\n"); + + yield* Effect.logInfo("Linked Discord thread to existing T3 thread", { + t3ThreadId, + discordThreadId, + parentChannelId: input.parentChannelId, + createdNewDiscordThread: input.existingDiscordThreadId === null, + }); + + // When the reply channel is the new/linked thread, avoid a second post. + if (input.replyChannelId === discordThreadId) { + return linkedContent; + } + return yield* postReply( + [ + `Linked **${shellThread.title}** → ${jump}`, + webLink === null ? null : `Open in Omegent: ${webLink}`, + ] + .filter((line): line is string => line !== null) + .join("\n"), + ); + }), + ); + + const openOrReuseThread = (channelId: string, messageId: string, name: string) => + rest + .createThreadFromMessage(channelId, messageId, { + name, + auto_archive_duration: 1440, + }) + .pipe( + Effect.catch((error) => + Effect.gen(function* () { + // Another bot (e.g. AutoThreads) may have already opened a thread. + yield* Effect.logWarning( + "createThreadFromMessage failed; looking up existing thread", + { + error: String(error), + messageId, + }, + ); + const message = yield* rest.getMessage(channelId, messageId); + const threadId = + message.thread && typeof message.thread === "object" && "id" in message.thread + ? String((message.thread as { id: string }).id) + : null; + if (threadId === null) { + return yield* Effect.fail(error); + } + return { id: threadId } as { id: string }; + }), + ), + ); + + const watchedDiscordLinkRequests = new Set(); + const completedDiscordLinkRequests = new Set(); + const linkingDiscordThreads = new Set(); + yield* Effect.forkScoped( + Effect.forever( + Effect.gen(function* () { + const shell = yield* t3.shell(); + if (shell === null) { + yield* Effect.sleep("1 second"); + return; + } + for (const thread of shell.threads) { + if (watchedDiscordLinkRequests.has(thread.id)) continue; + watchedDiscordLinkRequests.add(thread.id); + yield* Effect.forkDetach( + t3 + .subscribeThread(thread.id, (detail: OrchestrationThread) => + Effect.gen(function* () { + if ( + completedDiscordLinkRequests.has(detail.id) || + linkingDiscordThreads.has(detail.id) || + !detail.messages.some( + (message) => + message.role === "user" && + message.text.includes(DISCORD_LINK_REQUEST_MARKER), + ) + ) { + return; + } + linkingDiscordThreads.add(detail.id); + yield* Effect.gen(function* () { + const currentShell = yield* t3.shell(); + if (currentShell === null) return; + const target = currentShell.threads.find( + (candidate) => candidate.id === detail.id, + ); + if (target === undefined) return; + const existingLinks = yield* links.list(); + const worktreeLink = findDiscordLinkForT3Target({ + links: existingLinks, + threads: currentShell.threads, + target, + }); + if (worktreeLink !== undefined) { + if (worktreeLink.t3ThreadId !== target.id) { + yield* links.put({ + ...worktreeLink, + t3ThreadId: target.id, + projectId: target.projectId, + }); + } + yield* bridgeThreadToDiscord({ + discordChannelId: worktreeLink.discordThreadId, + t3ThreadId: target.id, + }); + completedDiscordLinkRequests.add(target.id); + yield* Effect.logInfo("Reused Discord thread for GitHub link request", { + t3ThreadId: target.id, + worktreePath: target.worktreePath, + discordThreadId: worktreeLink.discordThreadId, + }); + return; + } + + const project = currentShell.projects.find( + (candidate) => candidate.id === target.projectId, + ); + const alias = + project === undefined + ? undefined + : aliases + .list() + .find( + (candidate) => + normalizeWorkspacePath(candidate.workspaceRoot) === + normalizeWorkspacePath(project.workspaceRoot), + ); + if (!alias?.discordChannelId) { + yield* Effect.logWarning( + "GitHub requested a Discord thread but the project has no Discord channel", + { t3ThreadId: target.id, projectId: target.projectId }, + ); + return; + } + const parent = yield* rest.getChannel(alias.discordChannelId); + const guildId = + "guild_id" in parent && typeof parent.guild_id === "string" + ? parent.guild_id + : ""; + const starter = yield* rest.createMessage(alias.discordChannelId, { + content: `T3 created **${target.title}** from GitHub.`, + }); + const discordThread = yield* openOrReuseThread( + alias.discordChannelId, + starter.id, + truncateTitle(target.title), + ); + yield* links.put({ + discordThreadId: discordThread.id, + t3ThreadId: target.id, + projectId: target.projectId, + channelId: alias.discordChannelId, + guildId, + createdAt: DateTime.formatIso(DateTime.nowUnsafe()), + sentDiscordUserMessageIds: [], + }); + yield* bridgeThreadToDiscord({ + discordChannelId: discordThread.id, + t3ThreadId: target.id, + }); + yield* upsertThreadInfoPin({ + discordThreadId: discordThread.id, + t3ThreadId: target.id, + botConfig, + modelSelection: target.modelSelection, + worktreePath: target.worktreePath, + local: target.worktreePath === null, + titleLine: `T3 created **${target.title}** from GitHub.`, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to create thread info pin for GitHub link", { + discordThreadId: discordThread.id, + t3ThreadId: target.id, + error: String(error), + }), + ), + ); + completedDiscordLinkRequests.add(target.id); + yield* Effect.logInfo("Created Discord thread for GitHub link request", { + t3ThreadId: target.id, + worktreePath: target.worktreePath, + discordThreadId: discordThread.id, + discordChannelId: alias.discordChannelId, + }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + linkingDiscordThreads.delete(detail.id); + }), + ), + ); + }), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logError("Discord link request watcher stopped", { + t3ThreadId: thread.id, + cause, + }), + ), + ), + ); + } + yield* Effect.sleep("1 second"); + }), + ), + ); + + // The app's managed role id, per guild. Resolved lazily and cached: it never changes + // for a given guild, and listGuildRoles on every message would burn rate limit. + const botRoleIdByGuild = new Map(); + const resolveBotRoleId = (guildId: string) => + Effect.gen(function* () { + const cached = botRoleIdByGuild.get(guildId); + if (cached !== undefined) return cached; + const roles = yield* rest.listGuildRoles(guildId).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning(`Could not list roles for guild ${guildId}`, { cause }); + return [] as ReadonlyArray; + }), + ), + ); + const roleId = botManagedRoleIdFrom(roles, botUserId); + botRoleIdByGuild.set(guildId, roleId); + return roleId; + }); + + const handledMessageIds = createHandledDiscordMessageTracker(); + + type GatewayMessageEvent = { + readonly id: string; + readonly channel_id: string; + readonly guild_id?: string | null | undefined; + readonly type?: number | undefined; + readonly content?: string | null | undefined; + readonly mentions?: ReadonlyArray<{ readonly id?: string }> | null | undefined; + readonly mention_roles?: ReadonlyArray | null | undefined; + readonly attachments?: ReadonlyArray | null | undefined; + readonly author?: + | { + readonly id?: string | undefined; + readonly username?: string | undefined; + readonly global_name?: string | null | undefined; + readonly bot?: boolean | undefined; + } + | undefined; + readonly member?: { readonly nick?: string | null | undefined } | undefined; + readonly embeds?: DiscordMessageLike["embeds"]; + readonly timestamp?: string | undefined; + readonly webhook_id?: string | undefined; + readonly referenced_message?: DiscordMessagePayload | null | undefined; + readonly message_reference?: + | { + readonly message_id?: string | null | undefined; + readonly channel_id?: string | null | undefined; + readonly guild_id?: string | null | undefined; + } + | null + | undefined; + }; + + const handleInboundMessage = (rawEvent: GatewayMessageEvent, source: "create" | "update") => + Effect.gen(function* () { + // Cheap pre-filter: never re-enter routing for a message we already claimed. + // (CREATE and UPDATE share this tracker so edits cannot start a second turn.) + if (handledMessageIds.has(rawEvent.id)) { + yield* Effect.logInfo("Ignoring already-handled Discord message", { + source, + channelId: rawEvent.channel_id, + messageId: rawEvent.id, + }); + return; + } + + const event: GatewayMessageEvent | null = + source === "update" + ? yield* rest.getMessage(rawEvent.channel_id, rawEvent.id).pipe( + Effect.map( + (full) => + ({ + ...rawEvent, + ...full, + channel_id: rawEvent.channel_id, + }) as GatewayMessageEvent, + ), + Effect.catch((error) => + Effect.logWarning("Failed to hydrate Discord message update via REST", { + channelId: rawEvent.channel_id, + messageId: rawEvent.id, + error: String(error), + }).pipe(Effect.as(null)), + ), + ) + : rawEvent; + if (event === null) return; + + const mentionIds = (event.mentions ?? []) + .map((user) => user.id) + .filter((id): id is string => typeof id === "string"); + const mentionRoleIds = (event.mention_roles ?? []).filter( + (id): id is string => typeof id === "string", + ); + const guildId = event.guild_id ?? null; + const botRoleId = guildId === null ? null : yield* resolveBotRoleId(guildId); + const mentionsBotRole = botRoleId !== null && mentionRoleIds.includes(botRoleId); + + yield* Effect.logInfo(source === "create" ? "MESSAGE_CREATE" : "MESSAGE_UPDATE", { + channelId: event.channel_id, + messageId: event.id, + guildId, + type: event.type, + authorId: event.author?.id ?? null, + authorBot: event.author?.bot === true, + contentLen: (event.content ?? "").length, + contentPreview: (event.content ?? "").slice(0, 80), + mentionIds, + mentionsBot: mentionIds.includes(botUserId), + mentionRoleIds, + mentionsBotRole, + }); + + if ( + event.author?.bot === true || + event.author?.id === botUserId || + event.webhook_id !== undefined + ) { + return; + } + + let content = event.content ?? ""; + let gatewayAttachments = (event.attachments ?? + []) as ReadonlyArray; + let mentioned = + mentionsBotInEvent( + { + content: event.content ?? null, + mentions: event.mentions ?? null, + }, + botUserId, + ) || + mentionsBotInContent(content, botUserId) || + mentionsBotRole; + + if (!mentioned && content.includes(botUserId)) { + mentioned = true; + } + + const unmentionedLink = mentioned + ? null + : yield* links.getByDiscordThreadId(event.channel_id); + const automaticThreadMessage = !mentioned && threadTalkEnabled(unmentionedLink); + if (!mentioned && !automaticThreadMessage) return; + + if (content.length === 0) { + yield* Effect.logWarning( + "Routable message has empty gateway content; fetching it via REST. Enable Message Content Intent in the Discord Developer Portal.", + { channelId: event.channel_id, messageId: event.id }, + ); + const full = yield* rest.getMessage(event.channel_id, event.id).pipe( + Effect.catch((error) => + Effect.logError("Failed to fetch message content via REST", { + error: String(error), + }).pipe(Effect.as(null)), + ), + ); + if (full !== null) { + content = full.content ?? ""; + if (Array.isArray(full.attachments) && full.attachments.length > 0) { + gatewayAttachments = full.attachments as ReadonlyArray; + } + } + } + + if ( + event.type !== Discord.MessageType.DEFAULT && + event.type !== Discord.MessageType.REPLY + ) { + yield* Effect.logInfo("Ignoring mentioned non-user message type", { + type: event.type, + messageId: event.id, + }); + return; + } + + const referenced = yield* resolveReferencedMessage({ + event: { + channel_id: event.channel_id, + guild_id: event.guild_id, + referenced_message: event.referenced_message ?? null, + message_reference: event.message_reference ?? null, + }, + rest: rest as { + readonly getMessage: ( + channelId: string, + messageId: string, + ) => Effect.Effect; + }, + }); + if (referenced !== null) { + yield* Effect.logInfo("Resolved referenced Discord message for mention", { + messageId: event.id, + referencedMessageId: referenced.message.id, + referencedAuthor: referenced.message.author?.username ?? null, + hasEmbeds: (referenced.message.embeds?.length ?? 0) > 0, + }); + } + + const channel = yield* rest.getChannel(event.channel_id); + const inThread = isThreadChannel(channel.type); + if (automaticThreadMessage && !inThread) return; + + // Claim before image download / turn start. Late marking let MESSAGE_UPDATE race + // MESSAGE_CREATE and double-run prompts (especially noticeable on short edits). + if (!handledMessageIds.claim(event.id)) { + yield* Effect.logInfo("Ignoring concurrent Discord message routing race", { + source, + channelId: event.channel_id, + messageId: event.id, + }); + return; + } + + const body = mentioned ? stripBotMention(content, botUserId, botRoleId) : content.trim(); + const threadTalkCommand = mentioned ? parseThreadTalkCommand(body) : null; + if (threadTalkCommand !== null) { + if (!inThread) { + yield* rest.createMessage(event.channel_id, { + content: "Thread-talk can only be configured inside a linked Discord thread.", + message_reference: { message_id: event.id }, + }); + return; + } + const link = yield* links.getByDiscordThreadId(event.channel_id); + if (link === null) { + yield* rest.createMessage(event.channel_id, { + content: "This Discord thread is not linked to a T3 thread yet.", + message_reference: { message_id: event.id }, + }); + return; + } + if (threadTalkCommand.kind === "set") { + yield* links.setThreadTalkMode( + event.channel_id, + threadTalkCommand.enabled ? "all-messages" : null, + ); + } + const enabled = + threadTalkCommand.kind === "set" ? threadTalkCommand.enabled : threadTalkEnabled(link); + yield* rest.createMessage(event.channel_id, { + content: enabled + ? "Thread-talk is **on**. New human messages in this linked thread will be sent to T3 without requiring a mention." + : "Thread-talk is **off**. Mention `@Omegent` to send a message to Omegent.", + message_reference: { message_id: event.id }, + }); + yield* Effect.logInfo("Discord thread-talk mode resolved", { + discordThreadId: event.channel_id, + t3ThreadId: link.t3ThreadId, + enabled, + command: threadTalkCommand.kind, + actorId: event.author?.id ?? null, + }); + return; + } + + if (automaticThreadMessage && unmentionedLink !== null) { + const threadShell = yield* t3.getThreadShell(unmentionedLink.t3ThreadId); + if (hasInterruptibleTurn(threadShell)) { + yield* rest.createMessage(event.channel_id, { + content: + "Omegent is already working, so this message was not submitted. Wait for the current turn to finish, or mention `@Omegent stop`.", + message_reference: { message_id: event.id }, + }); + return; + } + } + + const intent = mentioned + ? parseMentionIntent(body) + : { + kind: "prompt" as const, + local: false, + plan: false, + prompt: body, + }; + const flags = intent.kind === "prompt" ? intent : parseMentionFlags(body); + const pendingReadyReaction = shouldShowThreadBootstrapReaction({ + inThread, + intentKind: intent.kind, + hasPromptOrAttachment: + (intent.kind === "prompt" && intent.prompt.length > 0) || gatewayAttachments.length > 0, + }) + ? { channelId: event.channel_id, messageId: event.id } + : undefined; + if (pendingReadyReaction !== undefined) { + yield* rest + .addMyMessageReaction( + pendingReadyReaction.channelId, + pendingReadyReaction.messageId, + THREAD_BOOTSTRAP_REACTION_EMOJI, + ) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to add Discord thread bootstrap reaction", { + discordMessageId: event.id, + error: String(error), + }), + ), + ); + } + + const imageCandidates = filterDiscordImageAttachments(gatewayAttachments); + const downloaded = yield* Effect.tryPromise({ + try: () => downloadDiscordImagesAsUploadAttachments(imageCandidates), + catch: (cause) => new DiscordImageDownloadError({ cause }), + }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to download Discord image attachments").pipe( + Effect.andThen(Effect.logError(cause)), + Effect.as({ + uploads: [] as ReadonlyArray, + skipped: imageCandidates.map((a) => ({ + filename: a.filename ?? "image", + reason: "download failed", + })), + }), + ), + ), + ); + if (downloaded.skipped.length > 0) { + yield* Effect.logWarning("Skipped some Discord image attachments", { + skipped: downloaded.skipped, + }); + } + const uploadAttachments = downloaded.uploads; + const hasAnyAttachments = gatewayAttachments.length > 0; + + yield* Effect.logInfo( + automaticThreadMessage ? "Thread-talk message received" : "Bot mention received", + { + channelId: event.channel_id, + messageId: event.id, + guildId: event.guild_id ?? null, + messageType: event.type, + source, + contentPreview: content.slice(0, 120), + discordAttachments: gatewayAttachments.length, + imageAttachments: uploadAttachments.length, + }, + ); + + const prompt = + intent.kind === "prompt" && intent.prompt.length > 0 + ? intent.prompt + : uploadAttachments.length > 0 + ? IMAGE_ONLY_PROMPT + : hasAnyAttachments + ? ATTACHMENT_ONLY_PROMPT + : ""; + if (intent.kind === "prompt" && prompt.length === 0) { + yield* rest.createMessage(event.channel_id, { + content: + content.length === 0 + ? "I saw your mention but message content is empty. Enable **Message Content Intent** for this bot in the Discord Developer Portal, then restart me." + : "Send a prompt after mentioning me (or attach a file). Optional flags: `--model` `--provider` `--base` `--local` `--plan` `--steer` `--queue`. Mid-turn follow-ups **queue** by default (📥); delete your message to cancel, or `/omegent steernow` to inject the queue. Use `--steer` to inject immediately.", + message_reference: { message_id: event.id }, + }); + return; + } + const effectivePrompt = automaticThreadMessage + ? formatUnmentionedDiscordPrompt({ + content: prompt, + authorId: event.author?.id ?? "unknown", + authorName: event.author?.global_name ?? event.author?.username ?? "Unknown user", + messageId: event.id, + }) + : prompt; + const flagsWithPrompt = { ...flags, prompt: effectivePrompt }; + + if (inThread) { + const topicLookup = yield* resolveProjectTopic(channel); + const parentId = + topicLookup.kind === "parent-unavailable" + ? topicLookup.parentChannelId + : (topicLookup.parentChannelId ?? + ("parent_id" in channel && typeof channel.parent_id === "string" + ? channel.parent_id + : null)); + const parentUnavailable = topicLookup.kind === "parent-unavailable"; + const topic = topicLookup.kind === "resolved" ? topicLookup.topic : null; + const pin = parentUnavailable + ? null + : yield* refreshChannelInfoPin(parentId ?? event.channel_id, topic); + + if (intent.kind === "help") { + if (parentUnavailable) { + yield* rest.createMessage(event.channel_id, { + content: missingProjectBindingMessage({ inThread: true, parentUnavailable: true }), + message_reference: { message_id: event.id }, + }); + return; + } + if (pin === null || parseTopicShortName(topic) === null) { + yield* rest.createMessage(event.channel_id, { + content: missingProjectBindingMessage({ + inThread: true, + parentUnavailable: false, + }), + message_reference: { message_id: event.id }, + }); + return; + } + + yield* rest.createMessage(event.channel_id, { + content: `Channel info: ${discordMessageUrl(event.guild_id, pin.channelId, pin.messageId)}`, + message_reference: { message_id: event.id }, + }); + return; + } + + if (intent.kind === "link-thread") { + if (parentUnavailable && parseTopicShortName(topic) === null) { + yield* rest.createMessage(event.channel_id, { + content: missingProjectBindingMessage({ inThread: true, parentUnavailable: true }), + message_reference: { message_id: event.id }, + }); + return; + } + yield* linkExistingT3Thread({ + t3ThreadId: intent.t3ThreadId, + replyChannelId: event.channel_id, + replyToMessageId: event.id, + guildId: event.guild_id ?? "", + topic, + existingDiscordThreadId: event.channel_id, + parentChannelId: parentId ?? event.channel_id, + }).pipe(Effect.catch((error) => reportError(event.channel_id, error))); + return; + } + + if (intent.kind === "interrupt") { + const existing = yield* links.getByDiscordThreadId(event.channel_id); + if (existing === null) { + yield* rest.createMessage(event.channel_id, { + content: + "This Discord thread is not linked to a T3 thread, so there is nothing to stop.", + message_reference: { message_id: event.id }, + }); + return; + } + + const threadShell = yield* t3.getThreadShell(existing.t3ThreadId); + if (!hasInterruptibleTurn(threadShell)) { + yield* rest.createMessage(event.channel_id, { + content: "There is no active turn to stop right now.", + message_reference: { message_id: event.id }, + }); + return; + } + + yield* t3.interrupt(existing.t3ThreadId); + yield* rest.createMessage(event.channel_id, { + content: "Stopping the current turn.", + message_reference: { message_id: event.id }, + }); + return; + } + + if (intent.kind === "compact") { + const existing = yield* links.getByDiscordThreadId(event.channel_id); + if (existing === null) { + yield* rest.createMessage(event.channel_id, { + content: + "This Discord thread is not linked to a T3 thread, so there is nothing to compact.", + message_reference: { message_id: event.id }, + }); + return; + } + + const content = yield* runContextCompact(existing.t3ThreadId); + yield* rest.createMessage(event.channel_id, { + content, + message_reference: { message_id: event.id }, + }); + return; + } + + if (intent.kind === "refresh-indicators") { + const existing = yield* links.getByDiscordThreadId(event.channel_id); + if (existing === null) { + yield* rest.createMessage(event.channel_id, { + content: + "This Discord thread is not linked to a T3 thread, so there are no indicators to refresh.", + message_reference: { message_id: event.id }, + }); + return; + } + + yield* bridgeThreadToDiscord({ + discordChannelId: event.channel_id, + t3ThreadId: existing.t3ThreadId, + mode: "rehydrate", + preferred: true, + lastActivityAt: existing.lastActivityAt, + }).pipe(Effect.catch((error) => reportError(event.channel_id, error))); + + const live = yield* bridgeHub.getLive(event.channel_id, existing.t3ThreadId); + if (live === null) { + yield* rest.createMessage(event.channel_id, { + content: "Could not attach a live bridge for this thread. Try again in a moment.", + message_reference: { message_id: event.id }, + }); + return; + } + + const result = yield* live.refreshThreadIndicators(); + yield* rest.createMessage(event.channel_id, { + content: result.ok + ? `Refreshed thread indicators → **${result.title}**` + : `Refresh-indicators failed: ${result.error}`, + message_reference: { message_id: event.id }, + }); + return; + } + + const mentionMessage = discordMessageFromEvent({ ...event, content }); + const turnInput: BridgedTurnInput = { + discordThreadId: event.channel_id, + channelId: parentId ?? event.channel_id, + guildId: event.guild_id ?? "", + prompt: effectivePrompt, + flags: flagsWithPrompt, + topic, + ...(parentUnavailable ? { parentUnavailable: true } : {}), + parentChannelId: parentId, + mentionMessage, + ...(referenced !== null + ? { + referencedMessage: referenced.message, + referencedMessageUrl: referenced.url, + } + : {}), + ...(gatewayAttachments.length > 0 ? { discordAttachments: gatewayAttachments } : {}), + ...(uploadAttachments.length > 0 ? { attachments: uploadAttachments } : {}), + ...(automaticThreadMessage ? { presentationMode: "final-only" as const } : {}), + }; + if (automaticThreadMessage && unmentionedLink !== null) { + const attempted = yield* turnCoordinator + .tryWithLock( + event.channel_id, + Effect.gen(function* () { + const latest = yield* t3.getThreadShell(unmentionedLink.t3ThreadId); + if (hasInterruptibleTurn(latest)) return "busy" as const; + yield* startBridgedTurnUnlocked(turnInput); + yield* waitForStartedTurnToBecomeVisible(unmentionedLink.t3ThreadId); + return "started" as const; + }), + ) + .pipe( + Effect.catch((error) => + reportError(event.channel_id, error).pipe( + Effect.as(Option.some("failed" as const)), + ), + ), + ); + const outcome = Option.match(attempted, { + onNone: () => "busy" as const, + onSome: (value) => value, + }); + if (outcome === "busy") { + yield* rest.createMessage(event.channel_id, { + content: + "Omegent is already working, so this message was not submitted. Wait for the current turn to finish, or mention `@Omegent stop`.", + message_reference: { message_id: event.id }, + }); + } + return; + } + + yield* startBridgedTurn(turnInput).pipe( + Effect.catch((error) => reportError(event.channel_id, error)), + ); + return; + } + + if (intent.kind === "interrupt") { + yield* rest.createMessage(event.channel_id, { + content: "Stop is only supported inside a linked Discord thread.", + message_reference: { message_id: event.id }, + }); + return; + } + + if (intent.kind === "compact") { + yield* rest.createMessage(event.channel_id, { + content: "Compact is only supported inside a linked Discord thread.", + message_reference: { message_id: event.id }, + }); + return; + } + + if (intent.kind === "refresh-indicators") { + yield* rest.createMessage(event.channel_id, { + content: "Refresh-indicators is only supported inside a linked Discord thread.", + message_reference: { message_id: event.id }, + }); + return; + } + + const topicLookup = yield* resolveProjectTopic(channel); + const parentUnavailable = topicLookup.kind === "parent-unavailable"; + const topic = topicLookup.kind === "resolved" ? topicLookup.topic : null; + const pin = parentUnavailable + ? null + : yield* refreshChannelInfoPin(event.channel_id, topic); + if (intent.kind === "help") { + if (parentUnavailable || pin === null || parseTopicShortName(topic) === null) { + yield* rest.createMessage(event.channel_id, { + content: missingProjectBindingMessage({ + inThread: false, + parentUnavailable, + }), + message_reference: { message_id: event.id }, + }); + return; + } + + yield* rest.createMessage(event.channel_id, { + content: `Channel info: ${discordMessageUrl(event.guild_id, pin.channelId, pin.messageId)}`, + message_reference: { message_id: event.id }, + }); + return; + } + + if (intent.kind === "link-thread") { + if (parentUnavailable && parseTopicShortName(topic) === null) { + yield* rest.createMessage(event.channel_id, { + content: missingProjectBindingMessage({ + inThread: false, + parentUnavailable: true, + }), + message_reference: { message_id: event.id }, + }); + return; + } + yield* linkExistingT3Thread({ + t3ThreadId: intent.t3ThreadId, + replyChannelId: event.channel_id, + replyToMessageId: event.id, + guildId: event.guild_id ?? "", + topic, + existingDiscordThreadId: null, + parentChannelId: event.channel_id, + }).pipe(Effect.catch((error) => reportError(event.channel_id, error))); + return; + } + + if (parentUnavailable || parseTopicShortName(topic) === null) { + if (pendingReadyReaction !== undefined) { + yield* rest + .deleteMyMessageReaction( + pendingReadyReaction.channelId, + pendingReadyReaction.messageId, + THREAD_BOOTSTRAP_REACTION_EMOJI, + ) + .pipe(Effect.catch(() => Effect.void)); + } + yield* rest.createMessage(event.channel_id, { + content: missingProjectBindingMessage({ + inThread: false, + parentUnavailable, + }), + message_reference: { message_id: event.id }, + }); + return; + } + + const discordThread = yield* openOrReuseThread( + event.channel_id, + event.id, + truncateTitle(prompt), + ); + const mentionMessage = discordMessageFromEvent({ ...event, content }); + + yield* startBridgedTurn({ + discordThreadId: discordThread.id, + channelId: event.channel_id, + guildId: event.guild_id ?? "", + prompt, + flags: flagsWithPrompt, + topic, + parentChannelId: event.channel_id, + mentionMessage, + ...(referenced !== null + ? { + referencedMessage: referenced.message, + referencedMessageUrl: referenced.url, + } + : {}), + ...(gatewayAttachments.length > 0 ? { discordAttachments: gatewayAttachments } : {}), + ...(uploadAttachments.length > 0 ? { attachments: uploadAttachments } : {}), + ...(pendingReadyReaction === undefined ? {} : { pendingReadyReaction }), + }).pipe(Effect.catch((error) => reportError(discordThread.id, error))); + }).pipe(Effect.catchCause(Effect.logError)); + + const handleMessages = gateway.handleDispatch("MESSAGE_CREATE", (event) => + handleInboundMessage(event as GatewayMessageEvent, "create"), + ); + const handleMessageUpdates = gateway.handleDispatch("MESSAGE_UPDATE", (event) => + handleInboundMessage(event as GatewayMessageEvent, "update"), + ); + // User deletes their parked prompt → drop it from the server queue (and badge). + const handleMessageDeletes = gateway.handleDispatch("MESSAGE_DELETE", (event) => + Effect.gen(function* () { + const messageId = + typeof event === "object" && + event !== null && + "id" in event && + typeof (event as { id?: unknown }).id === "string" + ? (event as { id: string }).id + : null; + if (messageId === null) return; + const pending = queuedPrompts.forgetDiscordMessage(messageId); + if (pending === null) return; + yield* Effect.logInfo("Discord user deleted queued prompt; removing from server queue", { + discordMessageId: messageId, + t3ThreadId: pending.t3ThreadId, + t3MessageId: pending.t3MessageId, + }); + yield* t3 + .removeQueuedMessage({ + threadId: pending.t3ThreadId, + messageId: pending.t3MessageId, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to remove queued prompt after Discord delete", { + t3ThreadId: pending.t3ThreadId, + t3MessageId: pending.t3MessageId, + error: String(error), + }), + ), + ); + // Message is gone — reaction cleanup is unnecessary. + }).pipe(Effect.catchCause(Effect.logError)), + ); + + const approvalButton = Ix.messageComponent( + Ix.idStartsWith("t3_approve:"), + Effect.gen(function* () { + const data = yield* Ix.MessageComponentData; + const parts = data.custom_id.split(":"); + const threadId = parts[1]; + const requestId = parts[2]; + if (threadId === undefined || requestId === undefined) { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Invalid approval payload.", flags: Discord.MessageFlags.Ephemeral }, + }); + } + yield* t3.respondToApproval(threadId as ThreadId, requestId, "accept"); + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Approved.", flags: Discord.MessageFlags.Ephemeral }, + }); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Approval failed: ${error instanceof Error ? error.message : String(error)}`, + flags: Discord.MessageFlags.Ephemeral, + }, + }), + ), + ), + ), + ); + + const denyButton = Ix.messageComponent( + Ix.idStartsWith("t3_deny:"), + Effect.gen(function* () { + const data = yield* Ix.MessageComponentData; + const parts = data.custom_id.split(":"); + const threadId = parts[1]; + const requestId = parts[2]; + if (threadId === undefined || requestId === undefined) { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Invalid approval payload.", flags: Discord.MessageFlags.Ephemeral }, + }); + } + yield* t3.respondToApproval(threadId as ThreadId, requestId, "decline"); + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Denied.", flags: Discord.MessageFlags.Ephemeral }, + }); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Deny failed: ${error instanceof Error ? error.message : String(error)}`, + flags: Discord.MessageFlags.Ephemeral, + }, + }), + ), + ), + ), + ); + + const stopButton = Ix.messageComponent( + Ix.idStartsWith("t3_stop:"), + Effect.gen(function* () { + const data = yield* Ix.MessageComponentData; + const threadId = data.custom_id.slice(turnStopCustomId("").length); + if (threadId.length === 0) { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Invalid stop payload.", flags: Discord.MessageFlags.Ephemeral }, + }); + } + + const threadShell = yield* t3.getThreadShell(threadId as ThreadId); + if (!hasInterruptibleTurn(threadShell)) { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "There is no active turn to stop right now.", + flags: Discord.MessageFlags.Ephemeral, + }, + }); + } + + yield* t3.interrupt(threadId as ThreadId); + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Stopping the current turn.", flags: Discord.MessageFlags.Ephemeral }, + }); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Stop failed: ${error instanceof Error ? error.message : String(error)}`, + flags: Discord.MessageFlags.Ephemeral, + }, + }), + ), + ), + ), + ); + + /** + * Blue Continue on a wake-up notice after server restart / interrupted session. + * Removes the button and starts a bridged "Continue" turn to wake the agent. + */ + const continueButton = Ix.messageComponent( + Ix.idStartsWith("t3_continue:"), + Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const data = yield* Ix.MessageComponentData; + const t3ThreadId = data.custom_id.slice(turnContinueCustomId("").length); + if (t3ThreadId.length === 0) { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Invalid continue payload.", flags: Discord.MessageFlags.Ephemeral }, + }); + } + + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Continue only works inside a Discord thread.", + flags: Discord.MessageFlags.Ephemeral, + }, + }); + } + + const link = yield* links.getByDiscordThreadId(channelId); + if (link === null || link.t3ThreadId !== t3ThreadId) { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "This thread is no longer linked to that T3 session.", + flags: Discord.MessageFlags.Ephemeral, + }, + }); + } + + const messageContent = + typeof interaction.message === "object" && + interaction.message !== null && + "content" in interaction.message && + typeof interaction.message.content === "string" + ? interaction.message.content + : ""; + + // Ack by stripping the Continue button so it cannot be double-clicked. + // Bridged turn starts in the background (interaction window is short). + const user = interaction.member?.user ?? interaction.user; + const requester: DiscordMessageLike = { + id: interaction.id, + content: "Continue", + author: { + id: user?.id, + username: user?.username, + displayName: + interaction.member?.nick ?? user?.global_name ?? user?.username ?? undefined, + bot: user?.bot, + }, + timestamp: interaction.id, + channelId, + }; + + const channel = yield* rest.getChannel(channelId); + const topicLookup = yield* resolveProjectTopic(channel); + const parentUnavailable = topicLookup.kind === "parent-unavailable"; + const parentId = + topicLookup.kind === "parent-unavailable" + ? topicLookup.parentChannelId + : (topicLookup.parentChannelId ?? + ("parent_id" in channel && typeof channel.parent_id === "string" + ? channel.parent_id + : null)); + const topic = topicLookup.kind === "resolved" ? topicLookup.topic : null; + + yield* forkSlashBackground( + startBridgedTurn({ + discordThreadId: channelId, + channelId: parentId ?? channelId, + guildId: interaction.guild_id ?? link.guildId ?? "", + prompt: "Continue", + flags: { local: false, plan: false, prompt: "Continue" }, + topic, + ...(parentUnavailable ? { parentUnavailable: true } : {}), + parentChannelId: parentId, + mentionMessage: requester, + }).pipe( + Effect.tap(() => + Effect.logInfo("Continue button woke interrupted T3 thread", { + channelId, + t3ThreadId, + }), + ), + Effect.catch((error) => reportError(channelId, error)), + ), + ); + + return Ix.response({ + type: Discord.InteractionCallbackTypes.UPDATE_MESSAGE, + data: { + ...idleMessageFields(messageContent), + }, + }); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Continue failed: ${error instanceof Error ? error.message : String(error)}`, + flags: Discord.MessageFlags.Ephemeral, + }, + }), + ), + ), + ), + ); + + // `/omegent` and its `/agent` alias share one handler. Discord has no native + // aliases, so each name is a separately registered command; the cast keeps the + // command-spec type constant across both so the single handler type-checks. + const makeSlashCommand = (commandName: string) => + Ix.guild( + { ...OMEGENT_SLASH_COMMAND, name: commandName } as typeof OMEGENT_SLASH_COMMAND, + (ix) => { + const optionString = (name: string): string => { + const value = Option.getOrUndefined(HashMap.get(ix.optionsMap, name)); + return typeof value === "string" ? value : ""; + }; + const optionBoolean = (name: string): boolean => { + // optionsMap is string-typed; read nested option objects for booleans. + // Cast: multi-subcommand root makes typed option names `never` in dfx helpers. + const option = Option.getOrUndefined(ix.option(name as never)); + return option !== undefined && "value" in option && option.value === true; + }; + + const interactionRequester = ( + interaction: Discord.APIInteraction, + ): DiscordMessageLike => { + const user = interaction.member?.user ?? interaction.user; + return { + id: interaction.id, + content: null, + author: { + id: user?.id, + username: user?.username, + displayName: + interaction.member?.nick ?? user?.global_name ?? user?.username ?? undefined, + bot: user?.bot, + }, + timestamp: interaction.id, // snowflake; buildDiscordTurnPrompt only needs identity + }; + }; + + const promptSlashHandler = (forcedDelivery?: "steer" | "queue") => + Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("Ask only works inside a server channel or thread.", { + ephemeral: true, + }); + } + + const prompt = optionString("prompt").trim(); + if (prompt.length === 0) { + return slashReply("Provide a non-empty `prompt`.", { ephemeral: true }); + } + + const model = optionString("model").trim(); + const provider = optionString("provider").trim(); + const base = optionString("base").trim(); + const local = optionBoolean("local"); + const plan = optionBoolean("plan"); + const steer = optionBoolean("steer"); + const queue = optionBoolean("queue"); + // Subcommand force wins; otherwise ask booleans (queue wins if both true). + const followUpDelivery = + forcedDelivery ?? + (queue === true + ? ("queue" as const) + : steer === true + ? ("steer" as const) + : undefined); + const flags = { + ...(model.length > 0 ? { model } : {}), + ...(provider.length > 0 ? { provider } : {}), + ...(base.length > 0 ? { base } : {}), + local, + plan, + ...(followUpDelivery === undefined ? {} : { followUpDelivery }), + prompt, + }; + + const channel = yield* rest.getChannel(channelId); + const inThread = isThreadChannel(channel.type); + const topicLookup = yield* resolveProjectTopic(channel); + const parentUnavailable = topicLookup.kind === "parent-unavailable"; + const parentId = + topicLookup.kind === "parent-unavailable" + ? topicLookup.parentChannelId + : (topicLookup.parentChannelId ?? + (inThread && "parent_id" in channel && typeof channel.parent_id === "string" + ? channel.parent_id + : null)); + const topic = topicLookup.kind === "resolved" ? topicLookup.topic : null; + + // New work needs a topic; continue turns can recover from an existing link. + if (parseTopicShortName(topic) === null) { + const existingLink = inThread ? yield* links.getByDiscordThreadId(channelId) : null; + if (existingLink === null) { + return slashReply(missingProjectBindingMessage({ inThread, parentUnavailable }), { + ephemeral: true, + }); + } + } + + const requester = interactionRequester(interaction); + const displayName = + requester.author?.displayName ?? requester.author?.username ?? "Someone"; + const ack = formatAskSlashAck({ + displayName, + prompt, + plan, + local, + ...(followUpDelivery === undefined ? {} : { followUpDelivery }), + }); + + if (inThread) { + // Fire-and-forget so we ack within Discord's interaction window. + yield* Effect.logInfo( + "Slash /omegent ask: starting bridged turn in linked thread", + { + channelId, + promptPreview: prompt.slice(0, 120), + }, + ); + yield* forkSlashBackground( + startBridgedTurn({ + discordThreadId: channelId, + channelId: parentId ?? channelId, + guildId: interaction.guild_id ?? "", + prompt, + flags, + topic, + ...(parentUnavailable ? { parentUnavailable: true } : {}), + parentChannelId: parentId, + mentionMessage: requester, + }).pipe( + Effect.tap(() => + Effect.logInfo("Slash /omegent ask: bridged turn started in linked thread", { + channelId, + }), + ), + Effect.catch((error) => reportError(channelId, error)), + ), + ); + return slashReply(ack); + } + + // New work from a project channel: seed a public starter message, open a thread. + const starter = yield* rest.createMessage(channelId, { content: ack }); + const discordThread = yield* openOrReuseThread( + channelId, + starter.id, + truncateTitle(prompt), + ); + yield* Effect.logInfo( + "Slash /omegent ask: starting bridged turn in new Discord thread", + { + channelId, + discordThreadId: discordThread.id, + promptPreview: prompt.slice(0, 120), + }, + ); + yield* forkSlashBackground( + startBridgedTurn({ + discordThreadId: discordThread.id, + channelId, + guildId: interaction.guild_id ?? "", + prompt, + flags, + topic, + parentChannelId: channelId, + mentionMessage: { + ...requester, + id: starter.id, + content: ack, + }, + }).pipe( + Effect.tap(() => + Effect.logInfo( + "Slash /omegent ask: bridged turn started in new Discord thread", + { + channelId, + discordThreadId: discordThread.id, + }, + ), + ), + Effect.catch((error) => reportError(discordThread.id, error)), + ), + ); + + const jump = discordChannelUrl(interaction.guild_id, discordThread.id); + return slashReply(`${ack}\n→ ${jump}`); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Ask failed: ${error instanceof Error ? error.message : String(error)}`, + { + ephemeral: true, + }, + ), + ), + ), + ); + + return ix.subCommands({ + ask: promptSlashHandler(), + steer: promptSlashHandler("steer"), + queue: promptSlashHandler("queue"), + + steernow: Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("steernow only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + const channel = yield* rest.getChannel(channelId); + if (!isThreadChannel(channel.type)) { + return slashReply("steernow only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + const link = yield* links.getByDiscordThreadId(channelId); + if (link === null) { + return slashReply("This thread is not linked to an Omegent session.", { + ephemeral: true, + }); + } + + // Server snapshot is authoritative after restart; local registry covers + // HTTP lag / soft-failed fetchThreadDetail so just-queued 📥 items still flush. + const detail = yield* t3.fetchThreadDetail(link.t3ThreadId); + const resolved = resolveSteernowMessageIds({ + serverQueued: detail?.thread.queuedMessages ?? [], + localPending: queuedPrompts.listForThread(link.t3ThreadId), + detailLoaded: detail !== null, + }); + if (resolved.messageIds.length === 0) { + return slashReply( + formatSteernowEmptyQueueMessage({ + snapshotMissing: resolved.snapshotMissing, + }), + { ephemeral: true }, + ); + } + + if (resolved.source === "local") { + yield* Effect.logInfo("steernow using local queued-prompt registry fallback", { + t3ThreadId: link.t3ThreadId, + count: resolved.messageIds.length, + snapshotMissing: resolved.snapshotMissing, + }); + } + + let steered = 0; + const failures: string[] = []; + for (const messageId of resolved.messageIds) { + const result = yield* t3 + .steerQueuedMessage({ + threadId: link.t3ThreadId, + messageId, + }) + .pipe(Effect.result); + if (Result.isSuccess(result)) { + steered += 1; + const pending = queuedPrompts.forgetT3Message(link.t3ThreadId, messageId); + if (pending !== null) { + yield* clearQueuedBadge(pending); + } + } else { + failures.push(String(messageId)); + yield* Effect.logWarning("steernow failed for queued message", { + t3ThreadId: link.t3ThreadId, + messageId, + error: String(result.failure), + }); + } + } + + if (steered === 0) { + return slashReply( + `Could not steer the queue (${failures.length} failed). Try again when the turn is running, or use \`/agent steer prompt:…\` to inject a new mid-turn prompt.`, + { ephemeral: true }, + ); + } + const failNote = + failures.length > 0 ? ` (${failures.length} could not be steered yet)` : ""; + return slashReply(`Steered ${steered} queued message(s)${failNote}.`); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `steernow failed: ${error instanceof Error ? error.message : String(error)}`, + { ephemeral: true }, + ), + ), + ), + ), + + help: Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("This command only works inside a server channel or thread.", { + ephemeral: true, + }); + } + + const channel = yield* rest.getChannel(channelId); + const inThread = isThreadChannel(channel.type); + const topicLookup = yield* resolveProjectTopic(channel); + if (topicLookup.kind === "parent-unavailable") { + return slashReply( + missingProjectBindingMessage({ inThread, parentUnavailable: true }), + { + ephemeral: true, + }, + ); + } + const parentId = topicLookup.parentChannelId; + const topic = topicLookup.topic; + const pinChannelId = parentId ?? channelId; + const binding = yield* resolveHelpChannelBinding(pinChannelId, topic); + if (binding.kind === "ok") { + return slashReply( + `Channel info: ${discordMessageUrl(interaction.guild_id, binding.pin.channelId, binding.pin.messageId)}`, + { ephemeral: true }, + ); + } + if (binding.kind === "no-topic") { + return slashReply( + missingProjectBindingMessage({ inThread, parentUnavailable: false }), + { ephemeral: true }, + ); + } + if (binding.kind === "unknown-alias") { + return slashReply( + `Channel topic resolves to alias \`${binding.shortName}\`, but that alias is not in the bot aliases file (\`T3_PROJECT_ALIASES_PATH\`).`, + { ephemeral: true }, + ); + } + if (binding.kind === "t3-connecting") { + return slashReply(T3_STILL_CONNECTING_MESSAGE, { ephemeral: true }); + } + if (binding.kind === "no-project") { + return slashReply( + `Alias \`${binding.shortName}\` → \`${binding.workspaceRoot}\`, but no T3 project is registered at that path.`, + { ephemeral: true }, + ); + } + return slashReply( + `Project \`${binding.shortName}\` is linked, but the channel-info pin could not be refreshed (often: pin content exceeded Discord’s 2000-character limit). Check bot logs for details.`, + { ephemeral: true }, + ); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Help failed: ${error instanceof Error ? error.message : String(error)}`, + { + ephemeral: true, + }, + ), + ), + ), + ), + + stop: Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("Stop only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + + const channel = yield* rest.getChannel(channelId); + if (!isThreadChannel(channel.type)) { + return slashReply("Stop is only supported inside a linked Discord thread.", { + ephemeral: true, + }); + } + + const existing = yield* links.getByDiscordThreadId(channelId); + if (existing === null) { + return slashReply( + "This Discord thread is not linked to a T3 thread, so there is nothing to stop.", + { ephemeral: true }, + ); + } + + const threadShell = yield* t3.getThreadShell(existing.t3ThreadId); + if (!hasInterruptibleTurn(threadShell)) { + return slashReply("There is no active turn to stop right now.", { + ephemeral: true, + }); + } + + yield* t3.interrupt(existing.t3ThreadId); + // Public ack so the thread has a shared audit trail. + return slashReply("Stopping the current turn."); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Stop failed: ${error instanceof Error ? error.message : String(error)}`, + { + ephemeral: true, + }, + ), + ), + ), + ), + + compact: Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("Compact only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + + const channel = yield* rest.getChannel(channelId); + if (!isThreadChannel(channel.type)) { + return slashReply("Compact is only supported inside a linked Discord thread.", { + ephemeral: true, + }); + } + + const existing = yield* links.getByDiscordThreadId(channelId); + if (existing === null) { + return slashReply( + "This Discord thread is not linked to a T3 thread, so there is nothing to compact.", + { ephemeral: true }, + ); + } + + // Compact can take longer than Discord's ~3s interaction window. + // Public deferred reply → edit with compaction stats for the channel. + const applicationId = interaction.application_id; + const token = interaction.token; + yield* forkSlashBackground( + Effect.gen(function* () { + yield* Effect.sleep("250 millis"); + const content = yield* runContextCompact(existing.t3ThreadId); + yield* rest + .updateOriginalWebhookMessage(applicationId, token, { + payload: { content }, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to edit deferred compact response", { + channelId, + error: String(error), + }), + ), + ); + }).pipe( + Effect.catchCause((cause) => + rest + .updateOriginalWebhookMessage(applicationId, token, { + payload: { + content: `Context compact failed: ${formatAlertCause(cause, 300)}`, + }, + }) + .pipe(Effect.ignore), + ), + ), + ); + + return slashDefer(); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Compact failed: ${error instanceof Error ? error.message : String(error)}`, + { ephemeral: true }, + ), + ), + ), + ), + + "thread-talk": Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply( + "Thread-talk can only be configured inside a linked Discord thread.", + { + ephemeral: true, + }, + ); + } + + // optionsMap flattens nested subcommand options; typed optionValue() is + // fragile across dfx's subcommand inference for multi-subcommand roots. + const actionRaw = + Option.getOrElse(HashMap.get(ix.optionsMap, "action"), () => "") ?? ""; + if (!isThreadTalkSlashAction(actionRaw)) { + return slashReply("Invalid thread-talk action. Use on, off, or status.", { + ephemeral: true, + }); + } + + const channel = yield* rest.getChannel(channelId); + if (!isThreadChannel(channel.type)) { + return slashReply( + "Thread-talk can only be configured inside a linked Discord thread.", + { + ephemeral: true, + }, + ); + } + + const link = yield* links.getByDiscordThreadId(channelId); + if (link === null) { + return slashReply("This Discord thread is not linked to a T3 thread yet.", { + ephemeral: true, + }); + } + + if (actionRaw === "on" || actionRaw === "off") { + yield* links.setThreadTalkMode( + channelId, + actionRaw === "on" ? "all-messages" : null, + ); + } + const enabled = + actionRaw === "on" || actionRaw === "off" + ? actionRaw === "on" + : threadTalkEnabled(link); + + yield* Effect.logInfo("Discord thread-talk mode resolved via slash", { + discordThreadId: channelId, + t3ThreadId: link.t3ThreadId, + enabled, + action: actionRaw, + actorId: interaction.member?.user?.id ?? interaction.user?.id ?? null, + }); + return threadTalkSlashReply({ action: actionRaw, enabled }); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Thread-talk failed: ${error instanceof Error ? error.message : String(error)}`, + { ephemeral: true }, + ), + ), + ), + ), + + link: Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("Link only works inside a server channel or thread.", { + ephemeral: true, + }); + } + + const ref = ( + Option.getOrElse(HashMap.get(ix.optionsMap, "ref"), () => "") ?? "" + ).trim(); + const t3ThreadId = extractT3ThreadId(ref); + if (t3ThreadId === null) { + return slashReply( + "Could not parse a T3 thread id from `ref`. Pass a bare id or a T3 URL with `?thread=`.", + { ephemeral: true }, + ); + } + + const channel = yield* rest.getChannel(channelId); + const inThread = isThreadChannel(channel.type); + const topicLookup = yield* resolveProjectTopic(channel); + if (topicLookup.kind === "parent-unavailable") { + return slashReply( + missingProjectBindingMessage({ inThread, parentUnavailable: true }), + { + ephemeral: true, + }, + ); + } + const parentId = topicLookup.parentChannelId; + const topic = topicLookup.topic; + + const summary = yield* linkExistingT3Thread({ + t3ThreadId, + replyChannelId: channelId, + replyToMessageId: null, + guildId: interaction.guild_id ?? "", + topic, + existingDiscordThreadId: inThread ? channelId : null, + parentChannelId: parentId ?? channelId, + replyViaReturn: true, + }); + // Shared link mutations are public. + return slashReply(summary); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Link failed: ${error instanceof Error ? error.message : String(error)}`, + { + ephemeral: true, + }, + ), + ), + ), + ), + + "refresh-indicators": Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("Refresh-indicators only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + + const channel = yield* rest.getChannel(channelId); + if (!isThreadChannel(channel.type)) { + return slashReply( + "Refresh-indicators is only supported inside a linked Discord thread.", + { ephemeral: true }, + ); + } + + const existing = yield* links.getByDiscordThreadId(channelId); + if (existing === null) { + return slashReply( + "This Discord thread is not linked to a T3 thread, so there are no indicators to refresh.", + { ephemeral: true }, + ); + } + + // Bridge rehydrate + VCS/PR lookup often exceeds Discord's ~3s interaction window. + // Return DEFERRED immediately; finish work after dfx acks, then edit the original. + const applicationId = interaction.application_id; + const token = interaction.token; + yield* forkSlashBackground( + Effect.gen(function* () { + // Let the deferred interaction response land before webhook edits. + yield* Effect.sleep("250 millis"); + yield* bridgeThreadToDiscord({ + discordChannelId: channelId, + t3ThreadId: existing.t3ThreadId, + mode: "rehydrate", + preferred: true, + lastActivityAt: existing.lastActivityAt, + }); + + const live = + (yield* bridgeHub.getLive(channelId, existing.t3ThreadId)) ?? + (yield* getLiveDiscordBridge(channelId, existing.t3ThreadId)); + const content = + live === null + ? "Could not attach a live bridge for this thread. Try again in a moment." + : yield* live + .refreshThreadIndicators() + .pipe( + Effect.map((result) => + result.ok + ? `Refreshed thread indicators → **${result.title}**` + : `Refresh-indicators failed: ${result.error}`, + ), + ); + + yield* rest + .updateOriginalWebhookMessage(applicationId, token, { + payload: { content }, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to edit deferred refresh-indicators response", { + channelId, + error: String(error), + }), + ), + ); + }).pipe( + Effect.catchCause((cause) => + rest + .updateOriginalWebhookMessage(applicationId, token, { + payload: { + content: `Refresh-indicators failed: ${formatAlertCause(cause, 300)}`, + }, + }) + .pipe(Effect.ignore), + ), + ), + ); + + return slashDefer({ ephemeral: true }); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Refresh-indicators failed: ${error instanceof Error ? error.message : String(error)}`, + { ephemeral: true }, + ), + ), + ), + ), + + assign: Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("Assign only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + + const channel = yield* rest.getChannel(channelId); + if (!isThreadChannel(channel.type)) { + return slashReply("Assign is only supported inside a linked Discord thread.", { + ephemeral: true, + }); + } + + const existing = yield* links.getByDiscordThreadId(channelId); + if (existing === null) { + return slashReply( + "This Discord thread is not linked to a T3 thread, so there are no PRs to assign.", + { ephemeral: true }, + ); + } + + const githubOption = ( + Option.getOrElse(HashMap.get(ix.optionsMap, "github"), () => "") ?? "" + ).trim(); + const requesterId = interaction.member?.user?.id ?? interaction.user?.id ?? null; + const resolved = resolveAssignGithubLogin({ + githubOption: githubOption.length > 0 ? githubOption : undefined, + requesterDiscordId: requesterId, + resolveByDiscordId: (discordId) => identityMap.resolveByDiscordId(discordId), + }); + if (!resolved.ok) { + return slashReply(resolved.message, { ephemeral: true }); + } + + const prUrls = existing.prUrls ?? []; + if (prUrls.length === 0) { + return slashReply( + `No linked pull requests on this thread yet. Open or post a PR first, then run \`/agent assign\`.`, + { ephemeral: true }, + ); + } + + // gh API can exceed Discord's ~3s window when multiple PRs are linked. + const applicationId = interaction.application_id; + const token = interaction.token; + const login = resolved.login; + yield* forkSlashBackground( + Effect.gen(function* () { + yield* Effect.sleep("250 millis"); + // assignPullRequestAssignees is best-effort (per-URL results; does not throw). + const results = yield* Effect.promise(() => + assignPullRequestAssignees({ + prUrls, + login, + }), + ); + const content = formatAssignSlashReply({ login, results }); + yield* Effect.logInfo("Discord slash assign completed", { + discordThreadId: channelId, + t3ThreadId: existing.t3ThreadId, + login, + source: resolved.source, + assigned: results.filter((r) => r.status === "assigned").length, + errors: results.filter((r) => r.status === "error").length, + actorId: requesterId, + }); + yield* rest + .updateOriginalWebhookMessage(applicationId, token, { + payload: { content }, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to edit deferred assign response", { + channelId, + error: String(error), + }), + ), + ); + }).pipe( + Effect.catchCause((cause) => + rest + .updateOriginalWebhookMessage(applicationId, token, { + payload: { + content: `Assign failed: ${formatAlertCause(cause, 300)}`, + }, + }) + .pipe(Effect.ignore), + ), + ), + ); + + // Public so the thread sees who assigned whom. + return slashDefer(); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `Assign failed: ${error instanceof Error ? error.message : String(error)}`, + { ephemeral: true }, + ), + ), + ), + ), + }); + }, + ); + + const omegentSlashCommand = makeSlashCommand(OMEGENT_SLASH_COMMAND_NAME); + const agentSlashCommand = makeSlashCommand(OMEGENT_SLASH_COMMAND_ALIAS); + + yield* registry.register( + Ix.builder + .add(omegentSlashCommand) + .add(agentSlashCommand) + .add(approvalButton) + .add(denyButton) + .add(stopButton) + .add(continueButton) + .catchAllCause(Effect.logError) as never, + ); + + yield* Effect.logInfo("Registered Discord slash commands", { + commands: [OMEGENT_SLASH_COMMAND_NAME, OMEGENT_SLASH_COMMAND_ALIAS], + subcommands: OMEGENT_SLASH_COMMAND.options.map((option) => option.name), + scope: "guild", + }); + + // Confirm gateway session is actually up (not only that the handler registered). + yield* Effect.forkScoped( + gateway.handleDispatch("READY", (ready) => + Effect.logInfo("Discord gateway READY", { + user: ready.user?.username, + userId: ready.user?.id, + guilds: Array.isArray(ready.guilds) ? ready.guilds.length : null, + }), + ), + ); + yield* Effect.forkScoped(handleMessages); + yield* Effect.forkScoped(handleMessageUpdates); + yield* Effect.forkScoped(handleMessageDeletes); + yield* Effect.logInfo("Discord mention router ready"); + return DiscordBotRunning.of({ botUserId }); + }); + +export const MentionRouterLive = (botConfig: DiscordBotConfig) => + Layer.effect(DiscordBotRunning, make(botConfig)); diff --git a/apps/discord-bot/src/features/ResponseBridge.test.ts b/apps/discord-bot/src/features/ResponseBridge.test.ts new file mode 100644 index 00000000000..06e739ea08e --- /dev/null +++ b/apps/discord-bot/src/features/ResponseBridge.test.ts @@ -0,0 +1,2569 @@ +import { MessageId, TurnId, type VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + activeStreamTipIdsForDelivery, + activeStreamTipText, + assistantMessagesThisTurn, + bridgeNeedsHttpReconcile, + isDeliveryBehindOrchestration, + isStreamTipDisplacedByForeignMessage, + isDiscordContentMessageType, + pickLatestContentMessageId, + deliveryFailureBackoffSeconds, + shouldRetryDeliveryFailure, + BRIDGE_DELIVERY_FAILURE_MAX_RETRIES, + findAlreadyPostedFinalChunkIds, + isAssistantAlreadyFinalizedOnDiscord, + normalizeDiscordContentForIdempotency, + resolveSubscribeAfterSequence, + resolveThreadSubscribeSeed, + trimOrchestrationThreadForDiscordMemory, + DISCORD_DELIVERED_MESSAGE_MEMORY_BUFFER, + SUBSCRIBE_SEQUENCE_BUFFER, + classifyUserMessageIngress, + DISCORD_EXTERNAL_ECHO_SURFACES, + discordBridgeOwnedMessageIds, + externalUserMessagesToEcho, + formatEchoedUserMessage, + isDiscordOriginatedUserPrompt, + isDiscordTasksSidePostContent, + isGitHubOriginatedUserPrompt, + isInternalAgentScaffoldingUserText, + shouldEchoUserMessageToDiscord, + shouldSuppressExternalUserEcho, + firstSnapshotBridgeAction, + pickLatestContentMessage, + nextBridgeStateAfterAdoptWorkingAck, + planStreamTipFreezeOnDisplacement, + resolveThreadTitleChangeRequestFromStatus, + rewriteInlinePathCodeSpansForDiscord, + rewriteMarkdownLocalFileLinksForDiscord, + resolveTaskMessageAction, + seedStreamMessageIds, + shouldArchiveStreamHistory, + shouldDropSeededWorkingAckOnInitialSnapshot, + shouldHoldFreshWorkingAck, + shouldPreserveStreamTipsOnBridgeStop, + shouldPublishRehydrateResumeTip, + shouldRecreateStreamTipOnUpdateFailure, + shouldSkipAlreadyDeliveredAssistant, + shouldReopenFinalizedDelivery, + shouldPublishAssistantUpdate, + startsNewStreamDelivery, + shouldFinalizeStreamBeforeNewDelivery, + streamTipBodyForHeartbeat, + finalAnswerText, + parseDiscordThreadTitleBadgeState, + parseDiscordThreadTitleBadges, + resolveDiscordThreadActivityBadgeState, + resolveDiscordThreadTitleBadgeState, + resolveDiscordThreadTitleBadges, + resolveDiscordTitlePrEvidence, + resolveSettledDiscordThreadTitleUpgrade, + resolveTemporaryDiscordThreadTitleBadge, + resolveThreadChangeRequestLookupCwds, + mergeStickyTitlePr, + nextMirroredThreadTitleAfterApply, + planDiscordThreadTitleApply, + shouldApplyDiscordThreadPrBadge, + shouldApplyDiscordThreadTitleBadge, + shouldConvertWorkingTipsToWakeUp, + summarizeExternalUserInput, + threadTitleChangeRequestState, + toStickyTitlePrEvidence, +} from "./ResponseBridge.ts"; + +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); +const assistantMessage = ( + id = "assistant-1", + overrides?: Partial<{ + readonly text: string; + readonly turnId: TurnId | null; + }>, +) => ({ + id: asMessageId(id), + role: "assistant" as const, + text: overrides?.text ?? "", + turnId: overrides?.turnId === undefined ? null : overrides.turnId, + streaming: false, + createdAt: "2026-07-19T00:00:00.000Z", + updatedAt: "2026-07-19T00:00:00.000Z", +}); +const userMessage = (id: string) => ({ + id: asMessageId(id), + role: "user" as const, + text: "follow-up", + turnId: null, + streaming: false, + createdAt: "2026-07-19T00:00:00.000Z", + updatedAt: "2026-07-19T00:00:00.000Z", +}); + +const statusWithPr = (overrides?: Partial): VcsStatusResult => ({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/thread-title", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: { + number: 42, + title: "Thread title badges", + state: "merged", + headRef: "feature/thread-title", + baseRef: "main", + url: "https://github.com/acme/widgets/pull/42", + }, + ...overrides, +}); + +describe("finalAnswerText", () => { + const threadWithTurnBubbles = (texts: ReadonlyArray) => { + const turnId = asTurnId("turn-answer"); + return { + latestTurn: { + turnId, + state: "completed" as const, + requestedAt: "2026-07-20T09:00:00.000Z", + startedAt: "2026-07-20T09:00:00.000Z", + completedAt: "2026-07-20T09:10:00.000Z", + assistantMessageId: asMessageId(`assistant-${texts.length - 1}`), + }, + session: null, + messages: [ + userMessage("user-1"), + ...texts.map((text, index) => assistantMessage(`assistant-${index}`, { text, turnId })), + ], + }; + }; + + it("prefers a substantial last bubble over an earlier longer Findings (draft PR case)", () => { + const findings = "A".repeat(2811); + const draftPr = + "**Draft PR:** https://github.com/example-org/scanner/pull/1950\n\n" + + "### Approach\n" + + "B".repeat(900); + expect(finalAnswerText(threadWithTurnBubbles([findings, draftPr]) as never)).toBe(draftPr); + }); + + it("keeps long Findings when the last bubble is only a short trailer", () => { + const findings = "Findings:\n" + "C".repeat(3200); + const trailer = "Done."; + expect(finalAnswerText(threadWithTurnBubbles([findings, trailer]) as never)).toBe(findings); + }); + + it("returns the only bubble when the turn has a single answer", () => { + const only = "**Draft PR:** https://example.com/pull/1"; + expect(finalAnswerText(threadWithTurnBubbles([only]) as never)).toBe(only); + }); +}); + +describe("assistantMessagesThisTurn", () => { + it("keeps pre-steer assistant progress when a mid-turn user message arrives", () => { + const turnId = asTurnId("turn-1"); + const preSteer = assistantMessage("assistant-pre", { + text: "long pre-steer findings…", + turnId, + }); + const postSteer = assistantMessage("assistant-post", { + text: "ack of follow-up", + turnId, + }); + const thread = { + latestTurn: { + turnId, + state: "running" as const, + requestedAt: "2026-07-19T00:00:00.000Z", + startedAt: "2026-07-19T00:00:00.000Z", + completedAt: null, + assistantMessageId: asMessageId("assistant-post"), + }, + session: { + threadId: "thread-1" as never, + status: "running" as const, + providerName: "codex", + runtimeMode: "default" as const, + activeTurnId: turnId, + lastError: null, + updatedAt: "2026-07-19T00:00:00.000Z", + }, + messages: [userMessage("user-1"), preSteer, userMessage("user-steer"), postSteer], + }; + + // Heuristic "after last user" would drop preSteer; turnId matching keeps it. + expect(assistantMessagesThisTurn(thread as never).map((message) => message.id)).toEqual([ + asMessageId("assistant-pre"), + asMessageId("assistant-post"), + ]); + }); + + it("falls back to after-last-user when turn ids are missing", () => { + const pre = assistantMessage("assistant-old", { text: "old" }); + const post = assistantMessage("assistant-new", { text: "new" }); + const thread = { + latestTurn: null, + session: null, + messages: [userMessage("user-1"), pre, userMessage("user-2"), post], + }; + expect(assistantMessagesThisTurn(thread as never).map((message) => message.id)).toEqual([ + asMessageId("assistant-new"), + ]); + }); + + it("returns empty for a new turn with no assistants yet (does not reuse prior turn bubbles)", () => { + const turn1 = asTurnId("turn-1"); + const turn2 = asTurnId("turn-2"); + const priorAnswer = assistantMessage("assistant-prior", { + text: "Mako Demo is live with the fix…", + turnId: turn1, + }); + const thread = { + latestTurn: { + turnId: turn2, + state: "running" as const, + requestedAt: "2026-07-21T08:30:00.000Z", + startedAt: "2026-07-21T08:30:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + session: { + threadId: "thread-1" as never, + status: "running" as const, + providerName: "codex", + runtimeMode: "default" as const, + activeTurnId: turn2, + lastError: null, + updatedAt: "2026-07-21T08:30:00.000Z", + }, + messages: [ + userMessage("user-1"), + priorAnswer, + userMessage("user-2"), // "so we good, don't care about gist" + ], + }; + + expect(assistantMessagesThisTurn(thread as never)).toEqual([]); + expect(finalAnswerText(thread as never)).toBe(""); + }); +}); + +describe("seedStreamMessageIds", () => { + it("keeps persisted stream tips active instead of marking them stale", () => { + expect( + seedStreamMessageIds({ + workingAckMessageId: "ack-new", + persistedStreamMessageIds: ["tip-1", "tip-2"], + }), + ).toEqual({ + discordMessageIds: ["tip-1", "tip-2", "ack-new"], + staleStreamMessageIds: [], + orphanTipIdsToDelete: [], + }); + }); + + it("dedupes when the working ack is already persisted", () => { + expect( + seedStreamMessageIds({ + workingAckMessageId: "tip-2", + persistedStreamMessageIds: ["tip-1", "tip-2"], + }), + ).toEqual({ + discordMessageIds: ["tip-1", "tip-2"], + staleStreamMessageIds: [], + orphanTipIdsToDelete: [], + }); + }); + + it("works with no working ack", () => { + expect( + seedStreamMessageIds({ + workingAckMessageId: null, + persistedStreamMessageIds: ["tip-1"], + }), + ).toEqual({ + discordMessageIds: ["tip-1"], + staleStreamMessageIds: [], + orphanTipIdsToDelete: [], + }); + }); + + it("discards persisted tips on a fresh Working turn so old bodies are not reused", () => { + expect( + seedStreamMessageIds({ + workingAckMessageId: "ack-new", + persistedStreamMessageIds: ["tip-1", "tip-2"], + discardPersistedTips: true, + }), + ).toEqual({ + discordMessageIds: ["ack-new"], + staleStreamMessageIds: [], + orphanTipIdsToDelete: ["tip-1", "tip-2"], + }); + }); + + it("does not discard persisted tips without a fresh Working ack", () => { + expect( + seedStreamMessageIds({ + workingAckMessageId: null, + persistedStreamMessageIds: ["tip-1"], + discardPersistedTips: true, + }), + ).toEqual({ + discordMessageIds: ["tip-1"], + staleStreamMessageIds: [], + orphanTipIdsToDelete: [], + }); + }); +}); + +describe("activeStreamTipText", () => { + it("returns full text when there is no break", () => { + expect(activeStreamTipText("hello world", "")).toBe("hello world"); + }); + + it("returns only the suffix after a frozen break prefix", () => { + expect(activeStreamTipText("hello world\n\nmore", "hello world")).toBe("more"); + }); + + it("falls back to full text when progress no longer starts with the prefix", () => { + expect(activeStreamTipText("rewritten", "hello")).toBe("rewritten"); + }); +}); + +describe("firstSnapshotBridgeAction", () => { + it("rehydrates a running turn even when the stream looks like it is still streaming", () => { + // Historical bug: isAssistantStreaming is true for any running turn, so + // gating on !streaming made rehydrate-resume dead code. + expect( + firstSnapshotBridgeAction({ + mode: "rehydrate", + turnInProgress: true, + hasContent: true, + alreadyFinalizedOnDiscord: false, + hasOpenTips: true, + }), + ).toBe("rehydrate-resume"); + }); + + it("catch-up finalizes offline-completed turns with open tips", () => { + expect( + firstSnapshotBridgeAction({ + mode: "rehydrate", + turnInProgress: false, + hasContent: true, + alreadyFinalizedOnDiscord: true, + hasOpenTips: true, + }), + ).toBe("catch-up-finalize"); + }); + + it("adopts completed assistants without re-post on interactive open", () => { + expect( + firstSnapshotBridgeAction({ + mode: "interactive", + turnInProgress: false, + hasContent: true, + alreadyFinalizedOnDiscord: true, + hasOpenTips: false, + }), + ).toBe("adopt-completed"); + }); +}); + +describe("shouldDropSeededWorkingAckOnInitialSnapshot", () => { + it("keeps the pre-posted working marker through the first prior-completed snapshot", () => { + expect( + shouldDropSeededWorkingAckOnInitialSnapshot({ + adoptedInitialSnapshot: false, + seededWorkingAckPending: true, + streaming: false, + turnInProgress: false, + }), + ).toBe(false); + }); + + it("keeps the working marker once the new turn is actually running", () => { + expect( + shouldDropSeededWorkingAckOnInitialSnapshot({ + adoptedInitialSnapshot: false, + seededWorkingAckPending: true, + streaming: true, + turnInProgress: true, + }), + ).toBe(false); + }); + + it("does not re-run after the initial snapshot has already been adopted", () => { + expect( + shouldDropSeededWorkingAckOnInitialSnapshot({ + adoptedInitialSnapshot: true, + seededWorkingAckPending: true, + streaming: false, + turnInProgress: false, + }), + ).toBe(false); + }); +}); + +describe("shouldReopenFinalizedDelivery", () => { + it("reopens when the same turn emits a new assistant segment after finalize", () => { + expect( + shouldReopenFinalizedDelivery({ + finalizedTurnId: "turn-1", + currentAssistantMessageId: "assistant-1", + turnId: "turn-1", + nextAssistantMessageId: "assistant-2", + }), + ).toBe(true); + }); + + it("keeps duplicate snapshots of the finalized assistant closed", () => { + expect( + shouldReopenFinalizedDelivery({ + finalizedTurnId: "turn-1", + currentAssistantMessageId: "assistant-1", + turnId: "turn-1", + nextAssistantMessageId: "assistant-1", + }), + ).toBe(false); + }); +}); + +describe("resolveTaskMessageAction", () => { + it("updates the persisted task message when tasks change across turns", () => { + expect( + resolveTaskMessageAction({ + taskDiscordMessageId: "task-message-1", + lastTasksKey: "old-turn-tasks", + nextTasksKey: "new-turn-tasks", + }), + ).toBe("update"); + }); + + it("creates only when no task message has ever been persisted", () => { + expect( + resolveTaskMessageAction({ + taskDiscordMessageId: null, + lastTasksKey: "", + nextTasksKey: "first-tasks", + }), + ).toBe("create"); + }); + + it("skips unchanged task content", () => { + expect( + resolveTaskMessageAction({ + taskDiscordMessageId: "task-message-1", + lastTasksKey: "same-tasks", + nextTasksKey: "same-tasks", + }), + ).toBe("skip"); + }); +}); + +describe("Discord Tasks side-channel (special treatment)", () => { + it("never classifies Tasks side-post bodies as external-echoable user input", () => { + const tasksBody = "**Tasks 1/3**\n◐ Fix the bridge\n○ Add tests\n✅ Ship it"; + expect(isDiscordTasksSidePostContent(tasksBody)).toBe(true); + expect(classifyUserMessageIngress(tasksBody)).toBe("internal"); + expect( + shouldEchoUserMessageToDiscord({ + text: tasksBody, + messageId: "t1", + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }), + ).toBe(false); + }); + + it("includes taskDiscordMessageId among owned ids so Tasks never freeze Working", () => { + const owned = discordBridgeOwnedMessageIds({ + discordMessageIds: ["working-tip"], + taskDiscordMessageId: "tasks-msg", + infoDiscordMessageId: "info-pin", + }); + expect(owned).toContain("tasks-msg"); + expect(owned).toContain("working-tip"); + expect( + isStreamTipDisplacedByForeignMessage({ + latestMessageId: "tasks-msg", + streamTipId: "working-tip", + ownedMessageIds: owned, + latestAuthorIsSelfBot: false, + }), + ).toBe(false); + }); +}); + +describe("shouldPublishAssistantUpdate", () => { + it("suppresses only live progress in final-only mode", () => { + expect(shouldPublishAssistantUpdate({ presentationMode: "final-only", streaming: true })).toBe( + false, + ); + expect(shouldPublishAssistantUpdate({ presentationMode: "final-only", streaming: false })).toBe( + true, + ); + }); + + it("keeps live progress in full mode", () => { + expect(shouldPublishAssistantUpdate({ presentationMode: "full", streaming: true })).toBe(true); + }); +}); + +describe("isStreamTipDisplacedByForeignMessage / content message types", () => { + it("treats Default and Reply as content; channel rename/pin as system", () => { + expect(isDiscordContentMessageType(0)).toBe(true); + expect(isDiscordContentMessageType(19)).toBe(true); + expect(isDiscordContentMessageType(undefined)).toBe(true); + expect(isDiscordContentMessageType(4)).toBe(false); // CHANNEL_NAME_CHANGE + expect(isDiscordContentMessageType(6)).toBe(false); // CHANNEL_PINNED_MESSAGE + }); + + it("picks the latest content message, skipping channel renames", () => { + // newest-first + expect( + pickLatestContentMessageId([ + { id: "rename-2", type: 4 }, + { id: "rename-1", type: 4 }, + { id: "tip-1", type: 0 }, + { id: "older", type: 0 }, + ]), + ).toBe("tip-1"); + }); + + it("is not displaced when the tip is still the channel tip", () => { + expect( + isStreamTipDisplacedByForeignMessage({ + latestMessageId: "tip-1", + streamTipId: "tip-1", + ownedMessageIds: ["task-1"], + }), + ).toBe(false); + }); + + it("is not displaced when latest is a live Tasks message (bot-owned)", () => { + expect( + isStreamTipDisplacedByForeignMessage({ + latestMessageId: "task-1", + streamTipId: "tip-1", + ownedMessageIds: ["task-1", "tip-1"], + }), + ).toBe(false); + }); + + it("is displaced when a human message is newer than the tip", () => { + expect( + isStreamTipDisplacedByForeignMessage({ + latestMessageId: "user-1", + streamTipId: "tip-1", + ownedMessageIds: ["task-1", "tip-1"], + }), + ).toBe(true); + }); + + it("is not displaced when latest is another owned stream chunk", () => { + expect( + isStreamTipDisplacedByForeignMessage({ + latestMessageId: "tip-2", + streamTipId: "tip-1", + ownedMessageIds: ["tip-1", "tip-2"], + }), + ).toBe(false); + }); +}); + +describe("shouldArchiveStreamHistory", () => { + it("never archives progress for final-only conversational turns", () => { + expect( + shouldArchiveStreamHistory({ + presentationMode: "final-only", + hasStreamMessages: true, + }), + ).toBe(false); + }); + + it("archives tracked progress in full mode", () => { + expect(shouldArchiveStreamHistory({ presentationMode: "full", hasStreamMessages: true })).toBe( + true, + ); + }); +}); + +describe("finalize accept-without-ack idempotency", () => { + it("normalizes Working markers and whitespace", () => { + expect(normalizeDiscordContentForIdempotency("Hello\n\n_Working.._\n")).toBe("Hello"); + expect(normalizeDiscordContentForIdempotency(" a b ")).toBe("a b"); + }); + + it("finds contiguous bot final chunks among recent messages", () => { + const ids = findAlreadyPostedFinalChunkIds({ + botUserId: "bot", + finalChunks: ["Part one", "Part two"], + // newest first (Discord listMessages order) + recentMessages: [ + { id: "m3", authorId: "bot", content: "Part two" }, + { id: "m2", authorId: "bot", content: "Part one" }, + { id: "m1", authorId: "user", content: "hi" }, + ], + }); + expect(ids).toEqual(["m2", "m3"]); + }); + + it("returns null when chunks are incomplete or in the wrong order", () => { + expect( + findAlreadyPostedFinalChunkIds({ + botUserId: "bot", + finalChunks: ["Part one", "Part two"], + recentMessages: [{ id: "m1", authorId: "bot", content: "Part one" }], + }), + ).toBeNull(); + // Wrong chronological order among bot messages (two then one). + expect( + findAlreadyPostedFinalChunkIds({ + botUserId: "bot", + finalChunks: ["Part one", "Part two"], + recentMessages: [ + { id: "m2", authorId: "bot", content: "Part one" }, + { id: "m1", authorId: "bot", content: "Part two" }, + ], + }), + ).toBeNull(); + }); + + it("ignores human messages between bot final chunks when bot order is correct", () => { + expect( + findAlreadyPostedFinalChunkIds({ + botUserId: "bot", + finalChunks: ["Part one", "Part two"], + recentMessages: [ + { id: "m3", authorId: "bot", content: "Part two" }, + { id: "m-x", authorId: "user", content: "interrupt" }, + { id: "m2", authorId: "bot", content: "Part one" }, + ], + }), + ).toEqual(["m2", "m3"]); + }); + + it("excludes stream tip ids from adoption", () => { + expect( + findAlreadyPostedFinalChunkIds({ + botUserId: "bot", + finalChunks: ["Answer"], + excludeMessageIds: ["tip-1"], + recentMessages: [ + { id: "tip-1", authorId: "bot", content: "Answer" }, + { id: "final-1", authorId: "bot", content: "Answer" }, + ], + }), + ).toEqual(["final-1"]); + }); + + it("detects durable or in-memory finalize markers", () => { + expect( + isAssistantAlreadyFinalizedOnDiscord({ + assistantId: "a1", + finalizedTurnId: "t1", + turnId: "t1", + lastFinalizedAssistantId: null, + durableLastFinalizedAssistantId: null, + }), + ).toBe(true); + expect( + isAssistantAlreadyFinalizedOnDiscord({ + assistantId: "a1", + finalizedTurnId: null, + turnId: "t1", + lastFinalizedAssistantId: null, + durableLastFinalizedAssistantId: "a1", + }), + ).toBe(true); + expect( + isAssistantAlreadyFinalizedOnDiscord({ + assistantId: "a1", + finalizedTurnId: null, + turnId: "t1", + lastFinalizedAssistantId: null, + durableLastFinalizedAssistantId: "a0", + }), + ).toBe(false); + }); +}); + +describe("trimOrchestrationThreadForDiscordMemory", () => { + const thread = (messageIds: ReadonlyArray) => + ({ + id: "t1", + messages: messageIds.map((id) => ({ + id, + role: id.startsWith("u") ? "user" : "assistant", + text: id, + turnId: null, + })), + }) as unknown as Parameters[0]["thread"]; + + it("keeps full transcript when nothing is finalized", () => { + const input = thread(["u1", "a1", "u2", "a2"]); + expect( + trimOrchestrationThreadForDiscordMemory({ + thread: input, + lastFinalizedAssistantId: null, + }).messages.map((m) => m.id), + ).toEqual(["u1", "a1", "u2", "a2"]); + }); + + it("drops messages before finalize watermark minus buffer", () => { + const input = thread(["u0", "a0", "u1", "a1", "u2", "a2"]); + expect( + trimOrchestrationThreadForDiscordMemory({ + thread: input, + lastFinalizedAssistantId: "a1", + buffer: DISCORD_DELIVERED_MESSAGE_MEMORY_BUFFER, + }).messages.map((m) => m.id), + ).toEqual(["a0", "u1", "a1", "u2", "a2"]); + }); +}); + +describe("resolveThreadSubscribeSeed", () => { + const warmThread = { id: "t1", messages: [] } as never; + it("prefers warm cache over HTTP", () => { + expect( + resolveThreadSubscribeSeed({ + warm: { snapshotSequence: 50, thread: warmThread }, + afterSequence: 40, + }), + ).toEqual({ kind: "warm", thread: warmThread, afterSequence: 50 }); + }); + + it("falls back to HTTP when warm is missing", () => { + expect( + resolveThreadSubscribeSeed({ + warm: null, + afterSequence: 40, + }), + ).toEqual({ kind: "http", afterSequence: 40 }); + }); + + it("is cold when neither seed is available", () => { + expect(resolveThreadSubscribeSeed({ warm: null, afterSequence: null })).toEqual({ + kind: "cold", + }); + }); +}); + +describe("deliveryFailureBackoffSeconds / shouldRetryDeliveryFailure", () => { + it("backs off exponentially and caps at 30s", () => { + expect(deliveryFailureBackoffSeconds(1)).toBe(2); + expect(deliveryFailureBackoffSeconds(2)).toBe(4); + expect(deliveryFailureBackoffSeconds(3)).toBe(8); + expect(deliveryFailureBackoffSeconds(4)).toBe(16); + expect(deliveryFailureBackoffSeconds(5)).toBe(30); + expect(deliveryFailureBackoffSeconds(10)).toBe(30); + }); + + it("retries up to the max then stops", () => { + expect(shouldRetryDeliveryFailure({ failureCount: 1 })).toBe(true); + expect( + shouldRetryDeliveryFailure({ + failureCount: BRIDGE_DELIVERY_FAILURE_MAX_RETRIES, + }), + ).toBe(true); + expect( + shouldRetryDeliveryFailure({ + failureCount: BRIDGE_DELIVERY_FAILURE_MAX_RETRIES + 1, + }), + ).toBe(false); + }); +}); + +describe("isDeliveryBehindOrchestration", () => { + it("is not lagging when neither cursor is set", () => { + expect( + isDeliveryBehindOrchestration({ + lastDeliveredSequence: null, + lastThreadSnapshotSequence: null, + }), + ).toBe(false); + }); + + it("does not treat unknown delivery cursor as lag (legacy links / first write)", () => { + expect( + isDeliveryBehindOrchestration({ + lastDeliveredSequence: null, + lastThreadSnapshotSequence: 10, + }), + ).toBe(false); + }); + + it("lags when delivery sequence is strictly behind orchestration", () => { + expect( + isDeliveryBehindOrchestration({ + lastDeliveredSequence: 5, + lastThreadSnapshotSequence: 10, + }), + ).toBe(true); + }); + + it("is caught up when cursors match", () => { + expect( + isDeliveryBehindOrchestration({ + lastDeliveredSequence: 10, + lastThreadSnapshotSequence: 10, + }), + ).toBe(false); + }); + + it("is caught up when delivery is ahead (HTTP seed race)", () => { + expect( + isDeliveryBehindOrchestration({ + lastDeliveredSequence: 12, + lastThreadSnapshotSequence: 10, + }), + ).toBe(false); + }); +}); + +describe("resolveSubscribeAfterSequence", () => { + it("returns null when no durable cursors exist (cold subscribe)", () => { + expect( + resolveSubscribeAfterSequence({ + lastDeliveredSequence: null, + lastThreadSnapshotSequence: null, + }), + ).toBeNull(); + }); + + it("prefers delivery cursor so already-synced events are not re-walked", () => { + expect( + resolveSubscribeAfterSequence({ + lastDeliveredSequence: 100, + lastThreadSnapshotSequence: 150, + buffer: 2, + }), + ).toBe(98); + }); + + it("falls back to orchestration cursor when delivery is unknown", () => { + expect( + resolveSubscribeAfterSequence({ + lastDeliveredSequence: null, + lastThreadSnapshotSequence: 40, + buffer: 2, + }), + ).toBe(38); + }); + + it("does not go below zero when buffer exceeds the anchor", () => { + expect( + resolveSubscribeAfterSequence({ + lastDeliveredSequence: 1, + lastThreadSnapshotSequence: 1, + buffer: 5, + }), + ).toBe(0); + }); + + it("uses the default buffer when omitted", () => { + expect( + resolveSubscribeAfterSequence({ + lastDeliveredSequence: 10, + lastThreadSnapshotSequence: 10, + }), + ).toBe(10 - SUBSCRIBE_SEQUENCE_BUFFER); + }); +}); + +describe("bridgeNeedsHttpReconcile", () => { + const idle = { + openStreamTipCount: 0, + seededWorkingAckPending: false, + turnInProgress: false, + awaitingDiscordFinal: false, + }; + + it("is idle when nothing is outstanding", () => { + expect(bridgeNeedsHttpReconcile(idle)).toBe(false); + }); + + it("reconciles while Working tips are open (stuck Working recovery)", () => { + expect(bridgeNeedsHttpReconcile({ ...idle, openStreamTipCount: 1 })).toBe(true); + }); + + it("reconciles while a fresh Working ack is pending", () => { + expect(bridgeNeedsHttpReconcile({ ...idle, seededWorkingAckPending: true })).toBe(true); + }); + + it("reconciles while the T3 turn is still running", () => { + expect(bridgeNeedsHttpReconcile({ ...idle, turnInProgress: true })).toBe(true); + }); + + it("reconciles until Discord has finalized a Discord-originated turn", () => { + expect(bridgeNeedsHttpReconcile({ ...idle, awaitingDiscordFinal: true })).toBe(true); + }); + + it("reconciles when dual-cursor delivery lags orchestration", () => { + expect(bridgeNeedsHttpReconcile({ ...idle, deliveryLagging: true })).toBe(true); + }); +}); + +describe("shouldPreserveStreamTipsOnBridgeStop", () => { + it("preserves tips when the turn is still running", () => { + expect( + shouldPreserveStreamTipsOnBridgeStop({ turnInProgress: true, openStreamTipCount: 1 }), + ).toBe(true); + }); + + it("does not preserve when the turn is idle (safe to clear leftovers)", () => { + expect( + shouldPreserveStreamTipsOnBridgeStop({ turnInProgress: false, openStreamTipCount: 2 }), + ).toBe(false); + }); + + it("does not preserve when there are no open tips", () => { + expect( + shouldPreserveStreamTipsOnBridgeStop({ turnInProgress: true, openStreamTipCount: 0 }), + ).toBe(false); + }); +}); + +describe("streamTipBodyForHeartbeat", () => { + it("suppresses prior-turn body while a fresh Working ack is pending", () => { + expect( + streamTipBodyForHeartbeat({ + seededWorkingAckPending: true, + lastAssistantText: "previous final answer", + streamBreakPrefix: "", + }), + ).toBe(""); + }); + + it("uses current stream body once the Working ack has been claimed by a stream write", () => { + expect( + streamTipBodyForHeartbeat({ + seededWorkingAckPending: false, + lastAssistantText: "live progress", + streamBreakPrefix: "", + }), + ).toContain("live progress"); + }); +}); + +/** + * Behaviour contracts for Working-tip lifecycle. + * These encode the product intent so regressions (prior-turn leak, dark Discord after + * restart, 10008 tip edits) fail tests instead of only showing up in Discord. + */ +describe("Working tip lifecycle contracts", () => { + describe("new user turn on reused bridge (no prior-turn leak)", () => { + it("holds only while Working is pending and the new turn has no assistants yet", () => { + expect( + shouldHoldFreshWorkingAck({ + mode: "interactive", + seededWorkingAckPending: true, + currentTurnAssistantCount: 0, + }), + ).toBe(true); + }); + + it("does not hold once the new turn has assistant content (stream it)", () => { + expect( + shouldHoldFreshWorkingAck({ + mode: "interactive", + seededWorkingAckPending: true, + currentTurnAssistantCount: 1, + }), + ).toBe(false); + }); + + it("does not hold on rehydrate (must catch-up / resume immediately)", () => { + expect( + shouldHoldFreshWorkingAck({ + mode: "rehydrate", + seededWorkingAckPending: true, + currentTurnAssistantCount: 0, + }), + ).toBe(false); + }); + + it("does not hold once stream has claimed the Working ack", () => { + expect( + shouldHoldFreshWorkingAck({ + mode: "interactive", + seededWorkingAckPending: false, + currentTurnAssistantCount: 0, + }), + ).toBe(false); + }); + + it("skips re-post when the latest assistant was already finalized and turn is idle", () => { + expect( + shouldSkipAlreadyDeliveredAssistant({ + assistantId: "assistant-prior", + lastFinalizedAssistantId: "assistant-prior", + turnInProgress: false, + }), + ).toBe(true); + }); + + it("skips the same finalized assistant even while a new turn is running", () => { + // Time-query race: turnInProgress=true with snapshot still showing prior bubble. + expect( + shouldSkipAlreadyDeliveredAssistant({ + assistantId: "assistant-prior", + lastFinalizedAssistantId: "assistant-prior", + turnInProgress: true, + }), + ).toBe(true); + }); + + it("does not skip a new assistant after the previous one was finalized", () => { + expect( + shouldSkipAlreadyDeliveredAssistant({ + assistantId: "assistant-new", + lastFinalizedAssistantId: "assistant-prior", + turnInProgress: false, + }), + ).toBe(false); + }); + + it("adopt Working ack resets prior body and tip ids to only the new ack", () => { + const next = nextBridgeStateAfterAdoptWorkingAck({ + priorDiscordMessageIds: ["old-tip-1", "old-tip-2"], + priorStaleStreamMessageIds: ["stale-a"], + workingAckMessageId: "new-working-ack", + }); + expect(next.discordMessageIds).toEqual(["new-working-ack"]); + expect(next.lastAssistantText).toBe(""); + expect(next.streamBreakPrefix).toBe(""); + expect(next.currentTurnId).toBeNull(); + expect(next.finalizedTurnId).toBeNull(); + expect(next.seededWorkingAckPending).toBe(true); + // Prior tips are frozen as channel history (not live/stale tips). + expect(next.orphanTipsToDelete).toEqual(["old-tip-1", "old-tip-2"]); + expect(next.staleStreamMessageIds).toEqual(["stale-a"]); + }); + + it("mid-turn Working ack adoption freezes old tips and starts a new delivery epoch", () => { + // Expected Discord UX: old tip loses Working+Stop, new Working appears under + // the human mid-turn message, stream continues on the new tip only. + const next = nextBridgeStateAfterAdoptWorkingAck({ + priorDiscordMessageIds: ["working-above-user"], + priorStaleStreamMessageIds: [], + workingAckMessageId: "working-below-user", + }); + expect(next.discordMessageIds).toEqual(["working-below-user"]); + expect(next.orphanTipsToDelete).toEqual(["working-above-user"]); + expect(next.seededWorkingAckPending).toBe(true); + expect( + startsNewStreamDelivery({ + currentTurnId: "same-turn", + nextTurnId: "same-turn", + reopensFinalizedDelivery: false, + seededWorkingAckPending: next.seededWorkingAckPending, + }), + ).toBe(true); + }); + + it("heartbeat never shows previous final answer under a fresh Working ack", () => { + const previousFinal = "**You are right** — fixed and pushed to PR #99"; + expect( + streamTipBodyForHeartbeat({ + seededWorkingAckPending: true, + lastAssistantText: previousFinal, + streamBreakPrefix: "", + }), + ).toBe(""); + }); + + it("starts a new stream delivery when Working ack is pending (not mid-turn edit)", () => { + expect( + startsNewStreamDelivery({ + currentTurnId: "turn-1", + nextTurnId: "turn-1", + reopensFinalizedDelivery: false, + seededWorkingAckPending: true, + }), + ).toBe(true); + }); + + it("starts a new stream delivery when turn id changes", () => { + expect( + startsNewStreamDelivery({ + currentTurnId: "turn-1", + nextTurnId: "turn-2", + reopensFinalizedDelivery: false, + seededWorkingAckPending: false, + }), + ).toBe(true); + }); + + it("continues the same delivery mid-turn without a fresh Working ack", () => { + expect( + startsNewStreamDelivery({ + currentTurnId: "turn-2", + nextTurnId: "turn-2", + reopensFinalizedDelivery: false, + seededWorkingAckPending: false, + }), + ).toBe(false); + }); + + it("finalizes substantial prior tip body before a new delivery (queue-drain race)", () => { + expect( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery: true, + lastAssistantText: + "**Yes — the bug is almost entirely a naming/dual-use problem.** Rename + return type.", + t3AssistantMessageId: "assistant:run:segment:5", + finalizedTurnId: null, + currentTurnId: "turn-prior", + }), + ).toBe(true); + }); + + it("does not finalize empty Working placeholders before a new delivery", () => { + expect( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery: true, + lastAssistantText: "_Working.._", + t3AssistantMessageId: "assistant:placeholder", + finalizedTurnId: null, + currentTurnId: "turn-prior", + }), + ).toBe(false); + }); + + it("does not re-finalize when the prior tip turn is already finalized", () => { + expect( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery: true, + lastAssistantText: "Already posted final answer body", + t3AssistantMessageId: "assistant:run:segment:5", + finalizedTurnId: "turn-prior", + currentTurnId: "turn-prior", + }), + ).toBe(false); + }); + + it("on new delivery keeps only the newest tip slot (avoids 10008 on prior tips)", () => { + const tips = activeStreamTipIdsForDelivery({ + startsNewDelivery: true, + discordMessageIds: ["prior-turn-tip", "fresh-working-ack"], + staleStreamMessageIds: [], + }); + expect(tips.discordMessageIds).toEqual(["fresh-working-ack"]); + expect(tips.staleStreamMessageIds).toEqual(["prior-turn-tip"]); + }); + + it("mid-turn delivery keeps the full tip history for multi-chunk streams", () => { + const tips = activeStreamTipIdsForDelivery({ + startsNewDelivery: false, + discordMessageIds: ["chunk-0", "chunk-1"], + staleStreamMessageIds: ["frozen"], + }); + expect(tips.discordMessageIds).toEqual(["chunk-0", "chunk-1"]); + expect(tips.staleStreamMessageIds).toEqual(["frozen"]); + }); + }); + + describe("bot restart / rehydrate while turn still running", () => { + it("preserves open tips on bridge stop when the turn is in progress", () => { + expect( + shouldPreserveStreamTipsOnBridgeStop({ + turnInProgress: true, + openStreamTipCount: 1, + }), + ).toBe(true); + }); + + it("does not treat preserve-on-stop as an excuse to repaint prior final text", () => { + // Preserve keeps the Discord message id; heartbeat must still not inject old body + // once a *new* Working ack is pending after the next user message. + expect( + shouldPreserveStreamTipsOnBridgeStop({ + turnInProgress: true, + openStreamTipCount: 1, + }), + ).toBe(true); + expect( + streamTipBodyForHeartbeat({ + seededWorkingAckPending: true, + lastAssistantText: "old final from before restart", + streamBreakPrefix: "", + }), + ).toBe(""); + }); + + it("rehydrate of a running turn resumes streaming (not catch-up finalize)", () => { + expect( + firstSnapshotBridgeAction({ + mode: "rehydrate", + turnInProgress: true, + hasContent: false, // tasks-only / tools only — still resume + alreadyFinalizedOnDiscord: false, + hasOpenTips: false, // tips may have been wiped + }), + ).toBe("rehydrate-resume"); + }); + + it("publishes a rehydrate resume tip even with empty progress (Working-only)", () => { + expect( + shouldPublishRehydrateResumeTip({ + presentationMode: "full", + turnInProgress: true, + }), + ).toBe(true); + }); + + it("does not publish rehydrate resume tips in final-only presentation", () => { + expect( + shouldPublishRehydrateResumeTip({ + presentationMode: "final-only", + turnInProgress: true, + }), + ).toBe(false); + }); + + it("catch-up finalizes when the turn completed offline with open tips", () => { + expect( + firstSnapshotBridgeAction({ + mode: "rehydrate", + turnInProgress: false, + hasContent: true, + alreadyFinalizedOnDiscord: false, + hasOpenTips: true, + }), + ).toBe("catch-up-finalize"); + }); + + it("HTTP reconcile stays armed while Working tips are open or turn is running", () => { + expect( + bridgeNeedsHttpReconcile({ + openStreamTipCount: 1, + seededWorkingAckPending: false, + turnInProgress: false, + awaitingDiscordFinal: false, + }), + ).toBe(true); + expect( + bridgeNeedsHttpReconcile({ + openStreamTipCount: 0, + seededWorkingAckPending: false, + turnInProgress: true, + awaitingDiscordFinal: false, + }), + ).toBe(true); + }); + }); + + describe("Discord 10008 Unknown Message on tip update", () => { + it("recreates the tip when update fails and the turn is still running", () => { + expect( + shouldRecreateStreamTipOnUpdateFailure({ + turnInProgress: true, + updateFailed: true, + }), + ).toBe(true); + }); + + it("does not recreate when the turn is idle (avoid ghost Working after settle)", () => { + expect( + shouldRecreateStreamTipOnUpdateFailure({ + turnInProgress: false, + updateFailed: true, + }), + ).toBe(false); + }); + + it("does not recreate when the update succeeded", () => { + expect( + shouldRecreateStreamTipOnUpdateFailure({ + turnInProgress: true, + updateFailed: false, + }), + ).toBe(false); + }); + }); + + describe("seedStreamMessageIds for fresh vs rehydrate", () => { + it("fresh Working turn discards persisted prior-turn tip ids as orphans", () => { + const seeded = seedStreamMessageIds({ + workingAckMessageId: "new-ack", + persistedStreamMessageIds: ["old-1", "old-2"], + discardPersistedTips: true, + }); + expect(seeded.discordMessageIds).toEqual(["new-ack"]); + expect(seeded.orphanTipIdsToDelete).toEqual(["old-1", "old-2"]); + }); + + it("rehydrate keeps persisted tip ids active so mid-turn resume can edit them", () => { + const seeded = seedStreamMessageIds({ + workingAckMessageId: null, + persistedStreamMessageIds: ["running-tip"], + discardPersistedTips: false, + }); + expect(seeded.discordMessageIds).toEqual(["running-tip"]); + expect(seeded.orphanTipIdsToDelete).toEqual([]); + }); + }); +}); + +describe("rewriteMarkdownLocalFileLinksForDiscord", () => { + it("rewrites GitHub-backed local file links to GitHub URLs", () => { + const text = + "[githubLinks.ts](/var/lib/t3/worktrees/t3code/t3-discord-1dd39f28/apps/discord-bot/src/presentation/githubLinks.ts:96)"; + const rewritten = rewriteMarkdownLocalFileLinksForDiscord({ + text, + githubUrlsBySrc: new Map([ + [ + "/var/lib/t3/worktrees/t3code/t3-discord-1dd39f28/apps/discord-bot/src/presentation/githubLinks.ts:96", + "https://github.com/example-org/example-repo/blob/main/apps/discord-bot/src/presentation/githubLinks.ts#L96", + ], + ]), + }); + expect(rewritten).toBe( + "[githubLinks.ts](https://github.com/example-org/example-repo/blob/main/apps/discord-bot/src/presentation/githubLinks.ts#L96)", + ); + }); + + it("keeps attachment fallback text for non-GitHub local files in final messages", () => { + const rewritten = rewriteMarkdownLocalFileLinksForDiscord({ + text: "[report.csv](/tmp/report.csv)", + githubUrlsBySrc: new Map(), + attachedFileNames: new Set(["report.csv"]), + oversizedByName: new Set(), + }); + expect(rewritten).toBe("report.csv (attached below)"); + }); + + it("keeps attachable documents as attachments even when source refs become links", () => { + const rewritten = rewriteMarkdownLocalFileLinksForDiscord({ + text: [ + "[ResponseBridge.ts](/repo/apps/discord-bot/src/features/ResponseBridge.ts:12)", + "[plan.md](/repo/tmp/plan.md)", + ].join("\n"), + githubUrlsBySrc: new Map([ + [ + "/repo/apps/discord-bot/src/features/ResponseBridge.ts:12", + "https://github.com/example-org/example-repo/blob/main/apps/discord-bot/src/features/ResponseBridge.ts#L12", + ], + ]), + attachedFileNames: new Set(["plan.md"]), + oversizedByName: new Set(), + }); + expect(rewritten).toContain( + "[ResponseBridge.ts](https://github.com/example-org/example-repo/blob/main/apps/discord-bot/src/features/ResponseBridge.ts#L12)", + ); + expect(rewritten).toContain("plan.md (attached below)"); + }); +}); + +describe("rewriteInlinePathCodeSpansForDiscord", () => { + it("rewrites inline tracked repo file references to GitHub links", () => { + const rewritten = rewriteInlinePathCodeSpansForDiscord({ + text: [ + "**Files:** `api/src/EasyLife/Standard/resources/RealPacking.ts:37-44`,", + "`api/src/EasyLife/Standard/core/packingCompletion.ts:36-45`", + ].join(" "), + githubUrlsByToken: new Map([ + [ + "api/src/EasyLife/Standard/resources/RealPacking.ts:37-44", + "https://github.com/example-org/example-repo/blob/main/api/src/EasyLife/Standard/resources/RealPacking.ts#L37", + ], + [ + "api/src/EasyLife/Standard/core/packingCompletion.ts:36-45", + "https://github.com/example-org/example-repo/blob/main/api/src/EasyLife/Standard/core/packingCompletion.ts#L36", + ], + ]), + }); + expect(rewritten).toContain( + "[`api/src/EasyLife/Standard/resources/RealPacking.ts:37-44`](https://github.com/example-org/example-repo/blob/main/api/src/EasyLife/Standard/resources/RealPacking.ts#L37)", + ); + expect(rewritten).toContain( + "[`api/src/EasyLife/Standard/core/packingCompletion.ts:36-45`](https://github.com/example-org/example-repo/blob/main/api/src/EasyLife/Standard/core/packingCompletion.ts#L36)", + ); + }); + + it("leaves unmatched inline references untouched", () => { + const text = "**Files:** `tmp/generated-report.html:1-10`"; + expect( + rewriteInlinePathCodeSpansForDiscord({ + text, + githubUrlsByToken: new Map(), + }), + ).toBe(text); + }); +}); + +describe("externalUserMessagesToEcho", () => { + it("includes unseen user messages from other clients", () => { + const messages = externalUserMessagesToEcho({ + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "sent from github", + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + { + id: MessageId.make("assistant-1"), + role: "assistant", + text: "ack", + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:01.000Z", + updatedAt: "2026-07-18T00:00:01.000Z", + }, + ], + observedInitialUserSnapshot: true, + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }); + expect(messages.map((message) => message.id)).toEqual(["user-1"]); + }); + + it("suppresses user messages that the Discord bot just sent into T3", () => { + const messages = externalUserMessagesToEcho({ + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "from discord", + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + ], + observedInitialUserSnapshot: true, + seenUserMessageIds: [], + sentDiscordUserMessageIds: ["user-1"], + }); + expect(messages).toEqual([]); + }); + + it("suppresses Discord-originated prompts even when sentDiscordUserMessageIds is missing", () => { + // Regression: idle rehydrate / id mismatch used to echo buildDiscordTurnPrompt + // back into Discord as External User Input, freeze the Working tip under that + // bot side-post, and leave the channel desynced while T3 kept working. + const discordPrompt = `## Discord conversation context +- This turn originated from a Discord thread. + +### Current requester +\`\`\`json +{"id":"1"} +\`\`\` + +## User request +fix the bridge desync`; + expect(isDiscordOriginatedUserPrompt(discordPrompt)).toBe(true); + const messages = externalUserMessagesToEcho({ + messages: [ + { + id: MessageId.make("user-discord-1"), + role: "user", + text: discordPrompt, + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + { + id: MessageId.make("user-external-1"), + role: "user", + text: "plain input from the web app", + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:01.000Z", + updatedAt: "2026-07-18T00:00:01.000Z", + }, + ], + observedInitialUserSnapshot: true, + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }); + expect(messages.map((message) => message.id)).toEqual(["user-external-1"]); + }); + + it("suppresses system-reminder / background-task scaffolding as external input", () => { + // Regression: agent harness injects Background task … + // as user-role text; mirroring it as External User Input is noise and freezes tips. + const systemReminder = ` +Background task "call-caf23c75-09ca-4fc6-a98e-daa9bcaa8e80-41" completed (exit code: 0). +Command: # High-signal PRs for resume/restart/wake continuity +for n in 3677 4229; do gh api "repos/pingdotgg/t3code/pulls/$n"; done +| Duration: 15.2s +Use get_command_or_subagent_output("call-caf23c75-09ca-4fc6-a98e-daa9bcaa8e80-41") to see the full output. +`; + expect(isInternalAgentScaffoldingUserText(systemReminder)).toBe(true); + expect(shouldSuppressExternalUserEcho(systemReminder)).toBe(true); + const messages = externalUserMessagesToEcho({ + messages: [ + { + id: MessageId.make("user-sys-1"), + role: "user", + text: systemReminder, + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + { + id: MessageId.make("user-real-1"), + role: "user", + text: "please also check PR 42", + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:01.000Z", + updatedAt: "2026-07-18T00:00:01.000Z", + }, + ], + observedInitialUserSnapshot: true, + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }); + expect(messages.map((message) => message.id)).toEqual(["user-real-1"]); + }); + + it("whitelists github + t3-client only (never same-surface discord or internal)", () => { + // Cross-surface policy: Discord echoes other surfaces, not its own ingress or harness. + expect(DISCORD_EXTERNAL_ECHO_SURFACES).toEqual(new Set(["github", "t3-client"])); + + const github = ` + +From GH [octocat](https://github.com/octocat) on [PR #42](https://github.com/acme/widgets/pull/42): investigate the failing check`; + const discord = `## Discord conversation context +### Current requester +## User request +hi from discord`; + const internal = `Background task "x" completed (exit code: 0).`; + const client = "plain note from the web app"; + + expect(classifyUserMessageIngress(github)).toBe("github"); + expect(classifyUserMessageIngress(discord)).toBe("discord"); + expect(classifyUserMessageIngress(internal)).toBe("internal"); + expect(classifyUserMessageIngress(client)).toBe("t3-client"); + expect(isGitHubOriginatedUserPrompt(github)).toBe(true); + + expect( + shouldEchoUserMessageToDiscord({ + text: github, + messageId: "g1", + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }), + ).toBe(true); + expect( + shouldEchoUserMessageToDiscord({ + text: client, + messageId: "c1", + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }), + ).toBe(true); + expect( + shouldEchoUserMessageToDiscord({ + text: discord, + messageId: "d1", + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }), + ).toBe(false); + expect( + shouldEchoUserMessageToDiscord({ + text: internal, + messageId: "i1", + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }), + ).toBe(false); + + const messages = externalUserMessagesToEcho({ + messages: [ + { + id: MessageId.make("g1"), + role: "user", + text: github, + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + { + id: MessageId.make("d1"), + role: "user", + text: discord, + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:01.000Z", + updatedAt: "2026-07-18T00:00:01.000Z", + }, + { + id: MessageId.make("i1"), + role: "user", + text: internal, + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:02.000Z", + updatedAt: "2026-07-18T00:00:02.000Z", + }, + { + id: MessageId.make("c1"), + role: "user", + text: client, + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:03.000Z", + updatedAt: "2026-07-18T00:00:03.000Z", + }, + ], + observedInitialUserSnapshot: true, + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }); + expect(messages.map((message) => message.id)).toEqual(["g1", "c1"]); + }); + + it("does not replay already-seen external user messages after resubscribe", () => { + const messages = externalUserMessagesToEcho({ + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "already mirrored", + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + ], + observedInitialUserSnapshot: true, + seenUserMessageIds: ["user-1"], + sentDiscordUserMessageIds: [], + }); + expect(messages).toEqual([]); + }); + + it("does not replay historical external user messages from the initial snapshot", () => { + const messages = externalUserMessagesToEcho({ + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "historical message", + turnId: null, + streaming: false, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + ], + observedInitialUserSnapshot: false, + seenUserMessageIds: [], + sentDiscordUserMessageIds: [], + }); + expect(messages).toEqual([]); + }); +}); + +describe("formatEchoedUserMessage", () => { + it("labels cross-client input with its person identity and client channel", () => { + expect( + formatEchoedUserMessage({ + ...userMessage("user-mobile"), + text: "please check the latest build", + source: { + channel: "mobile", + username: "patroza", + }, + }), + ).toBe("from **patroza@mobile**:\nplease check the latest build"); + }); + + it("uses an explicit fallback for legacy messages without provenance", () => { + expect(formatEchoedUserMessage(userMessage("user-legacy"))).toBe( + "from **unknown@unknown**:\nfollow-up", + ); + }); +}); + +describe("planStreamTipFreezeOnDisplacement", () => { + it("does not hide in-flight body when freezing an empty Working tip (mid-turn human reply)", () => { + // Production: Working.. never got prose painted; human mid-turn mention displaced the + // tip. Old code set breakPrefix = fullDisplayText so post-break tip was empty forever. + const plan = planStreamTipFreezeOnDisplacement({ + previousFullDisplayText: "", + previousTipBody: "", + previousLastAssistantText: "", + }); + expect(plan.freezeContent).toBeNull(); + expect(plan.nextBreakPrefix).toBe(""); + expect(plan.nextLastAssistantText).toBe(""); + // Current write must paint fully under the user message. + expect(activeStreamTipText("I'll find how the web UI handles…", plan.nextBreakPrefix)).toBe( + "I'll find how the web UI handles…", + ); + }); + + it("freezes already-shown prose and only posts the delta post-break", () => { + const shown = "I'll find how the web UI handles the wake-up status."; + const plan = planStreamTipFreezeOnDisplacement({ + previousFullDisplayText: shown, + previousTipBody: shown, + previousLastAssistantText: shown, + }); + expect(plan.freezeContent).toBe(shown); + expect(plan.nextBreakPrefix).toBe(shown); + expect(plan.nextLastAssistantText).toBe(shown); + const next = `${shown}\n\nUpdated plan: add a blue Continue button.`; + expect(activeStreamTipText(next, plan.nextBreakPrefix)).toBe( + "Updated plan: add a blue Continue button.", + ); + }); +}); + +describe("isStreamTipDisplacedByForeignMessage self-bot side posts", () => { + it("does not freeze the Working tip when the latest content message is our bot", () => { + // External User Input / Tasks echoes are bot-authored; without this guard they + // stole tip ownership and left Discord on a frozen Working body while T3 advanced. + expect( + isStreamTipDisplacedByForeignMessage({ + latestMessageId: "external-echo-1", + streamTipId: "working-tip", + ownedMessageIds: ["working-tip"], + latestAuthorIsSelfBot: true, + }), + ).toBe(false); + }); + + it("still freezes when a human reply is newer than the tip", () => { + expect( + isStreamTipDisplacedByForeignMessage({ + latestMessageId: "human-1", + streamTipId: "working-tip", + ownedMessageIds: ["working-tip"], + latestAuthorIsSelfBot: false, + }), + ).toBe(true); + }); + + it("pickLatestContentMessage returns author for self-bot checks", () => { + const latest = pickLatestContentMessage([ + { id: "rename", type: 4, author: { id: "bot" } }, + { id: "echo", type: 0, author: { id: "bot" } }, + ]); + expect(latest?.id).toBe("echo"); + expect(latest?.author?.id).toBe("bot"); + }); +}); + +describe("summarizeExternalUserInput", () => { + it("drops leading HTML comment sections and preserves the visible linked header", () => { + expect( + summarizeExternalUserInput(` + +From GH [octocat](https://github.com/octocat) on [PR #42](https://github.com/acme/widgets/pull/42): investigate the failing check`), + ).toBe( + "From GH [octocat](https://github.com/octocat) on [PR #42](https://github.com/acme/widgets/pull/42): investigate the failing check", + ); + }); + + it("drops HTML comment sections and preserves the visible linked header", () => { + expect( + summarizeExternalUserInput(`From GH [octocat](https://github.com/octocat) on [PR #42](https://github.com/acme/widgets/pull/42): investigate the failing check + +`), + ).toBe( + "From GH [octocat](https://github.com/octocat) on [PR #42](https://github.com/acme/widgets/pull/42): investigate the failing check", + ); + }); + + it("falls back to the raw text when no summary marker is present", () => { + expect(summarizeExternalUserInput("plain external input")).toBe("plain external input"); + }); + + it("strips system-reminder envelopes from echoed text", () => { + expect( + summarizeExternalUserInput(`prefix + +Background task "x" completed (exit code: 0). + +suffix`), + ).toBe("prefix\n\nsuffix"); + }); +}); + +describe("threadTitleChangeRequestState", () => { + it("returns null when the thread has no branch-backed worktree context", () => { + expect( + threadTitleChangeRequestState( + { + branch: null, + worktreePath: "/tmp/worktree", + messages: [assistantMessage()], + }, + { state: "open" }, + ), + ).toBeNull(); + expect( + threadTitleChangeRequestState( + { + branch: "feature/thread-title", + worktreePath: null, + messages: [assistantMessage()], + }, + { state: "open" }, + ), + ).toBeNull(); + }); + + it("returns null until the thread has a real assistant response", () => { + expect( + threadTitleChangeRequestState( + { branch: "feature/thread-title", worktreePath: "/tmp/worktree", messages: [] }, + { state: "open" }, + ), + ).toBeNull(); + }); + + it("passes through open and merged pull request states for linked worktrees", () => { + expect( + threadTitleChangeRequestState( + { + branch: "feature/thread-title", + worktreePath: "/tmp/worktree", + messages: [assistantMessage()], + }, + { state: "open" }, + ), + ).toBe("open"); + expect( + threadTitleChangeRequestState( + { + branch: "feature/thread-title", + worktreePath: "/tmp/worktree", + messages: [assistantMessage()], + }, + { state: "merged" }, + ), + ).toBe("merged"); + }); + + it("uses the initialized state for assistant-backed threads with no PR", () => { + expect( + threadTitleChangeRequestState( + { + branch: "feature/thread-title", + worktreePath: "/tmp/worktree", + messages: [assistantMessage()], + }, + null, + ), + ).toBe("initialized"); + }); +}); + +describe("resolveSettledDiscordThreadTitleUpgrade", () => { + it("upgrades a plain settled title once the worktree thread has an assistant message", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Link Discord to T3 thread", + branch: "t3-discord/23df93db", + worktreePath: "/var/lib/t3/worktrees/t3code/t3-discord-23df93db", + messages: [assistantMessage()], + }, + mirroredThreadTitle: "Link Discord to T3 thread", + attemptedThreadTitle: "Link Discord to T3 thread", + cachedPr: null, + }), + ).toBe("▫️ Link Discord to T3 thread"); + }); + + it("returns null when still waiting for an assistant response", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Link Discord to T3 thread", + branch: "t3-discord/23df93db", + worktreePath: "/var/lib/t3/worktrees/t3code/t3-discord-23df93db", + messages: [], + }, + mirroredThreadTitle: "Link Discord to T3 thread", + attemptedThreadTitle: "Link Discord to T3 thread", + cachedPr: null, + }), + ).toBeNull(); + }); + + it("returns null when the initialized badge is already mirrored", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Link Discord to T3 thread", + branch: "t3-discord/23df93db", + worktreePath: "/var/lib/t3/worktrees/t3code/t3-discord-23df93db", + messages: [assistantMessage()], + }, + mirroredThreadTitle: "▫️ Link Discord to T3 thread", + attemptedThreadTitle: "▫️ Link Discord to T3 thread", + cachedPr: null, + }), + ).toBeNull(); + }); + + it("upgrades when cached VCS status later reports an open PR", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Verify PROJ-378 PR readiness", + branch: "t3-discord/91dbe634", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-91dbe634", + messages: [assistantMessage()], + }, + mirroredThreadTitle: "▫️ Verify PROJ-378 PR readiness", + attemptedThreadTitle: "▫️ Verify PROJ-378 PR readiness", + cachedPr: { state: "open", hasFailingChecks: false }, + }), + ).toBe("🔀 Verify PROJ-378 PR readiness"); + }); + + it("does not demote an open PR badge to initialized when PR cache is briefly null", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Empasa pickup carrier rollout", + branch: "t3-discord/empasa-pickup-carrier", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-c434b753", + messages: [assistantMessage()], + }, + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + attemptedThreadTitle: "🔀 Empasa pickup carrier rollout", + cachedPr: null, + }), + ).toBeNull(); + }); + + it("keeps open PR and adds wake-required activity when interrupted mid-turn", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Empasa pickup carrier rollout", + branch: "t3-discord/empasa-pickup-carrier", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-c434b753", + messages: [assistantMessage()], + session: { status: "interrupted", activeTurnId: null } as never, + latestTurn: { + turnId: "turn-1" as never, + state: "running", + completedAt: null, + } as never, + }, + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + attemptedThreadTitle: "🔀 Empasa pickup carrier rollout", + cachedPr: { state: "open", hasFailingChecks: false }, + }), + ).toBe("🔀 ❗ Empasa pickup carrier rollout"); + }); + + it("keeps open PR and adds busy activity while a turn is Working", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Empasa pickup carrier rollout", + branch: "t3-discord/empasa-pickup-carrier", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-c434b753", + messages: [assistantMessage()], + session: { status: "running", activeTurnId: "turn-1" } as never, + latestTurn: { + turnId: "turn-1" as never, + state: "running", + completedAt: null, + } as never, + }, + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + attemptedThreadTitle: "🔀 Empasa pickup carrier rollout", + cachedPr: { state: "open", hasFailingChecks: false }, + }), + ).toBe("🔀 ⏳ Empasa pickup carrier rollout"); + }); + + it("clears busy activity and keeps the PR badge when a turn settles", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Empasa pickup carrier rollout", + branch: "t3-discord/empasa-pickup-carrier", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-c434b753", + messages: [assistantMessage()], + session: { status: "ready", activeTurnId: null } as never, + latestTurn: { + turnId: "turn-1" as never, + state: "completed", + completedAt: "2026-07-01T00:00:00.000Z", + } as never, + }, + mirroredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + attemptedThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + cachedPr: { state: "open", hasFailingChecks: false }, + }), + ).toBe("🔀 Empasa pickup carrier rollout"); + }); + + it("restores initialized when busy settles with confirmed no-PR evidence", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Empasa pickup carrier rollout", + branch: "t3-discord/empasa-pickup-carrier", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-c434b753", + messages: [assistantMessage()], + session: { status: "ready", activeTurnId: null } as never, + latestTurn: { + turnId: "turn-1" as never, + state: "completed", + completedAt: "2026-07-01T00:00:00.000Z", + } as never, + }, + mirroredThreadTitle: "⏳ Empasa pickup carrier rollout", + attemptedThreadTitle: "⏳ Empasa pickup carrier rollout", + cachedPr: null, + canApplyNoPrBadge: true, + }), + ).toBe("▫️ Empasa pickup carrier rollout"); + }); + + it("does not paint ▫️ when busy settles but no-PR is still unconfirmed", () => { + // Activity clears → plain title is ok; without canApplyNoPrBadge we keep no PR column. + // Mirrored was activity-only, so desired becomes plain title (no badges). + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Empasa pickup carrier rollout", + branch: "t3-discord/empasa-pickup-carrier", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-c434b753", + messages: [assistantMessage()], + session: { status: "ready", activeTurnId: null } as never, + latestTurn: { + turnId: "turn-1" as never, + state: "completed", + completedAt: "2026-07-01T00:00:00.000Z", + } as never, + }, + mirroredThreadTitle: "⏳ Empasa pickup carrier rollout", + attemptedThreadTitle: "⏳ Empasa pickup carrier rollout", + cachedPr: null, + canApplyNoPrBadge: false, + }), + ).toBe("Empasa pickup carrier rollout"); + }); + + it("retries dual busy title when attempted was poisoned by a failed rename", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Empasa pickup carrier rollout", + branch: "t3-discord/empasa-pickup-carrier", + worktreePath: "/var/lib/t3/worktrees/scanner/t3-discord-c434b753", + messages: [assistantMessage()], + session: { status: "running", activeTurnId: "turn-1" } as never, + latestTurn: { + turnId: "turn-1" as never, + state: "running", + completedAt: null, + } as never, + }, + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + attemptedThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + cachedPr: { state: "open", hasFailingChecks: false }, + }), + ).toBe("🔀 ⏳ Empasa pickup carrier rollout"); + }); +}); + +describe("planDiscordThreadTitleApply", () => { + it("applies a freshly computed title that Discord does not have yet", () => { + expect( + planDiscordThreadTitleApply({ + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + pendingDesiredThreadTitle: null, + computedDesiredTitle: "🔀 ⏳ Empasa pickup carrier rollout", + }), + ).toEqual({ + pendingDesiredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + applyTitle: "🔀 ⏳ Empasa pickup carrier rollout", + }); + }); + + it("clears pending when compute says Discord already matches", () => { + expect( + planDiscordThreadTitleApply({ + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + pendingDesiredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + computedDesiredTitle: "🔀 Empasa pickup carrier rollout", + }), + ).toEqual({ + pendingDesiredThreadTitle: null, + applyTitle: null, + }); + }); + + it("retries a prior failed rename when compute has nothing new", () => { + // Turn settled, secondary timed out mid-rename — heartbeat must re-apply settle. + expect( + planDiscordThreadTitleApply({ + mirroredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + pendingDesiredThreadTitle: "🔀 Empasa pickup carrier rollout", + computedDesiredTitle: null, + }), + ).toEqual({ + pendingDesiredThreadTitle: "🔀 Empasa pickup carrier rollout", + applyTitle: "🔀 Empasa pickup carrier rollout", + }); + }); + + it("does not re-apply when mirrored already matches pending", () => { + expect( + planDiscordThreadTitleApply({ + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + pendingDesiredThreadTitle: "🔀 Empasa pickup carrier rollout", + computedDesiredTitle: null, + }), + ).toEqual({ + pendingDesiredThreadTitle: null, + applyTitle: null, + }); + }); + + it("prefers a newer computed settle over a stale pending busy title", () => { + // VCS raced with settle: pending still wants ⏳, compute now wants settled. + expect( + planDiscordThreadTitleApply({ + mirroredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + pendingDesiredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + computedDesiredTitle: "🔀 Empasa pickup carrier rollout", + }), + ).toEqual({ + pendingDesiredThreadTitle: "🔀 Empasa pickup carrier rollout", + applyTitle: "🔀 Empasa pickup carrier rollout", + }); + }); +}); + +describe("nextMirroredThreadTitleAfterApply", () => { + it("keeps pending and leaves mirrored unchanged on REST failure", () => { + expect( + nextMirroredThreadTitleAfterApply({ + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + pendingDesiredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + appliedTitle: "🔀 ⏳ Empasa pickup carrier rollout", + success: false, + }), + ).toEqual({ + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + pendingDesiredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + attemptedThreadTitle: null, + }); + }); + + it("updates mirrored and clears matching pending on success", () => { + expect( + nextMirroredThreadTitleAfterApply({ + mirroredThreadTitle: "🔀 ⏳ Empasa pickup carrier rollout", + pendingDesiredThreadTitle: "🔀 Empasa pickup carrier rollout", + appliedTitle: "🔀 Empasa pickup carrier rollout", + success: true, + }), + ).toEqual({ + mirroredThreadTitle: "🔀 Empasa pickup carrier rollout", + pendingDesiredThreadTitle: null, + attemptedThreadTitle: "🔀 Empasa pickup carrier rollout", + }); + }); +}); + +describe("resolveTemporaryDiscordThreadTitleBadge", () => { + it("returns busy while a turn is Working", () => { + expect( + resolveTemporaryDiscordThreadTitleBadge({ + sessionStatus: "running", + latestTurnState: "running", + }), + ).toBe("busy"); + }); + + it("returns wake-required for a real mid-turn interrupt", () => { + expect( + resolveTemporaryDiscordThreadTitleBadge({ + sessionStatus: "interrupted", + latestTurnState: "running", + }), + ).toBe("wake-required"); + }); + + it("returns null when idle so the PR path can paint sticky badges", () => { + expect( + resolveTemporaryDiscordThreadTitleBadge({ + sessionStatus: "ready", + latestTurnState: "completed", + latestTurnCompletedAt: "2026-07-01T00:00:00.000Z", + }), + ).toBeNull(); + }); +}); + +describe("shouldApplyDiscordThreadTitleBadge", () => { + it("allows plain → initialized → open → merged", () => { + expect(shouldApplyDiscordThreadTitleBadge(null, "initialized")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("initialized", "open")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("open", "merged")).toBe(true); + }); + + it("refuses open → initialized / plain demotion (flip-flop)", () => { + expect(shouldApplyDiscordThreadTitleBadge("open", "initialized")).toBe(false); + expect(shouldApplyDiscordThreadTitleBadge("open", null)).toBe(false); + expect(shouldApplyDiscordThreadTitleBadge("merged", "initialized")).toBe(false); + }); + + it("always allows applying and clearing wake-required (sticky over git/PR while active)", () => { + expect(shouldApplyDiscordThreadTitleBadge("open", "wake-required")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("merged", "wake-required")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("wake-required", "open")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("wake-required", "initialized")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("wake-required", null)).toBe(true); + }); + + it("always allows applying and clearing busy (Working..) over git/PR badges", () => { + expect(shouldApplyDiscordThreadTitleBadge("open", "busy")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("initialized", "busy")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("merged", "busy")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("busy", "open")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("busy", "initialized")).toBe(true); + expect(shouldApplyDiscordThreadTitleBadge("busy", null)).toBe(true); + }); + + it("parses dual-slot and legacy badge prefixes from mirrored Discord titles", () => { + expect(parseDiscordThreadTitleBadges("🔀 ⏳ Empasa pickup carrier rollout")).toEqual({ + pr: "open", + activity: "busy", + }); + expect(parseDiscordThreadTitleBadges("❌ ❗ Empasa pickup carrier rollout")).toEqual({ + pr: "open", + activity: "wake-required", + }); + // Legacy dual ❌ 🔀 still parses as open. + expect(parseDiscordThreadTitleBadges("❌ 🔀 Empasa pickup carrier rollout")).toEqual({ + pr: "open", + activity: null, + }); + expect(parseDiscordThreadTitleBadgeState("🔀 Empasa pickup carrier rollout")).toBe("open"); + expect(parseDiscordThreadTitleBadgeState("❌ Empasa pickup carrier rollout")).toBe("open"); + expect(parseDiscordThreadTitleBadgeState("❌ 🔀 Empasa pickup carrier rollout")).toBe("open"); + expect(parseDiscordThreadTitleBadgeState("❗ Empasa pickup carrier rollout")).toBe( + "wake-required", + ); + expect(parseDiscordThreadTitleBadgeState("⏳ Empasa pickup carrier rollout")).toBe("busy"); + expect(parseDiscordThreadTitleBadgeState("▫️ Empasa pickup carrier rollout")).toBe( + "initialized", + ); + expect(parseDiscordThreadTitleBadgeState("Empasa pickup carrier rollout")).toBeNull(); + }); + + it("shouldApplyDiscordThreadPrBadge refuses open → initialized demotion", () => { + expect(shouldApplyDiscordThreadPrBadge("open", "initialized")).toBe(false); + expect(shouldApplyDiscordThreadPrBadge("open", "merged")).toBe(true); + }); +}); + +describe("resolveDiscordThreadTitleBadges", () => { + it("keeps PR and activity independent (client-style dual indicators)", () => { + expect( + resolveDiscordThreadTitleBadges({ + sessionStatus: "running", + latestTurnState: "running", + prState: "open", + }), + ).toEqual({ pr: "open", activity: "busy" }); + expect( + resolveDiscordThreadTitleBadges({ + sessionStatus: "interrupted", + latestTurnState: "running", + prState: "open", + }), + ).toEqual({ pr: "open", activity: "wake-required" }); + expect( + resolveDiscordThreadTitleBadges({ + sessionStatus: "ready", + prState: "open", + }), + ).toEqual({ pr: "open", activity: null }); + }); +}); + +describe("resolveDiscordThreadTitleBadgeState", () => { + it("legacy exclusive API prefers activity over PR", () => { + expect( + resolveDiscordThreadTitleBadgeState({ + sessionStatus: "interrupted", + latestTurnState: "running", + prState: "open", + }), + ).toBe("wake-required"); + expect( + resolveDiscordThreadTitleBadgeState({ + sessionStatus: "running", + latestTurnState: "running", + prState: "open", + }), + ).toBe("busy"); + }); + + it("returns activity busy when session is starting with no turn yet", () => { + expect( + resolveDiscordThreadActivityBadgeState({ + sessionStatus: "starting", + latestTurnState: null, + }), + ).toBe("busy"); + }); + + it("keeps PR state for zombie interrupted sessions with a completed turn", () => { + expect( + resolveDiscordThreadTitleBadgeState({ + sessionStatus: "interrupted", + latestTurnState: "completed", + latestTurnCompletedAt: "2026-07-01T00:00:00.000Z", + prState: "open", + }), + ).toBe("open"); + }); + + it("returns PR state once the session is no longer interrupted", () => { + expect( + resolveDiscordThreadTitleBadgeState({ + sessionStatus: "ready", + prState: "open", + }), + ).toBe("open"); + }); +}); + +describe("shouldConvertWorkingTipsToWakeUp", () => { + it("converts open Working tips when a mid-turn session is interrupted", () => { + expect( + shouldConvertWorkingTipsToWakeUp({ + sessionStatus: "interrupted", + latestTurnState: "running", + turnInProgress: false, + openStreamTipCount: 1, + wakeUpNoticePosted: false, + }), + ).toBe(true); + }); + + it("does not convert zombie interrupted sessions or empty tips", () => { + expect( + shouldConvertWorkingTipsToWakeUp({ + sessionStatus: "interrupted", + latestTurnState: "completed", + latestTurnCompletedAt: "2026-07-01T00:00:00.000Z", + turnInProgress: false, + openStreamTipCount: 1, + wakeUpNoticePosted: false, + }), + ).toBe(false); + expect( + shouldConvertWorkingTipsToWakeUp({ + sessionStatus: "interrupted", + latestTurnState: "running", + turnInProgress: false, + openStreamTipCount: 1, + wakeUpNoticePosted: true, + }), + ).toBe(false); + expect( + shouldConvertWorkingTipsToWakeUp({ + sessionStatus: "interrupted", + latestTurnState: "running", + turnInProgress: true, + openStreamTipCount: 1, + wakeUpNoticePosted: false, + }), + ).toBe(false); + expect( + shouldConvertWorkingTipsToWakeUp({ + sessionStatus: "interrupted", + latestTurnState: "running", + turnInProgress: false, + openStreamTipCount: 0, + wakeUpNoticePosted: false, + }), + ).toBe(false); + expect( + shouldConvertWorkingTipsToWakeUp({ + sessionStatus: "ready", + latestTurnState: "running", + turnInProgress: false, + openStreamTipCount: 1, + wakeUpNoticePosted: false, + }), + ).toBe(false); + }); +}); + +describe("resolveThreadTitleChangeRequestFromStatus", () => { + it("reuses the shared VCS status PR when it belongs to the thread branch", () => { + expect( + resolveThreadTitleChangeRequestFromStatus({ branch: "feature/thread-title" }, statusWithPr()), + ).toMatchObject({ number: 42, state: "merged" }); + }); + + it("ignores PRs from unrelated branches", () => { + expect( + resolveThreadTitleChangeRequestFromStatus( + { branch: "feature/thread-title" }, + statusWithPr({ + refName: "feature/other-branch", + pr: { ...statusWithPr().pr!, headRef: "feature/other-branch" }, + }), + ), + ).toBeNull(); + }); +}); + +describe("mergeStickyTitlePr / resolveDiscordTitlePrEvidence", () => { + it("keeps sticky PR when the next observation is null (unknown)", () => { + const sticky = { state: "open" as const, number: 9, hasFailingChecks: true }; + expect(mergeStickyTitlePr(sticky, null)).toEqual(sticky); + expect(mergeStickyTitlePr(sticky, undefined)).toEqual(sticky); + }); + + it("keeps hasFailingChecks=true for the same open PR until explicit false", () => { + const sticky = { state: "open" as const, number: 9, hasFailingChecks: true }; + expect(mergeStickyTitlePr(sticky, { state: "open", number: 9 })).toEqual({ + state: "open", + number: 9, + hasFailingChecks: true, + }); + expect( + mergeStickyTitlePr(sticky, { state: "open", number: 9, hasFailingChecks: false }), + ).toEqual({ + state: "open", + number: 9, + hasFailingChecks: false, + }); + }); + + it("does not allow no-PR (▫️) badge until remote status is observed", () => { + expect( + resolveDiscordTitlePrEvidence({ + stickyPr: null, + statusPr: null, + remoteStatusObserved: false, + }), + ).toEqual({ + stickyPr: null, + effectivePr: null, + canApplyNoPrBadge: false, + }); + }); + + it("allows no-PR badge only after remote is observed with no sticky PR", () => { + expect( + resolveDiscordTitlePrEvidence({ + stickyPr: null, + statusPr: null, + remoteStatusObserved: true, + }), + ).toEqual({ + stickyPr: null, + effectivePr: null, + canApplyNoPrBadge: true, + }); + }); + + it("sticks open failing PR from VCS and refuses demotion via null status", () => { + const openFailing = toStickyTitlePrEvidence({ + state: "open", + number: 12, + hasFailingChecks: true, + }); + const afterNull = resolveDiscordTitlePrEvidence({ + stickyPr: openFailing, + statusPr: null, + remoteStatusObserved: true, + }); + expect(afterNull.effectivePr).toEqual(openFailing); + expect(afterNull.canApplyNoPrBadge).toBe(false); + }); + + it("settled upgrade does not paint ▫️ when canApplyNoPrBadge is false", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Sync recent scanner learnings", + branch: "t3-discord/scanner", + worktreePath: "/tmp/wt", + messages: [assistantMessage()], + }, + mirroredThreadTitle: "❌ Sync recent scanner learnings", + attemptedThreadTitle: "❌ Sync recent scanner learnings", + cachedPr: null, + canApplyNoPrBadge: false, + }), + ).toBeNull(); + }); + + it("settled upgrade may paint ▫️ only with confirmed no-PR evidence", () => { + expect( + resolveSettledDiscordThreadTitleUpgrade({ + thread: { + title: "Sync recent scanner learnings", + branch: "t3-discord/scanner", + worktreePath: "/tmp/wt", + messages: [assistantMessage()], + }, + mirroredThreadTitle: "Sync recent scanner learnings", + attemptedThreadTitle: "Sync recent scanner learnings", + cachedPr: null, + canApplyNoPrBadge: true, + }), + ).toBe("▫️ Sync recent scanner learnings"); + }); +}); + +describe("resolveThreadChangeRequestLookupCwds", () => { + it("tries the thread worktree before the project root", () => { + expect( + resolveThreadChangeRequestLookupCwds( + { worktreePath: "/tmp/worktree" }, + { workspaceRoot: "/tmp/project" }, + ), + ).toEqual(["/tmp/worktree", "/tmp/project"]); + }); + + it("deduplicates identical worktree and project paths", () => { + expect( + resolveThreadChangeRequestLookupCwds( + { worktreePath: "/tmp/project" }, + { workspaceRoot: "/tmp/project" }, + ), + ).toEqual(["/tmp/project"]); + }); +}); diff --git a/apps/discord-bot/src/features/ResponseBridge.ts b/apps/discord-bot/src/features/ResponseBridge.ts new file mode 100644 index 00000000000..f7716ca3dc3 --- /dev/null +++ b/apps/discord-bot/src/features/ResponseBridge.ts @@ -0,0 +1,5687 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off globalFetchInEffect:off unknownInEffectCatch:off nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import type { + ChatImageAttachment, + OrchestrationThread, + ThreadId, + VcsStatusChangeRequest, + VcsStatusResult, +} from "@t3tools/contracts"; +import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import { sessionNeedsWakeUp } from "@t3tools/shared/sessionWake"; +import { resolveThreadChangeRequest } from "@t3tools/shared/sourceControl"; +import { + appendStatsToMessageChunks, + formatTurnResponseStatsLine, +} from "@t3tools/shared/turnResponseStats"; +import { Discord, DiscordConfig, DiscordREST, UI } from "dfx"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Redacted from "effect/Redacted"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Semaphore from "effect/Semaphore"; + +import { DiscordBotConfig } from "../config.ts"; +import { + appendT3DeepLinkToChunks, + buildStreamHistoryMarkdownText, + DISCORD_MAX_FILES_PER_MESSAGE, + imageAttachmentsOf, + STREAM_HISTORY_MARKDOWN_NAME, + streamHistoryHasAdditionalContent, + unpostedAttachments, +} from "../presentation/attachments.ts"; +import { buildOmegentThreadMessageUrl } from "../presentation/discordPrAttribution.ts"; +import { + createMessageWithAttachments, + DiscordUploadError, + textFile, + type DiscordUploadFile, +} from "../presentation/discordFiles.ts"; +import { formatModelSelectionLine } from "../presentation/threadInfoPin.ts"; +import { + assertFilesystemPath as assertFilesystemFilePath, + extractMarkdownLocalFileLinks, + fileNameForLocalFileRef, + guessFileMimeType, + isLocalFileSrc, + replaceMarkdownLocalFileLinks, + resolveLocalFilePathOnDisk, + stripMarkdownLocalFileLinks, + type MarkdownLocalFileRef, +} from "../presentation/markdownFiles.ts"; +import { + resolveGitHubBlobUrlForLocalPath, + resolveGitHubBlobUrlForPathReference, +} from "../presentation/githubLinks.ts"; +import { extractPullRequestUrls } from "../presentation/prLinks.ts"; +import { + assertFilesystemPath, + extractMarkdownImages, + fileNameForImageRef, + guessImageMimeType, + isLocalImageSrc, + resolveImagePathOnDisk, + stripMarkdownImages, + type MarkdownImageRef, +} from "../presentation/markdownImages.ts"; +import { + chunkDiscordContent, + formatInProgressChunk, + formatWakeUpTipContent, + inProgressChunkLimit, + idleMessageFields, + nextWorkingDotCount, + decorateDiscordThreadTitle, + stripWorkingIndicator, + type WorkingDotCount, + wakeUpMessageFields, + workingMessageFields, +} from "../presentation/messages.ts"; +import { + derivePendingInteractions, + type PendingApproval, +} from "../presentation/pendingInteractions.ts"; +import { formatTasksForDiscord, presentTasks } from "../presentation/tasks.ts"; +import { countTurnToolCalls } from "../presentation/toolCalls.ts"; +import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; +import { ThreadWarmCacheStore } from "../store/ThreadWarmCacheStore.ts"; +import { T3Session } from "../t3/T3Session.ts"; +import { formatAlertCause, postBridgeAlert, postFatalAlert } from "./Alerts.ts"; +import { BridgeHub, type BridgeControlSlot, type BridgeEnsureInput } from "./BridgeHub.ts"; +import { + assistantMessagesForDelivery, + beginDeliveryEpoch, + decideAssistantDelivery, + decideHeartbeat, + initialDeliveryEpochState, + shouldRecreateTip, + type DeliveryEpochState, +} from "./DiscordDelivery.ts"; +import { upsertThreadInfoPin } from "./ThreadInfoPin.ts"; + +const DISCORD_LIMIT = 2000; +const STREAM_CHUNK_LIMIT = inProgressChunkLimit(DISCORD_LIMIT); +const DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES = 10_000_000; + +interface BridgeState { + /** T3 orchestration turn currently tracked by this bridge. */ + readonly currentTurnId: string | null; + /** T3 orchestration assistant message id currently being streamed. */ + readonly t3AssistantMessageId: string | null; + /** Full assistant text last applied to the in-progress Discord stream. */ + readonly lastAssistantText: string; + /** + * Discord messages that carry the current turn's *in-progress* stream (ordered tip slots). + * On finalize these are deleted; their content is archived as stream-history.md. + * May include the pre-bridge Working.. ack (seeded at start). + */ + readonly discordMessageIds: ReadonlyArray; + /** + * Extra stream message ids that were abandoned (e.g. tip ownership lost and replaced). + * Always deleted on finalize / when starting a new assistant message. + */ + readonly staleStreamMessageIds: ReadonlyArray; + /** + * Full stream display text frozen when a user (or other) message displaced our tip. + * Active tip messages after the break only show the *suffix* after this prefix so we + * never re-copy pre-break content below the user message (order stays correct). + */ + readonly streamBreakPrefix: string; + readonly lastTasksKey: string; + readonly taskDiscordMessageId: string | null; + readonly lastApprovalKey: string; + /** Whether we already published a final answer for this T3 turn. */ + readonly finalizedTurnId: string | null; + /** Chat attachment ids already uploaded to Discord for the current T3 assistant message. */ + readonly postedAttachmentIds: ReadonlyArray; + /** Local markdown image srcs already uploaded for this T3 assistant message. */ + readonly postedMarkdownImageSrcs: ReadonlyArray; + /** Local markdown file srcs already uploaded for this T3 assistant message. */ + readonly postedMarkdownFileSrcs: ReadonlyArray; + /** Discord message ids of the final answer post(s), if any. */ + readonly finalDiscordMessageIds: ReadonlyArray; + /** Whether we already attached stream-history.md for this T3 assistant id. */ + readonly streamHistoryPosted: boolean; + /** + * After the first snapshot we adopt any prior completed assistant as "already + * finalized" so re-subscribing a long-lived thread does not re-post old answers. + */ + readonly adoptedInitialSnapshot: boolean; + /** Last Discord thread title we successfully mirrored from the T3 thread title. */ + readonly mirroredThreadTitle: string | null; + /** Title attempted by this bridge, preventing retries on every stream snapshot. */ + readonly attemptedThreadTitle: string | null; + /** + * A fresh Working.. ack was posted before `startTurn` dispatch. Keep it visible through + * the bridge's initial idle snapshot, and only clear it once the new turn actually starts + * (or explicit error cleanup removes it). + */ + readonly seededWorkingAckPending: boolean; + /** User message ids already observed by this bridge subscription. */ + readonly seenUserMessageIds: ReadonlyArray; + /** Whether the bridge has already treated one thread snapshot as baseline state. */ + readonly observedInitialUserSnapshot: boolean; + /** User message ids sent into T3 by this Discord bot and therefore not echoed back. */ + readonly sentDiscordUserMessageIds: ReadonlyArray; + /** + * Structural delivery epoch FSM (see DiscordDelivery.ts). Gates stream / finalize / + * heartbeat so a finalized answer cannot reappear as Working.. under itself. + */ + readonly delivery: DeliveryEpochState; + /** + * Converted an open Working tip into a wake-up notice for this bridge lifetime. + * Prevents re-editing / re-posting the same notice on every snapshot. + */ + readonly wakeUpNoticePosted: boolean; +} + +export type DiscordBridgePresentationMode = "full" | "final-only"; + +/** + * Discord Tasks side-channel lifecycle (one editable message per bridge). + * Independent of External User Input echo and of Working stream tips. + */ +export function resolveTaskMessageAction(input: { + readonly taskDiscordMessageId: string | null; + readonly lastTasksKey: string; + readonly nextTasksKey: string; +}): "skip" | "update" | "create" { + if (input.lastTasksKey === input.nextTasksKey) return "skip"; + return input.taskDiscordMessageId === null ? "create" : "update"; +} + +/** + * Message ids that must never steal Working tip ownership. + * Includes stream tips, finals, Tasks side-post, info pin, and durable stream markers. + */ +export function discordBridgeOwnedMessageIds(input: { + readonly discordMessageIds?: ReadonlyArray; + readonly staleStreamMessageIds?: ReadonlyArray; + readonly finalDiscordMessageIds?: ReadonlyArray; + readonly taskDiscordMessageId?: string | null | undefined; + readonly infoDiscordMessageId?: string | null | undefined; + readonly streamDiscordMessageIds?: ReadonlyArray; +}): ReadonlyArray { + const ids: string[] = []; + for (const group of [ + input.discordMessageIds, + input.staleStreamMessageIds, + input.finalDiscordMessageIds, + input.streamDiscordMessageIds, + ]) { + for (const id of group ?? []) { + const value = id?.trim() ?? ""; + if (value !== "") ids.push(value); + } + } + for (const id of [input.taskDiscordMessageId, input.infoDiscordMessageId]) { + const value = id?.trim() ?? ""; + if (value !== "") ids.push(value); + } + return ids; +} + +/** + * Normalize Discord message content for accept-without-ack idempotency checks. + * Collapses whitespace and strips Working indicators so chunk compares stay stable. + */ +export function normalizeDiscordContentForIdempotency(content: string): string { + return stripWorkingIndicator(content) + .replace(/\u200b/gu, "") + .replace(/\s+/gu, " ") + .trim(); +} + +/** + * Find bot-authored messages that already match the final answer chunks (oldest→newest). + * Used when Discord accepted createMessage but we timed out before recording state — + * retry must adopt those ids instead of posting a second final. + * + * Returns null when no complete contiguous match is found. + */ +export function findAlreadyPostedFinalChunkIds(input: { + readonly recentMessages: ReadonlyArray<{ + readonly id: string; + readonly authorId: string; + readonly content: string; + }>; + readonly botUserId: string; + readonly finalChunks: ReadonlyArray; + readonly excludeMessageIds?: ReadonlyArray; +}): ReadonlyArray | null { + const chunks = input.finalChunks + .map((chunk) => normalizeDiscordContentForIdempotency(chunk.trim() !== "" ? chunk : "_(done)_")) + .filter((chunk) => chunk !== ""); + if (chunks.length === 0) return null; + + const exclude = new Set((input.excludeMessageIds ?? []).filter((id) => id.trim() !== "")); + // listMessages is newest-first; walk oldest-first for contiguous chunk order. + const botMessages = input.recentMessages + .filter( + (message) => + message.authorId === input.botUserId && + !exclude.has(message.id) && + normalizeDiscordContentForIdempotency(message.content) !== "", + ) + .slice() + .reverse(); + + if (botMessages.length < chunks.length) return null; + + for (let start = 0; start <= botMessages.length - chunks.length; start += 1) { + let matched = true; + const ids: string[] = []; + for (let offset = 0; offset < chunks.length; offset += 1) { + const message = botMessages[start + offset]!; + const body = normalizeDiscordContentForIdempotency(message.content); + if (body !== chunks[offset]) { + matched = false; + break; + } + ids.push(message.id); + } + if (matched) return ids; + } + return null; +} + +/** + * Whether durable/in-memory finalize markers already claim this assistant was delivered. + */ +export function isAssistantAlreadyFinalizedOnDiscord(input: { + readonly assistantId: string; + readonly finalizedTurnId: string | null; + readonly turnId: string | null; + readonly lastFinalizedAssistantId: string | null | undefined; + readonly durableLastFinalizedAssistantId: string | null | undefined; +}): boolean { + if ( + input.finalizedTurnId !== null && + input.turnId !== null && + input.finalizedTurnId === input.turnId + ) { + return true; + } + if (input.lastFinalizedAssistantId === input.assistantId) return true; + if (input.durableLastFinalizedAssistantId === input.assistantId) return true; + return false; +} + +/** + * Discord message types that represent real chat content (user/bot). + * System messages (channel name change=4, pin=6, etc.) must NOT steal stream tip ownership — + * new threads rename early and otherwise freeze empty `_Working.._` while T3 streams. + * @see https://discord.com/developers/docs/resources/message#message-object-message-types + */ +export function isDiscordContentMessageType(type: number | null | undefined): boolean { + // 0 Default, 19 Reply. Treat missing type as content (older payloads). + return type === null || type === undefined || type === 0 || type === 19; +} + +/** + * Newest-first message list → latest content message id (skips channel renames / pins). + */ +export function pickLatestContentMessageId( + messages: ReadonlyArray<{ readonly id: string; readonly type?: number | null }>, +): string | null { + return pickLatestContentMessage(messages)?.id ?? null; +} + +/** + * Newest-first message list → latest content message (skips channel renames / pins). + */ +export function pickLatestContentMessage< + T extends { readonly id: string; readonly type?: number | null }, +>(messages: ReadonlyArray): T | null { + for (const message of messages) { + if (isDiscordContentMessageType(message.type)) { + const id = message.id.trim(); + if (id !== "") return message; + } + } + return null; +} + +/** + * Whether the stream tip should be frozen and reopened after a *foreign* channel tip. + * + * Only true when a non-owned *content* message is the latest (typically a human reply). + * Bot side posts — live Tasks, stream chunks, finals, external-input echoes — and Discord + * system messages (title renames, pins) must NOT break the tip. Otherwise empty + * `_Working.._` freezes while T3 streams intermediate prose (common on new threads with + * early renames, and when External User Input echoes land mid-turn). + */ +export function isStreamTipDisplacedByForeignMessage(input: { + readonly latestMessageId: string | null; + readonly streamTipId: string | null; + readonly ownedMessageIds: ReadonlyArray; + /** + * When the latest content message was authored by this Discord bot, never treat it as + * foreign — side posts (Tasks, external echoes, info pins) often lack durable ownership + * tracking and used to freeze the Working tip mid-turn. + */ + readonly latestAuthorIsSelfBot?: boolean; +}): boolean { + const tip = input.streamTipId?.trim() ?? ""; + if (tip === "") return false; + const latest = input.latestMessageId?.trim() ?? ""; + if (latest === "") return false; + if (latest === tip) return false; + // Our own bot posts never steal tip ownership (even if not in ownedMessageIds yet). + if (input.latestAuthorIsSelfBot === true) return false; + const owned = new Set(); + owned.add(tip); + for (const id of input.ownedMessageIds) { + const value = id?.trim() ?? ""; + if (value !== "") owned.add(value); + } + // Latest message is still one of ours (tasks / stream / final) — keep editing the tip. + if (owned.has(latest)) return false; + return true; +} + +export function shouldPublishAssistantUpdate(input: { + readonly presentationMode: DiscordBridgePresentationMode; + readonly streaming: boolean; +}): boolean { + return input.presentationMode === "full" || !input.streaming; +} + +export function shouldArchiveStreamHistory(input: { + readonly presentationMode: DiscordBridgePresentationMode; + readonly hasStreamMessages: boolean; +}): boolean { + return input.presentationMode === "full" && input.hasStreamMessages; +} + +export function shouldReopenFinalizedDelivery(input: { + readonly finalizedTurnId: string | null; + readonly currentAssistantMessageId: string | null; + readonly turnId: string | null; + readonly nextAssistantMessageId: string; +}): boolean { + return ( + input.finalizedTurnId !== null && + (input.finalizedTurnId !== input.turnId || + input.currentAssistantMessageId !== input.nextAssistantMessageId) + ); +} + +/** + * Dual-cursor lag: orchestration has advanced past what Discord successfully applied. + * + * - `lastThreadSnapshotSequence` advances when T3 state is observed (performant resume). + * - `lastDeliveredSequence` advances only after `processThreadSnapshot` succeeds. + * + * When delivery is behind, keep HTTP-reconciling / rehydrating even if tips look clean + * (crash between sequence persist and Discord I/O, hung REST, worker death mid-queue). + * + * Storage/memory contract: both cursors are O(1) scalars on the link row. We never + * persist or keep an event log of pre-sync history — only these markers + tip ids. + */ +export function isDeliveryBehindOrchestration(input: { + readonly lastDeliveredSequence: number | null | undefined; + readonly lastThreadSnapshotSequence: number | null | undefined; +}): boolean { + const observed = input.lastThreadSnapshotSequence; + const delivered = input.lastDeliveredSequence; + // Both cursors must be known. A null delivery cursor means pre-dual-cursor links or + // a brand-new link — do not force rehydrate of every historical link on upgrade. + // Open tips / awaiting-final / turn-in-progress still drive recovery in those cases. + // Once delivery has been written at least once, lag is strict sequence comparison. + if (observed === null || observed === undefined || !Number.isFinite(observed)) { + return false; + } + if (delivered === null || delivered === undefined || !Number.isFinite(delivered)) { + return false; + } + return delivered < observed; +} + +/** + * How many sequences to rewind when resuming the WS stream so a mid-delivery race + * can re-observe a tiny tail. Not history storage — just a resume offset. + */ +export const SUBSCRIBE_SEQUENCE_BUFFER = 2 as const; + +function finiteSequenceOrNull(value: number | null | undefined): number | null { + if (value === null || value === undefined || !Number.isFinite(value)) return null; + return value; +} + +/** + * Choose `subscribeThread({ afterSequence })` so we do **not** re-walk the WS event + * log before what Discord has already synced (minus a small buffer). + * + * Preference order: + * 1. `lastDeliveredSequence` — safe high-water for "already applied to Discord" + * 2. else `lastThreadSnapshotSequence` — legacy / first-write before delivery cursor exists + * + * Returns null for cold subscribe (no durable cursor yet). Never returns a cursor that + * requires loading stored pre-sync event history — only a number for the server filter. + */ +export function resolveSubscribeAfterSequence(input: { + readonly lastDeliveredSequence: number | null | undefined; + readonly lastThreadSnapshotSequence: number | null | undefined; + readonly buffer?: number; +}): number | null { + const buffer = Math.max(0, input.buffer ?? SUBSCRIBE_SEQUENCE_BUFFER); + const delivered = finiteSequenceOrNull(input.lastDeliveredSequence); + const observed = finiteSequenceOrNull(input.lastThreadSnapshotSequence); + // Prefer delivery cursor: skip everything Discord already applied. + // When lagging, this is *behind* orchestration — intentional: re-sync only the + // unsynced tail (+buffer), not the whole thread event log from 0. + const anchor = delivered ?? observed; + if (anchor === null) return null; + return Math.max(0, anchor - buffer); +} + +/** + * How many already-finalized messages to keep before `lastFinalizedAssistantId` + * for growth reopen / settle edge cases. Everything older is dropped from the + * in-memory and warm-cache OrchestrationThread. + */ +export const DISCORD_DELIVERED_MESSAGE_MEMORY_BUFFER = 2 as const; + +/** + * Drop messages Discord has already successfully finalized, keeping a small + * buffer before the finalize watermark plus everything after it (active turn). + */ +export function trimOrchestrationThreadForDiscordMemory(input: { + readonly thread: OrchestrationThread; + readonly lastFinalizedAssistantId: string | null | undefined; + readonly buffer?: number; +}): OrchestrationThread { + const finalizedId = input.lastFinalizedAssistantId?.trim() ?? ""; + if (finalizedId === "") return input.thread; + const buffer = Math.max(0, input.buffer ?? DISCORD_DELIVERED_MESSAGE_MEMORY_BUFFER); + const messages = input.thread.messages; + const finalizedIdx = messages.findIndex((message) => message.id === finalizedId); + if (finalizedIdx < 0) return input.thread; + const start = Math.max(0, finalizedIdx - buffer); + if (start === 0) return input.thread; + return { + ...input.thread, + messages: messages.slice(start), + }; +} + +/** + * Prefer durable warm base (like web/desktop cache) over HTTP full tip when present. + */ +export function resolveThreadSubscribeSeed(input: { + readonly warm: { + readonly snapshotSequence: number; + readonly thread: OrchestrationThread; + } | null; + readonly afterSequence: number | null; +}): + | { readonly kind: "warm"; readonly thread: OrchestrationThread; readonly afterSequence: number } + | { readonly kind: "http"; readonly afterSequence: number } + | { readonly kind: "cold" } { + if ( + input.warm !== null && + Number.isFinite(input.warm.snapshotSequence) && + input.warm.snapshotSequence >= 0 + ) { + return { + kind: "warm", + thread: input.warm.thread, + afterSequence: input.warm.snapshotSequence, + }; + } + if (input.afterSequence !== null && input.afterSequence >= 0) { + return { kind: "http", afterSequence: input.afterSequence }; + } + return { kind: "cold" }; +} + +/** + * Whether the bridge should poll T3 over HTTP for a fresh snapshot. + * + * WS event delivery can stall (no thread updates for long stretches while the agent + * still works). Open Working.. tips / in-progress turns must not rely on WS alone. + * + * Also keeps polling after a Discord-originated user turn until we finalize at least + * once for that bridge session — covers turns that never posted a Working tip and + * never received WS assistant events (T3 UI still advances). + * + * Dual-cursor lag (`deliveryLagging`) recovers cases where orchestration advanced but + * Discord I/O never completed for that sequence. + */ +export function bridgeNeedsHttpReconcile(input: { + readonly openStreamTipCount: number; + readonly seededWorkingAckPending: boolean; + readonly turnInProgress: boolean; + /** + * True when we still owe Discord a final answer for work this bridge started + * (e.g. sent a user message / Working ack but finalizedTurnId is still null). + */ + readonly awaitingDiscordFinal: boolean; + /** True when lastDeliveredSequence lags lastThreadSnapshotSequence. */ + readonly deliveryLagging?: boolean; +}): boolean { + if (input.seededWorkingAckPending) return true; + if (input.openStreamTipCount > 0) return true; + if (input.turnInProgress) return true; + if (input.awaitingDiscordFinal) return true; + if (input.deliveryLagging === true) return true; + return false; +} + +/** How often each live bridge re-fetches thread state when {@link bridgeNeedsHttpReconcile}. */ +export const BRIDGE_HTTP_RECONCILE_INTERVAL = "12 seconds" as const; + +/** Cap Discord finalize create/edit work so a hung REST call cannot pin the delivery queue. */ +export const BRIDGE_FINALIZE_DISCORD_TIMEOUT = "45 seconds" as const; + +/** + * Cap live stream tip create/edit work. Stream path previously had no timeout; a hung + * Discord REST call held the delivery lock forever so later assistants never reached + * Discord while T3 kept advancing (stuck `_Working.._` with live intermediate text in T3). + */ +export const BRIDGE_STREAM_DISCORD_TIMEOUT = "30 seconds" as const; + +/** + * Outer cap for one coalesced `processThreadSnapshot` pass (primary stream/finalize + + * best-effort secondary). Kept high enough for a finalize multipart upload. + */ +export const BRIDGE_PROCESS_SNAPSHOT_TIMEOUT = "90 seconds" as const; + +/** + * Cap title / pin / tasks / approvals so secondary Discord work cannot burn the whole + * processThreadSnapshot budget and trip the outer TimeoutError while the tip already + * has (or needs) stream content. + */ +export const BRIDGE_SECONDARY_DISCORD_TIMEOUT = "20 seconds" as const; + +/** Immediate in-worker retries after processThreadSnapshot failure before deferring to HTTP reconcile. */ +export const BRIDGE_DELIVERY_FAILURE_MAX_RETRIES = 5 as const; + +/** + * Backoff between in-worker delivery retries after TimeoutError / process failure. + * failureCount is 1-based (after increment). Caps at 30s. + */ +export function deliveryFailureBackoffSeconds(failureCount: number): number { + const n = Math.max(1, Math.floor(failureCount)); + return Math.min(30, 2 ** Math.min(n, 5)); +} + +export function shouldRetryDeliveryFailure(input: { + readonly failureCount: number; + readonly maxRetries?: number; +}): boolean { + const max = input.maxRetries ?? BRIDGE_DELIVERY_FAILURE_MAX_RETRIES; + return input.failureCount >= 1 && input.failureCount <= max; +} + +/** + * On bridge fiber stop (restart, reconnect dropAll, channel re-ensure), keep open + * stream tips on Discord when a turn is still running so rehydrate can resume them. + * + * This is the correct mid-turn resume strategy — **not** repainting the previous + * turn's final answer under a new Working tip. New interactive turns orphan-clean + * prior tip ids via {@link seedStreamMessageIds} / {@link nextBridgeStateAfterAdoptWorkingAck}. + */ +export function shouldPreserveStreamTipsOnBridgeStop(input: { + readonly turnInProgress: boolean; + readonly openStreamTipCount: number; +}): boolean { + return input.turnInProgress && input.openStreamTipCount > 0; +} + +/** Stable synthetic assistant id used only to reseed a Working tip with no prose yet. */ +export const REHYDRATE_WORKING_PLACEHOLDER_ID = "rehydrate:working-tip"; + +/** + * Body text for the Working heartbeat tip. + * + * While a fresh Working ack is pending for a new user turn, never paint prior-turn + * `lastAssistantText` (that re-shows the previous answer under Working..). + */ +export function streamTipBodyForHeartbeat(input: { + readonly seededWorkingAckPending: boolean; + readonly lastAssistantText: string; + readonly streamBreakPrefix: string; +}): string { + if (input.seededWorkingAckPending) return ""; + return activeStreamTipText(streamDisplayText(input.lastAssistantText), input.streamBreakPrefix); +} + +/** + * Hold a fresh Working ack without painting snapshot assistant body. + * + * Only while the *current* turn has no assistant bubbles yet. Blanket holding for + * the whole `seededWorkingAckPending` lifetime blocked legitimate new-turn stream + * writes (pending never cleared) or, when pending was cleared elsewhere, still + * allowed prior-turn bodies through once latestTurn lagged. + */ +export function shouldHoldFreshWorkingAck(input: { + readonly mode: "interactive" | "rehydrate"; + readonly seededWorkingAckPending: boolean; + /** Assistants belonging to the active turn only (see {@link assistantMessagesThisTurn}). */ + readonly currentTurnAssistantCount: number; +}): boolean { + return ( + input.mode === "interactive" && + input.seededWorkingAckPending && + input.currentTurnAssistantCount === 0 + ); +} + +/** + * Active tip message ids for a streaming write. + * + * On a new delivery (new turn / fresh Working ack), keep only the newest tip slot + * so we never edit prior-turn message ids (Discord 10008 Unknown Message). + */ +export function activeStreamTipIdsForDelivery(input: { + readonly startsNewDelivery: boolean; + readonly discordMessageIds: ReadonlyArray; + readonly staleStreamMessageIds: ReadonlyArray; +}): { + readonly discordMessageIds: ReadonlyArray; + readonly staleStreamMessageIds: ReadonlyArray; +} { + if (!input.startsNewDelivery) { + return { + discordMessageIds: [...input.discordMessageIds], + staleStreamMessageIds: [...input.staleStreamMessageIds], + }; + } + const tip = + input.discordMessageIds.length > 0 + ? input.discordMessageIds[input.discordMessageIds.length - 1]! + : null; + return { + discordMessageIds: tip !== null ? [tip] : [], + staleStreamMessageIds: uniqueDiscordMessageIds([ + ...input.staleStreamMessageIds, + ...input.discordMessageIds.slice(0, -1), + ]), + }; +} + +/** + * Whether streaming should treat this write as a brand-new tip delivery + * (clear last body / tip history), not a mid-turn edit of the same turn. + */ +export function startsNewStreamDelivery(input: { + readonly currentTurnId: string | null; + readonly nextTurnId: string | null; + readonly reopensFinalizedDelivery: boolean; + readonly seededWorkingAckPending: boolean; +}): boolean { + return ( + input.currentTurnId !== input.nextTurnId || + input.reopensFinalizedDelivery || + input.seededWorkingAckPending + ); +} + +/** + * When a new user turn / Working ack starts, any substantial stream tip body + * that was never finalized must be posted as a durable final **before** the + * tip is cleared. Queue-drain races finish the prior answer into the tip and + * immediately open the next Working epoch — without this, the final only + * lived as an editable tip and is deleted when the new epoch starts. + * + * Mirrors the server/web queue-drain final orphan problem (turnId restamp / + * fold rehome): Discord's presentation boundary is the Working tip lifecycle. + */ +export function shouldFinalizeStreamBeforeNewDelivery(input: { + readonly startsNewDelivery: boolean; + readonly lastAssistantText: string; + readonly t3AssistantMessageId: string | null; + readonly finalizedTurnId: string | null; + readonly currentTurnId: string | null; +}): boolean { + if (!input.startsNewDelivery) return false; + if (input.t3AssistantMessageId === null || input.t3AssistantMessageId.trim() === "") { + return false; + } + // Already finalized this tip's turn — nothing left to promote. + if ( + input.finalizedTurnId !== null && + input.currentTurnId !== null && + input.finalizedTurnId === input.currentTurnId + ) { + return false; + } + const text = input.lastAssistantText.trim(); + if (text === "" || text === "…") return false; + // Working-only placeholder with no prose yet. + if (/^_Working/i.test(text) && text.length < 40) return false; + return true; +} + +/** + * Pure state patch when a Discord Working.. ack is adopted for a new user turn + * (or mid-turn steer) on a reused bridge. Clears prior stream body and points the + * live tip at the new Working ack only. + * + * `orphanTipsToDelete` is misnamed historically: callers **freeze** those tips + * (strip Working.. + Stop) and leave them as channel history above the human message; + * only empty Working-only orphans are deleted. + */ +export function nextBridgeStateAfterAdoptWorkingAck(input: { + readonly priorDiscordMessageIds: ReadonlyArray; + readonly priorStaleStreamMessageIds: ReadonlyArray; + readonly workingAckMessageId: string; +}): { + readonly discordMessageIds: ReadonlyArray; + readonly staleStreamMessageIds: ReadonlyArray; + /** Prior tip ids to freeze (or delete if empty Working-only). */ + readonly orphanTipsToDelete: ReadonlyArray; + readonly lastAssistantText: string; + readonly streamBreakPrefix: string; + readonly currentTurnId: null; + readonly t3AssistantMessageId: null; + readonly finalizedTurnId: null; + readonly finalDiscordMessageIds: ReadonlyArray; + readonly streamHistoryPosted: false; + readonly postedAttachmentIds: ReadonlyArray; + readonly postedMarkdownImageSrcs: ReadonlyArray; + readonly postedMarkdownFileSrcs: ReadonlyArray; + readonly seededWorkingAckPending: true; +} { + const orphanTipsToDelete = input.priorDiscordMessageIds.filter( + (id) => id !== input.workingAckMessageId, + ); + return { + discordMessageIds: [input.workingAckMessageId], + // Do not re-queue orphans as stale live tips — they become frozen history. + staleStreamMessageIds: [...input.priorStaleStreamMessageIds].filter( + (id) => id !== input.workingAckMessageId && !orphanTipsToDelete.includes(id), + ), + orphanTipsToDelete, + lastAssistantText: "", + streamBreakPrefix: "", + currentTurnId: null, + t3AssistantMessageId: null, + finalizedTurnId: null, + finalDiscordMessageIds: [], + streamHistoryPosted: false, + postedAttachmentIds: [], + postedMarkdownImageSrcs: [], + postedMarkdownFileSrcs: [], + seededWorkingAckPending: true, + }; +} + +/** + * After a stream tip update fails (e.g. Discord 10008 Unknown Message), recreate + * when the turn is still running so Discord does not go dark. + */ +export function shouldRecreateStreamTipOnUpdateFailure(input: { + readonly turnInProgress: boolean; + readonly updateFailed: boolean; +}): boolean { + return input.updateFailed && input.turnInProgress; +} + +/** + * Rehydrate/resume should always attempt a streaming tip write for a running turn, + * including empty progress (Working-only), so restarts never leave Discord without + * a liveness bubble while T3 is still busy. + */ +export function shouldPublishRehydrateResumeTip(input: { + readonly presentationMode: DiscordBridgePresentationMode; + readonly turnInProgress: boolean; +}): boolean { + return ( + input.turnInProgress && + shouldPublishAssistantUpdate({ + presentationMode: input.presentationMode, + streaming: true, + }) + ); +} + +const emptyState = (seed?: { + readonly workingAckMessageId?: string | null; + readonly lastFinalizedAssistantId?: string | null; +}): BridgeState => { + const hasWorkingAck = + seed?.workingAckMessageId !== undefined && + seed.workingAckMessageId !== null && + seed.workingAckMessageId !== ""; + const lastFinalized = seed?.lastFinalizedAssistantId ?? null; + return { + currentTurnId: null, + t3AssistantMessageId: null, + lastAssistantText: "", + // Reuse the router's Working.. ack as the first stream tip so it is edited/deleted. + discordMessageIds: hasWorkingAck ? [seed!.workingAckMessageId!] : [], + staleStreamMessageIds: [], + streamBreakPrefix: "", + lastTasksKey: "", + taskDiscordMessageId: null, + lastApprovalKey: "", + finalizedTurnId: null, + postedAttachmentIds: [], + postedMarkdownImageSrcs: [], + postedMarkdownFileSrcs: [], + finalDiscordMessageIds: [], + streamHistoryPosted: false, + adoptedInitialSnapshot: false, + mirroredThreadTitle: null, + attemptedThreadTitle: null, + seededWorkingAckPending: hasWorkingAck, + seenUserMessageIds: [], + observedInitialUserSnapshot: false, + sentDiscordUserMessageIds: [], + delivery: initialDeliveryEpochState({ + epoch: hasWorkingAck ? 1 : 0, + phase: hasWorkingAck ? "awaiting" : "idle", + lastFinalizedAssistantId: lastFinalized, + lastFinalizedText: null, + settleReady: false, + }), + wakeUpNoticePosted: false, + }; +}; + +function uniqueDiscordMessageIds(ids: ReadonlyArray): ReadonlyArray { + return [...new Set(ids.filter((id) => id.trim() !== ""))]; +} + +function uniqueMessageIds(ids: ReadonlyArray): ReadonlyArray { + return [...new Set(ids.filter((id) => id.trim() !== ""))]; +} + +/** + * Live bridge handle per Discord channel. + * Mid-turn follow-ups re-use this handle instead of interrupting the fiber + * (which used to drop in-memory stream tips and re-seed a blank Working.. tip). + */ +export type { LiveDiscordBridge } from "./BridgeHub.ts"; + +export const getLiveDiscordBridge = (discordChannelId: string, t3ThreadId: string) => + Effect.gen(function* () { + const hub = yield* BridgeHub; + return yield* hub.getLive(discordChannelId, t3ThreadId); + }); + +function summarizeBridgeStateForLog(state: BridgeState) { + return { + currentTurnId: state.currentTurnId, + t3AssistantMessageId: state.t3AssistantMessageId, + discordMessageIds: [...state.discordMessageIds], + staleStreamMessageIds: [...state.staleStreamMessageIds], + streamBreakPrefixLen: state.streamBreakPrefix.length, + finalDiscordMessageIds: [...state.finalDiscordMessageIds], + taskDiscordMessageId: state.taskDiscordMessageId, + lastAssistantTextLength: state.lastAssistantText.length, + finalizedTurnId: state.finalizedTurnId, + streamHistoryPosted: state.streamHistoryPosted, + adoptedInitialSnapshot: state.adoptedInitialSnapshot, + seededWorkingAckPending: state.seededWorkingAckPending, + deliveryEpoch: state.delivery.epoch, + deliveryPhase: state.delivery.phase, + lastFinalizedAssistantId: state.delivery.lastFinalizedAssistantId, + }; +} + +/** + * True when the T3 UI would show "Wake Required" for this thread snapshot. + * Requires incomplete-turn evidence so zombie interrupted sessions stay quiet. + */ +export function isSessionWakeRequired( + thread: { + readonly session?: { + readonly status?: string | null; + readonly activeTurnId?: string | null; + } | null; + readonly latestTurn?: { + readonly state?: string | null; + readonly completedAt?: string | null; + } | null; + } | null, +): boolean { + if (thread === null) return false; + return sessionNeedsWakeUp({ + sessionStatus: thread.session?.status ?? null, + activeTurnId: thread.session?.activeTurnId ?? null, + latestTurnState: thread.latestTurn?.state ?? null, + latestTurnCompletedAt: thread.latestTurn?.completedAt ?? null, + }); +} + +/** + * Discord "in progress" is turn-scoped, not message-scoped. + * + * Codex/ACP often emits several assistant bubbles per turn (between tools). Each + * bubble ends with `message.streaming === false` while `latestTurn.state` is still + * `running`. Treating that as finalize posts stream-history.md mid-turn and + * creates new Discord messages instead of editing the tip. + * + * Only leave stream mode when the turn itself is no longer running. + * Interrupted sessions that need wake-up are never treated as live streaming. + */ +function isTurnInProgress(thread: OrchestrationThread): boolean { + if (isSessionWakeRequired(thread)) return false; + // Authoritative: turn still running (multi-step agents keep this while tools run). + if (thread.latestTurn?.state === "running") return true; + // No turn object yet but session already spinning up / running. + if ( + (thread.latestTurn === null || thread.latestTurn === undefined) && + (thread.session?.status === "running" || thread.session?.status === "starting") + ) { + return true; + } + return false; +} + +/** + * When a Working tip is still open after a real mid-turn interrupt (server + * restart / orphan settle), convert it to a wake-up notice instead of deleting it. + * Does not fire for zombie interrupted sessions with no unfinished turn. + */ +export function shouldConvertWorkingTipsToWakeUp(input: { + readonly sessionStatus: string | null | undefined; + readonly activeTurnId?: string | null | undefined; + readonly latestTurnState?: string | null | undefined; + readonly latestTurnCompletedAt?: string | null | undefined; + readonly turnInProgress: boolean; + readonly openStreamTipCount: number; + readonly wakeUpNoticePosted: boolean; +}): boolean { + if (input.wakeUpNoticePosted) return false; + if (input.turnInProgress) return false; + if (input.openStreamTipCount <= 0) return false; + return sessionNeedsWakeUp({ + sessionStatus: input.sessionStatus, + activeTurnId: input.activeTurnId, + latestTurnState: input.latestTurnState, + latestTurnCompletedAt: input.latestTurnCompletedAt, + }); +} + +function isAssistantStreaming(thread: OrchestrationThread, assistantId: string): boolean { + if (isTurnInProgress(thread)) return true; + const message = thread.messages.find((entry) => entry.id === assistantId); + return message?.streaming === true; +} + +export function shouldDropSeededWorkingAckOnInitialSnapshot(input: { + readonly adoptedInitialSnapshot: boolean; + readonly seededWorkingAckPending: boolean; + readonly streaming: boolean; + readonly turnInProgress: boolean; +}): boolean { + void input; + // The initial Discord ack exists specifically to cover the gap before the next + // stream or final state is ready. Clearing it on the first pre-start snapshot + // causes a visible blink: Working.. disappears, then reappears once streaming starts. + // Keep it until a replacement is posted or the turn later settles with nothing to show. + return false; +} + +/** + * Decide how the first thread snapshot should be applied to Discord. + * + * Important: do not require `!streaming` here. Turn-in-progress always reports + * streaming=true, and the old guard made rehydrate-of-running-turns unreachable. + */ +export function firstSnapshotBridgeAction(input: { + readonly mode: "interactive" | "rehydrate"; + readonly turnInProgress: boolean; + readonly hasContent: boolean; + readonly alreadyFinalizedOnDiscord: boolean; + readonly hasOpenTips: boolean; +}): "catch-up-finalize" | "rehydrate-resume" | "adopt-completed" | "interactive-resume" | "skip" { + if (input.mode === "rehydrate" && !input.turnInProgress) { + if (input.hasContent && (!input.alreadyFinalizedOnDiscord || input.hasOpenTips)) { + return "catch-up-finalize"; + } + return "adopt-completed"; + } + if (input.mode === "rehydrate" && input.turnInProgress) { + return "rehydrate-resume"; + } + if (!input.turnInProgress) { + return "adopt-completed"; + } + return "interactive-resume"; +} + +/** + * Where a user-role T3 message came from, inferred from prompt envelopes. + * + * OrchestrationMessage has no first-class `source` field yet, so surfaces are classified + * from known ingress builders (Discord bot, GitHub PR bridge, agent harness). Plain text + * defaults to `t3-client` (web / desktop / mobile / API). + * + * Cross-surface policy: a surface should only *echo* messages from other surfaces. + * Discord bot echoes `github` + `t3-client`, never `discord` or `internal`. + * + * **Discord Tasks are not an ingress surface.** They are a first-class side-channel + * projected from `turn.plan.updated` activities via {@link presentTasks} / + * {@link formatTasksForDiscord} / `taskDiscordMessageId` — never from user-message echo. + * Tasks posts must stay bot-owned (do not freeze Working tips) and keep updating mid-turn. + */ +export type UserMessageIngressSurface = "discord" | "github" | "t3-client" | "internal"; + +/** Surfaces Discord may post as External User Input (whitelist). */ +export const DISCORD_EXTERNAL_ECHO_SURFACES: ReadonlySet = new Set([ + "github", + "t3-client", +]); + +/** + * True when text matches the Discord Tasks side-post body (`**Tasks N/M**`…). + * Those posts are bot-authored progress UI, not External User Input and not stream tips. + */ +export function isDiscordTasksSidePostContent(text: string): boolean { + return /^\*\*Tasks\s+\d+\s*\/\s*\d+\*\*/u.test(text.trim()); +} + +/** + * Classify a user-role message body by ingress surface (content heuristics). + * Prefer durable message ids (`sentDiscordUserMessageIds`) when available; this is the + * content fallback for rehydrate / id mismatch / non-Discord injectors. + */ +export function classifyUserMessageIngress(text: string): UserMessageIngressSurface { + const body = text.trim(); + if (body === "") return "t3-client"; + + // Agent harness / runtime scaffolding (not a human client). + if (isInternalAgentScaffoldingUserText(body)) return "internal"; + + // Never re-echo our own Tasks side-post body if it somehow re-enters as user text. + if (isDiscordTasksSidePostContent(body)) return "internal"; + + // Discord mention / bootstrap path (buildDiscordTurnPrompt / buildSentryBootstrapPrompt). + if (isDiscordOriginatedUserPrompt(body)) return "discord"; + + // GitHub PR bridge (buildGitHubTurnPrompt). + if (isGitHubOriginatedUserPrompt(body)) return "github"; + + // Default: T3 client (web/desktop/mobile) or unlabeled API turn. + return "t3-client"; +} + +/** + * User prompts this bot injected into T3 (Discord mention / bootstrap path). + * Never mirror these back into Discord as "External User Input" — they originated here. + */ +export function isDiscordOriginatedUserPrompt(text: string): boolean { + const body = text.trim(); + if (body === "") return false; + // buildDiscordTurnPrompt / buildSentryBootstrapPrompt markers. + if (body.includes("## Discord conversation context")) return true; + // Compact turn format: `rules:` / `req:` lines under the Discord header region. + if (/(?:^|\n)rules:\s+\//u.test(body) && body.includes("## User request")) return true; + if (/(?:^|\n)req:\s+\S/u.test(body) && body.includes("## User request")) return true; + if (body.includes("## Discord investigation bootstrap")) return true; + // Older / compact Discord turn envelopes still include these sections together. + if (body.includes("### Current requester") && body.includes("## User request")) return true; + return false; +} + +/** + * GitHub PR App turns (buildGitHubTurnPrompt). Echo these on Discord; do not treat as Discord. + */ +export function isGitHubOriginatedUserPrompt(text: string): boolean { + const body = text.trim(); + if (body === "") return false; + if (body.includes("## GitHub pull request context")) return true; + // Visible header after HTML comment strip: "From GH [login](...) on [PR #N](...):" + if (/^From GH\s+\[/mu.test(body) || /\nFrom GH\s+\[/u.test(body)) return true; + return false; +} + +/** + * Internal agent / runtime scaffolding that can appear as user-role text in T3 + * (tool completion notices, harness reminders). Not a real human/web/GitHub client. + */ +export function isInternalAgentScaffoldingUserText(text: string): boolean { + const body = text.trim(); + if (body === "") return false; + // Grok / agent harness system reminders (background task completion, etc.). + if (/<\s*system-reminder\b/iu.test(body)) return true; + if (/<\/\s*system-reminder\s*>/iu.test(body)) return true; + // Common body when the wrapper tag is stripped but the notice remains. + if (/^Background task\s+"/iu.test(body) && /completed\s*\(exit code:/iu.test(body)) { + return true; + } + // Other harness / tool XML shells that sometimes land as user-role text. + if (/<\s*system(?:-|\s)?(?:message|context|notification)\b/iu.test(body)) return true; + if (/<\s*tool_(?:result|response|call)\b/iu.test(body)) return true; + return false; +} + +/** + * True when a user-role message must not be mirrored to Discord as External User Input. + * Prefer {@link classifyUserMessageIngress} + whitelist; this remains for call sites/tests. + */ +export function shouldSuppressExternalUserEcho(text: string): boolean { + return !DISCORD_EXTERNAL_ECHO_SURFACES.has(classifyUserMessageIngress(text)); +} + +/** + * Whether Discord should post this user message as External User Input. + * Whitelist: github + t3-client only (never same-surface Discord, never internal). + */ +export function shouldEchoUserMessageToDiscord(input: { + readonly text: string; + readonly messageId: string; + readonly seenUserMessageIds: ReadonlySet | ReadonlyArray; + readonly sentDiscordUserMessageIds: ReadonlySet | ReadonlyArray; +}): boolean { + const seen = + input.seenUserMessageIds instanceof Set + ? input.seenUserMessageIds + : new Set(input.seenUserMessageIds); + const sentByDiscord = + input.sentDiscordUserMessageIds instanceof Set + ? input.sentDiscordUserMessageIds + : new Set(input.sentDiscordUserMessageIds); + if (seen.has(input.messageId) || sentByDiscord.has(input.messageId)) return false; + return DISCORD_EXTERNAL_ECHO_SURFACES.has(classifyUserMessageIngress(input.text)); +} + +export function externalUserMessagesToEcho(input: { + readonly messages: OrchestrationThread["messages"]; + readonly observedInitialUserSnapshot: boolean; + readonly seenUserMessageIds: ReadonlyArray; + readonly sentDiscordUserMessageIds: ReadonlyArray; +}): ReadonlyArray { + if (!input.observedInitialUserSnapshot) return []; + const seen = new Set(input.seenUserMessageIds); + const sentByDiscord = new Set(input.sentDiscordUserMessageIds); + return input.messages.filter( + (message) => + message.role === "user" && + shouldEchoUserMessageToDiscord({ + text: message.text, + messageId: message.id, + seenUserMessageIds: seen, + sentDiscordUserMessageIds: sentByDiscord, + }), + ); +} + +export function summarizeExternalUserInput(text: string): string { + // Drop HTML comment blocks (GitHub PR context) and system-reminder envelopes so + // anything that still slips through the echo filter is less noisy. + return text + .replace(/\s*/gu, "") + .replace(/<\s*system-reminder\b[^>]*>[\s\S]*?<\/\s*system-reminder\s*>/giu, "") + .replace(/\n{3,}/gu, "\n\n") + .trim(); +} + +export function threadTitleChangeRequestState( + thread: Pick, + pr: Pick | null | undefined, +): "initialized" | "open" | "merged" | "closed" | null { + const hasAssistantMessage = thread.messages.some((message) => message.role === "assistant"); + if (!hasAssistantMessage) return null; + if (thread.branch === null || thread.worktreePath === null) return null; + return pr?.state ?? "initialized"; +} + +/** + * Sticky PR evidence for Discord title badges. + * Transient null lookups must not erase a previously observed PR — that is the + * ▫️ ⇄ ❌🔀 flip-flop when VCS stream starts before remote is warm, or GH rate-limits. + */ +export type StickyTitlePrEvidence = Pick & { + readonly hasFailingChecks?: boolean; +}; + +export function toStickyTitlePrEvidence( + pr: + | (Pick & { + readonly hasFailingChecks?: boolean | undefined; + }) + | null + | undefined, +): StickyTitlePrEvidence | null { + if (pr === null || pr === undefined) return null; + return { + state: pr.state, + number: pr.number, + ...(pr.hasFailingChecks === undefined ? {} : { hasFailingChecks: pr.hasFailingChecks }), + }; +} + +/** + * Merge PR observations into sticky evidence. + * - null/undefined next = unknown (keep previous) + * - same open PR keeps hasFailingChecks=true until an explicit false arrives + */ +export function mergeStickyTitlePr( + previous: StickyTitlePrEvidence | null, + next: StickyTitlePrEvidence | null | undefined, +): StickyTitlePrEvidence | null { + if (next === null || next === undefined) return previous; + if (previous === null) return next; + + const keepFailing = + previous.number === next.number && + previous.state === "open" && + next.state === "open" && + previous.hasFailingChecks === true && + next.hasFailingChecks !== false; + + return { + state: next.state, + number: next.number, + ...(keepFailing + ? { hasFailingChecks: true } + : next.hasFailingChecks === undefined + ? {} + : { hasFailingChecks: next.hasFailingChecks }), + }; +} + +/** + * Single place to decide PR evidence for Discord title sync. + * + * Prefer one warm source (VCS remote status) once observed; optional GH branch + * lookup only bootstraps when remote has not been observed yet. Never treat + * "remote not loaded" as "no PR" — that paints ▫️ and thrash-renames old threads. + */ +export function resolveDiscordTitlePrEvidence(input: { + readonly stickyPr: StickyTitlePrEvidence | null; + readonly statusPr: StickyTitlePrEvidence | null | undefined; + readonly remoteStatusObserved: boolean; + readonly branchLookupPr?: StickyTitlePrEvidence | null; + readonly branchLookupCompleted?: boolean; +}): { + readonly stickyPr: StickyTitlePrEvidence | null; + readonly effectivePr: StickyTitlePrEvidence | null; + /** True only when we may paint the no-PR `initialized` (▫️) badge. */ + readonly canApplyNoPrBadge: boolean; +} { + let sticky = input.stickyPr; + // Positive observations always stick. + sticky = mergeStickyTitlePr(sticky, input.statusPr); + if (input.branchLookupCompleted === true) { + sticky = mergeStickyTitlePr(sticky, input.branchLookupPr ?? null); + } + + const canApplyNoPrBadge = + sticky === null && (input.remoteStatusObserved || input.branchLookupCompleted === true); + + return { + stickyPr: sticky, + effectivePr: sticky, + canApplyNoPrBadge, + }; +} + +/** PR / change-request title column (optional). */ +export type DiscordThreadPrBadgeState = "initialized" | "open" | "merged" | "closed" | null; + +/** Working / lifecycle title column (optional). */ +export type DiscordThreadActivityBadgeState = "busy" | "wake-required" | null; + +/** + * Dual-slot Discord title badges (matches T3 client: PR icon + status pill). + * Layout: `| PR | activity | Title` — each slot optional. + */ +export type DiscordThreadTitleBadges = { + readonly pr: DiscordThreadPrBadgeState; + readonly activity: DiscordThreadActivityBadgeState; +}; + +/** + * @deprecated Exclusive single-slot union kept for older tests/callers. + * Prefer `DiscordThreadTitleBadges` / dual-slot helpers. + */ +export type DiscordThreadTitleBadgeState = + | DiscordThreadActivityBadgeState + | DiscordThreadPrBadgeState; + +/** + * Whether Discord is actively Working (turn in progress). + * Mirrors `isTurnInProgress` without the wake-required gate. + */ +function isDiscordThreadTurnBusy(input: { + readonly sessionStatus: string | null | undefined; + readonly latestTurnState?: string | null | undefined; +}): boolean { + if (input.latestTurnState === "running") return true; + if ( + (input.latestTurnState === null || input.latestTurnState === undefined) && + (input.sessionStatus === "running" || input.sessionStatus === "starting") + ) { + return true; + } + return false; +} + +/** + * Working/lifecycle column only (busy / wake-required). Independent of PR badges. + */ +export function resolveDiscordThreadActivityBadgeState(input: { + readonly sessionStatus: string | null | undefined; + readonly activeTurnId?: string | null | undefined; + readonly latestTurnState?: string | null | undefined; + readonly latestTurnCompletedAt?: string | null | undefined; +}): DiscordThreadActivityBadgeState { + if ( + sessionNeedsWakeUp({ + sessionStatus: input.sessionStatus, + activeTurnId: input.activeTurnId, + latestTurnState: input.latestTurnState, + latestTurnCompletedAt: input.latestTurnCompletedAt, + }) + ) { + return "wake-required"; + } + if ( + isDiscordThreadTurnBusy({ + sessionStatus: input.sessionStatus, + latestTurnState: input.latestTurnState, + }) + ) { + return "busy"; + } + return null; +} + +/** + * @deprecated Prefer dual-slot resolve (`activity` + `pr` separately). + * Returns activity if present, otherwise the PR state (legacy exclusive behavior). + */ +export function resolveDiscordThreadTitleBadgeState(input: { + readonly sessionStatus: string | null | undefined; + readonly activeTurnId?: string | null | undefined; + readonly latestTurnState?: string | null | undefined; + readonly latestTurnCompletedAt?: string | null | undefined; + readonly prState: DiscordThreadPrBadgeState; +}): DiscordThreadTitleBadgeState { + const activity = resolveDiscordThreadActivityBadgeState(input); + if (activity !== null) return activity; + return input.prState; +} + +/** Compose PR + activity for the current snapshot. */ +export function resolveDiscordThreadTitleBadges(input: { + readonly sessionStatus: string | null | undefined; + readonly activeTurnId?: string | null | undefined; + readonly latestTurnState?: string | null | undefined; + readonly latestTurnCompletedAt?: string | null | undefined; + readonly prState: DiscordThreadPrBadgeState; +}): DiscordThreadTitleBadges { + return { + pr: input.prState, + activity: resolveDiscordThreadActivityBadgeState(input), + }; +} + +/** Rank used to prevent PR badge flip-flops (higher = more advanced / sticky). */ +export function discordThreadTitleBadgeRank(state: DiscordThreadTitleBadgeState): number { + switch (state) { + case null: + return 0; + case "initialized": + return 1; + case "open": + return 2; + case "closed": + return 3; + case "merged": + return 4; + case "busy": + return 50; + case "wake-required": + return 100; + } +} + +/** + * Parse dual-slot badges from a Discord thread title. + * Accepts new `PR activity Title` order and legacy single-slot / activity-first titles. + */ +export function parseDiscordThreadTitleBadges( + title: string | null | undefined, +): DiscordThreadTitleBadges { + if (title === null || title === undefined || title.trim() === "") { + return { pr: null, activity: null }; + } + let rest = title.trimStart(); + let pr: DiscordThreadPrBadgeState = null; + let activity: DiscordThreadActivityBadgeState = null; + + const takePr = (): boolean => { + // Open + failing checks: standalone ❌ (preferred) or legacy "❌ 🔀". + if (/^❌\s+🔀\s+/u.test(rest) || /^❌\s+/u.test(rest)) { + pr = "open"; + rest = rest.replace(/^❌\s+🔀\s+/u, "").replace(/^❌\s+/u, ""); + return true; + } + if (/^🔀\s+/u.test(rest)) { + pr = "open"; + rest = rest.replace(/^🔀\s+/u, ""); + return true; + } + if (/^✔️\s+/u.test(rest) || /^✓\s+/u.test(rest)) { + pr = "merged"; + rest = rest.replace(/^(?:✔️|✓)\s+/u, ""); + return true; + } + if (/^✖️\s+/u.test(rest) || /^✕\s+/u.test(rest)) { + pr = "closed"; + rest = rest.replace(/^(?:✖️|✕)\s+/u, ""); + return true; + } + if ( + /^▫️\s+/u.test(rest) || + /^·\s+/u.test(rest) || + /^🍴\s+/u.test(rest) || + /^✅\s+/u.test(rest) + ) { + pr = "initialized"; + rest = rest.replace(/^(?:▫️|·|🍴|✅)\s+/u, ""); + return true; + } + return false; + }; + + const takeActivity = (): boolean => { + if (/^❗\s+/u.test(rest)) { + activity = "wake-required"; + rest = rest.replace(/^❗\s+/u, ""); + return true; + } + if (/^⏳\s+/u.test(rest)) { + activity = "busy"; + rest = rest.replace(/^⏳\s+/u, ""); + return true; + } + return false; + }; + + // Preferred order: PR then activity. Also accept legacy activity-first. + if (!takePr()) { + takeActivity(); + takePr(); + } else { + takeActivity(); + } + + return { pr, activity }; +} + +/** + * Parse a single exclusive badge (legacy). Prefer `parseDiscordThreadTitleBadges`. + * When both slots are present, returns the PR badge (activity is independent now). + */ +export function parseDiscordThreadTitleBadgeState( + title: string | null | undefined, +): DiscordThreadTitleBadgeState { + const badges = parseDiscordThreadTitleBadges(title); + // Prefer PR for sticky demotion checks; fall back to activity for pure activity titles. + if (badges.pr !== null) return badges.pr; + return badges.activity; +} + +/** + * Whether applying PR badge `next` over `current` is allowed. + * Never demote open/merged/closed → initialized/plain from transient missing PR data. + * Activity badges are independent and not governed by this helper. + */ +export function shouldApplyDiscordThreadPrBadge( + current: DiscordThreadPrBadgeState, + next: DiscordThreadPrBadgeState, +): boolean { + if (current === next) return true; + if (current === "closed" && next === "open") return true; + if ( + current === "merged" && + (next === "open" || next === "closed" || next === "initialized" || next === null) + ) { + return false; + } + if ( + (current === "open" || current === "merged" || current === "closed") && + (next === "initialized" || next === null) + ) { + return false; + } + return discordThreadTitleBadgeRank(next) >= discordThreadTitleBadgeRank(current); +} + +/** + * @deprecated Dual-slot: use `shouldApplyDiscordThreadPrBadge` for PR; activity always applies. + * Kept for older tests that still pass exclusive states. + */ +export function shouldApplyDiscordThreadTitleBadge( + current: DiscordThreadTitleBadgeState, + next: DiscordThreadTitleBadgeState, +): boolean { + if (current === next) return true; + // Activity transitions always allowed (legacy exclusive API). + if ( + next === "wake-required" || + next === "busy" || + current === "wake-required" || + current === "busy" + ) { + return true; + } + return shouldApplyDiscordThreadPrBadge(current, next); +} + +/** + * Desired Discord title after a prior plain-title settle. + * + * Composes optional PR + activity badges (client-style dual indicators). + * Returns null when the settled title already matches, or when applying would + * demote a stronger PR badge without a usable PR replacement. + * + * `canApplyNoPrBadge` must be true before painting ▫️ from a null PR — unknown + * remote status is not evidence of "no PR". + */ +export function resolveSettledDiscordThreadTitleUpgrade(input: { + readonly thread: Pick & { + readonly session?: OrchestrationThread["session"]; + readonly latestTurn?: OrchestrationThread["latestTurn"]; + }; + readonly mirroredThreadTitle: string | null; + readonly attemptedThreadTitle: string | null; + readonly cachedPr: + | (Pick & { + readonly number?: number; + readonly hasFailingChecks?: boolean; + }) + | null + | undefined; + /** When false, skip upgrades that would paint ▫️ from null/unknown PR evidence. */ + readonly canApplyNoPrBadge?: boolean; +}): string | null { + const rawPrState = threadTitleChangeRequestState(input.thread, input.cachedPr); + const prState: DiscordThreadPrBadgeState = + rawPrState === "initialized" && input.canApplyNoPrBadge === false ? null : rawPrState; + const activity = resolveDiscordThreadActivityBadgeState({ + sessionStatus: input.thread.session?.status ?? null, + activeTurnId: input.thread.session?.activeTurnId ?? null, + latestTurnState: input.thread.latestTurn?.state ?? null, + latestTurnCompletedAt: input.thread.latestTurn?.completedAt ?? null, + }); + + const mirroredBadges = parseDiscordThreadTitleBadges(input.mirroredThreadTitle); + const currentBadges = + mirroredBadges.pr !== null || mirroredBadges.activity !== null + ? mirroredBadges + : parseDiscordThreadTitleBadges(input.attemptedThreadTitle); + + // Sticky PR: refuse demotion; keep current PR column when next would weaken it. + const prAllowed = shouldApplyDiscordThreadPrBadge(currentBadges.pr, prState); + const appliedPr = prAllowed ? prState : currentBadges.pr; + + // Demotion refused and activity unchanged → leave the mirrored title alone + // (preserves ❌ 🔀 etc. without re-decorating from a null PR cache). + if (!prAllowed && activity === currentBadges.activity) { + return null; + } + + // When keeping a sticky open PR without fresh PR evidence, preserve failing-check + // decoration already on the Discord title (standalone ❌ or legacy ❌ 🔀). + const mirroredTitle = input.mirroredThreadTitle ?? ""; + const mirroredHasFailingOpen = + appliedPr === "open" && (/^❌\s+🔀\s+/u.test(mirroredTitle) || /^❌\s+/u.test(mirroredTitle)); + const hasFailingChecks = + appliedPr === "open" && + (input.cachedPr?.hasFailingChecks === true || (!prAllowed && mirroredHasFailingOpen)); + + const desiredTitle = decorateDiscordThreadTitle( + input.thread.title, + { + pr: appliedPr, + activity, + hasFailingChecks, + }, + 100, + ); + // Only treat successfully mirrored titles as settled. + if (desiredTitle === input.mirroredThreadTitle) { + return null; + } + // Nothing to add and Discord title is already plain → wait for PR evidence / assistant. + // Still allow clearing a previous activity-only badge (⏳ → plain). + if ( + appliedPr === null && + activity === null && + currentBadges.pr === null && + currentBadges.activity === null + ) { + return null; + } + return desiredTitle; +} + +/** + * Temporary activity badge only (busy / wake-required). PR stays independent. + */ +export function resolveTemporaryDiscordThreadTitleBadge(input: { + readonly sessionStatus: string | null | undefined; + readonly activeTurnId?: string | null | undefined; + readonly latestTurnState?: string | null | undefined; + readonly latestTurnCompletedAt?: string | null | undefined; +}): DiscordThreadActivityBadgeState { + return resolveDiscordThreadActivityBadgeState(input); +} + +/** + * Desired-state plan for Discord thread renames. + * + * Title apply must be durable and coalesced: + * - Never apply a previously captured thread snapshot (VCS races re-painted ⏳ + * after settle when the callback held a stale "running" thread). + * - On REST failure / rate-limit / interrupt, keep `pending` so a later tick retries. + * - Only clear pending when Discord already shows that exact name. + */ +export function planDiscordThreadTitleApply(input: { + readonly mirroredThreadTitle: string | null; + readonly pendingDesiredThreadTitle: string | null; + /** Freshly computed desired name, or null when compute has nothing new. */ + readonly computedDesiredTitle: string | null; +}): { + readonly pendingDesiredThreadTitle: string | null; + readonly applyTitle: string | null; +} { + if (input.computedDesiredTitle !== null) { + if (input.computedDesiredTitle === input.mirroredThreadTitle) { + return { pendingDesiredThreadTitle: null, applyTitle: null }; + } + return { + pendingDesiredThreadTitle: input.computedDesiredTitle, + applyTitle: input.computedDesiredTitle, + }; + } + // No fresh compute — retry a prior failed apply if Discord still lags. + if ( + input.pendingDesiredThreadTitle !== null && + input.pendingDesiredThreadTitle !== input.mirroredThreadTitle + ) { + return { + pendingDesiredThreadTitle: input.pendingDesiredThreadTitle, + applyTitle: input.pendingDesiredThreadTitle, + }; + } + return { pendingDesiredThreadTitle: null, applyTitle: null }; +} + +/** + * Commit or roll back after a Discord channel rename attempt. + * Success updates mirrored; failure keeps pending so settle/busy is retried. + */ +export function nextMirroredThreadTitleAfterApply(input: { + readonly mirroredThreadTitle: string | null; + readonly pendingDesiredThreadTitle: string | null; + readonly appliedTitle: string; + readonly success: boolean; +}): { + readonly mirroredThreadTitle: string | null; + readonly pendingDesiredThreadTitle: string | null; + readonly attemptedThreadTitle: string | null; +} { + if (!input.success) { + return { + mirroredThreadTitle: input.mirroredThreadTitle, + pendingDesiredThreadTitle: input.appliedTitle, + // Do not poison attempted with a name Discord never accepted. + attemptedThreadTitle: null, + }; + } + const pendingCleared = + input.pendingDesiredThreadTitle === null || + input.pendingDesiredThreadTitle === input.appliedTitle; + return { + mirroredThreadTitle: input.appliedTitle, + pendingDesiredThreadTitle: pendingCleared ? null : input.pendingDesiredThreadTitle, + attemptedThreadTitle: input.appliedTitle, + }; +} + +export function resolveThreadTitleChangeRequestFromStatus( + thread: Pick, + status: VcsStatusResult | null, +): VcsStatusChangeRequest | null { + return resolveThreadChangeRequest(thread.branch, status); +} + +export function resolveThreadChangeRequestLookupCwds( + thread: Pick, + project: { readonly workspaceRoot: string }, +): ReadonlyArray { + return [ + ...new Set( + [thread.worktreePath, project.workspaceRoot].filter( + (value): value is string => value !== null, + ), + ), + ]; +} + +/** + * Structural view of a transcript message for Discord echo labels. + * + * Accepts both plain orchestration messages and identity-overlay `SourceRef` + * provenance without `exactOptionalPropertyTypes` friction on `source`. + */ +type SourceAwareOrchestrationMessage = { + readonly text: string; + readonly attachments?: ReadonlyArray | null | undefined; + readonly source?: + | { + readonly channel?: string | null | undefined; + readonly username?: string | null | undefined; + } + | null + | undefined; +}; + +function echoedUserSourceLabel(message: SourceAwareOrchestrationMessage): string { + const username = message.source?.username?.trim() || "unknown"; + const channel = message.source?.channel?.trim() || "unknown"; + return `from **${username}@${channel}**:`; +} + +export function formatEchoedUserMessage(message: SourceAwareOrchestrationMessage): string { + const body = summarizeExternalUserInput(message.text); + const sourceLabel = echoedUserSourceLabel(message); + const attachmentCount = message.attachments?.length ?? 0; + const attachmentNote = + attachmentCount === 0 + ? "" + : attachmentCount === 1 + ? "\n\n_(1 attachment included in the external input)_" + : `\n\n_(${attachmentCount} attachments included in the external input)_`; + if (body === "") { + return attachmentCount === 0 + ? `${sourceLabel}\n_(empty message)_` + : `${sourceLabel}\n${attachmentNote.trim()}`; + } + return `${sourceLabel}\n${body}${attachmentNote}`; +} + +/** + * Assistant messages that belong to the active/latest turn. + * + * Prefer `turnId` matching so a mid-turn steer (extra user message) does not + * orphan pre-steer assistant progress when computing the Discord stream tip or + * final answer. Fall back to "after last user message" when turn ids are missing. + * + * Critical: if we have a turn id and *no* assistants for that turn yet, return + * empty — do **not** fall back to the previous turn's bubbles. That regression + * re-posted the prior final answer on the next Discord Working tip / finalize. + */ +export function assistantMessagesThisTurn( + thread: OrchestrationThread, +): ReadonlyArray { + const turnId = thread.latestTurn?.turnId ?? thread.session?.activeTurnId ?? null; + const selected = assistantMessagesForDelivery({ + messages: thread.messages.map((message) => ({ + id: message.id, + role: message.role, + turnId: message.turnId, + text: message.text, + })), + turnId, + turnInProgress: isTurnInProgress(thread), + hasLatestTurn: thread.latestTurn !== null && thread.latestTurn !== undefined, + // Callers that need lastFinalized filtering use the delivery path with explicit id. + lastFinalizedAssistantId: null, + }); + const ids = new Set(selected.map((entry) => entry.id)); + return thread.messages.filter((message) => message.role === "assistant" && ids.has(message.id)); +} + +/** + * Skip re-delivering an assistant Discord already finalized (prevents re-posting + * the previous final answer when a new user turn starts before latestTurn advances). + * + * Applies even while turnInProgress: a new turn can be running while the snapshot + * still only exposes the prior finalized bubble (time-query race). + */ +export function shouldSkipAlreadyDeliveredAssistant(input: { + readonly assistantId: string; + readonly lastFinalizedAssistantId: string | null; + readonly turnInProgress: boolean; +}): boolean { + void input.turnInProgress; + if (input.lastFinalizedAssistantId === null) return false; + return input.lastFinalizedAssistantId === input.assistantId; +} + +/** + * Seed Discord stream tip slots when a bridge starts/restarts. + * + * - **Mid-turn / rehydrate:** prior stream message ids stay **active** so we keep + * editing the same tip history. + * - **Fresh Working ack (new user turn):** do **not** re-seed prior tip ids. Those + * messages still hold the previous turn's body; attaching them next to a blank + * Working.. then partially rewriting only the first chunk leaves old bubbles + * visible ("old tip + new answer"). Discard them so the new turn starts clean. + */ +export function seedStreamMessageIds(input: { + readonly workingAckMessageId?: string | null | undefined; + readonly persistedStreamMessageIds: ReadonlyArray; + /** + * When true with a working ack, ignore persisted tip ids (new user turn). + * When false/omitted, rehydrate mid-turn history as active tips. + */ + readonly discardPersistedTips?: boolean; +}): { + readonly discordMessageIds: ReadonlyArray; + readonly staleStreamMessageIds: ReadonlyArray; + /** Persisted tip ids that must be deleted from Discord for a clean new turn. */ + readonly orphanTipIdsToDelete: ReadonlyArray; +} { + const ack = + input.workingAckMessageId !== undefined && + input.workingAckMessageId !== null && + input.workingAckMessageId.trim() !== "" + ? input.workingAckMessageId + : null; + const persisted = uniqueDiscordMessageIds(input.persistedStreamMessageIds).filter( + (id) => id !== ack, + ); + const discardPersisted = input.discardPersistedTips === true && ack !== null; + if (discardPersisted) { + return { + discordMessageIds: [ack], + staleStreamMessageIds: [], + orphanTipIdsToDelete: persisted, + }; + } + return { + discordMessageIds: uniqueDiscordMessageIds([...persisted, ...(ack !== null ? [ack] : [])]), + staleStreamMessageIds: [], + orphanTipIdsToDelete: [], + }; +} + +/** Concatenate this turn's assistant bubbles — for live stream tip + stream-history.md. */ +function turnProgressText(thread: OrchestrationThread): string { + return assistantMessagesThisTurn(thread) + .map((message) => message.text.trimEnd()) + .filter((text) => text.trim() !== "") + .join("\n\n") + .trimEnd(); +} + +/** + * User-visible final Discord answer (not the live tip). + * + * Multi-step agents emit many short interim bubbles ("I'm checking…") then a long + * Findings answer. Joining them all into the "final" post looks like the in-progress + * stream was never cleaned up. Prefer the last bubble when it's substantial; otherwise + * the longest bubble of the turn. + * + * Important: a later medium-length delivery (draft PR summary, approach notes) must + * not lose to an earlier longer Findings bubble — that buried PR links in + * stream-history.md only (see the related Discord discussion). + */ +export function finalAnswerText(thread: OrchestrationThread): string { + const texts = assistantMessagesThisTurn(thread) + .map((message) => message.text.trimEnd()) + .filter((text) => text.trim() !== ""); + if (texts.length === 0) return ""; + if (texts.length === 1) return texts[0]!; + + const last = texts[texts.length - 1]!; + const longest = texts.reduce((a, b) => (a.length >= b.length ? a : b)); + + // True short trailer after a long Findings (e.g. ~120 chars after ~3200) → keep Findings. + // Absolute cap matters: 969-char draft PR after 2811-char analysis is *not* a trailer + // (old ratio-only check used 0.5 and discarded the PR). + const SHORT_TRAILER_MAX_CHARS = 400; + if ( + longest.length >= 800 && + last.length < SHORT_TRAILER_MAX_CHARS && + last.length < longest.length * 0.35 + ) { + return longest; + } + // Otherwise prefer the last bubble (natural end of the turn). + return last; +} + +function allStreamIds(state: BridgeState): ReadonlyArray { + return [...new Set([...state.discordMessageIds, ...state.staleStreamMessageIds])]; +} + +function formatMarkdownLocalFileRefForDiscord(input: { + readonly ref: MarkdownLocalFileRef; + readonly githubUrlsBySrc: ReadonlyMap; + readonly attachedFileNames?: ReadonlySet | undefined; + readonly oversizedByName?: ReadonlySet | undefined; +}): string { + const display = + input.ref.label.trim() !== "" ? input.ref.label : fileNameForLocalFileRef(input.ref); + const githubUrl = input.githubUrlsBySrc.get(input.ref.target); + if (githubUrl) { + return `[${display}](${githubUrl})`; + } + + const uploadName = fileNameForLocalFileRef(input.ref); + if (input.oversizedByName?.has(uploadName)) { + return `${display} (too large to attach in Discord)`; + } + if (input.attachedFileNames?.has(uploadName)) { + return `${display} (attached below)`; + } + if (input.attachedFileNames || input.oversizedByName) { + return `${display} (attachment unavailable)`; + } + return input.ref.match; +} + +export function rewriteMarkdownLocalFileLinksForDiscord(input: { + readonly text: string; + readonly githubUrlsBySrc: ReadonlyMap; + readonly attachedFileNames?: ReadonlySet | undefined; + readonly oversizedByName?: ReadonlySet | undefined; +}): string { + return replaceMarkdownLocalFileLinks(input.text, (ref) => + formatMarkdownLocalFileRefForDiscord({ + ref, + githubUrlsBySrc: input.githubUrlsBySrc, + attachedFileNames: input.attachedFileNames, + oversizedByName: input.oversizedByName, + }), + ); +} + +interface InlinePathCodeSpanRef { + readonly match: string; + readonly token: string; +} + +const INLINE_PATH_CODE_SPAN = + /`([^`\n]*\/[^`\n]*\.[A-Za-z0-9_-]{1,16}(?::\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)?)`/gu; + +function extractInlinePathCodeSpanRefs(text: string): ReadonlyArray { + const refs: InlinePathCodeSpanRef[] = []; + const seen = new Set(); + for (const match of text.matchAll(INLINE_PATH_CODE_SPAN)) { + const full = match[0]; + const token = match[1]?.trim() ?? ""; + if (full === undefined || token === "" || seen.has(full)) continue; + seen.add(full); + refs.push({ match: full, token }); + } + return refs; +} + +const resolveGitHubLinksForInlinePathCodeSpans = ( + refs: ReadonlyArray, + cwd: string | null, +) => + Effect.tryPromise({ + try: async () => { + if (cwd === null) return new Map(); + const urls = new Map(); + const repoContextCache = new Map(); + for (const ref of refs) { + const url = await resolveGitHubBlobUrlForPathReference(ref.token, { + cwd, + repoContextCache, + }); + if (url) { + urls.set(ref.token, url); + } + } + return urls; + }, + catch: (cause) => cause, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to resolve GitHub links for inline path code spans").pipe( + Effect.andThen(Effect.logError(cause)), + Effect.as(new Map()), + ), + ), + ); + +export function rewriteInlinePathCodeSpansForDiscord(input: { + readonly text: string; + readonly githubUrlsByToken: ReadonlyMap; +}): string { + let out = input.text; + const ordered = [...extractInlinePathCodeSpanRefs(input.text)].sort( + (a, b) => b.match.length - a.match.length, + ); + for (const ref of ordered) { + const githubUrl = input.githubUrlsByToken.get(ref.token); + if (!githubUrl) continue; + out = out.split(ref.match).join(`[\`${ref.token}\`](${githubUrl})`); + } + return out; +} + +const resolveGitHubLinksForMarkdownFiles = (refs: ReadonlyArray) => + Effect.tryPromise({ + try: async () => { + const urls = new Map(); + const repoContextCache = new Map(); + for (const ref of refs) { + const url = await resolveGitHubBlobUrlForLocalPath(ref.target, { repoContextCache }); + if (url) { + urls.set(ref.target, url); + } + } + return urls; + }, + catch: (cause) => cause, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to resolve GitHub links for markdown files").pipe( + Effect.andThen(Effect.logError(cause)), + Effect.as(new Map()), + ), + ), + ); + +/** Text shown while streaming — drop unreadable local markdown image embeds. */ +function streamDisplayText(text: string): string { + const withoutImages = stripMarkdownImages(text, (ref) => isLocalImageSrc(ref.src)); + const stripped = stripMarkdownLocalFileLinks(withoutImages); + const imageCount = extractMarkdownImages(text).filter((ref) => isLocalImageSrc(ref.src)).length; + const fileCount = extractMarkdownLocalFileLinks(text).filter((ref) => + isLocalFileSrc(ref.src), + ).length; + const localCount = imageCount + fileCount; + if (localCount === 0) return stripped; + const note = + localCount === 1 + ? "_(attachment will attach when done)_" + : `_(${localCount} attachments will attach when done)_`; + return stripped.trim() === "" ? note : `${stripped.trimEnd()}\n\n${note}`; +} + +/** + * After a tip break (user message displaced us), only stream the suffix that is + * new relative to the frozen prefix. Prevents re-copying pre-break content below + * the user message while keeping chronological order. + */ +export function activeStreamTipText(fullDisplayText: string, breakPrefix: string): string { + if (breakPrefix === "") return fullDisplayText; + if (fullDisplayText.startsWith(breakPrefix)) { + return fullDisplayText.slice(breakPrefix.length).replace(/^\n+/u, ""); + } + // Progress rewound / rewritten — show full text on the post-break tip. + return fullDisplayText; +} + +/** + * When a human (or other foreign) message displaces the Working tip, freeze only what + * Discord already showed and set the break prefix to that already-shown text. + * + * Bug this prevents: setting breakPrefix to the *incoming* full body (which had never + * been painted) made post-break tipDisplayText empty — Discord kept a bare Working.. + * above the user and never showed the assistant stream after mid-turn mentions. + */ +export function planStreamTipFreezeOnDisplacement(input: { + /** Cumulative assistant text already applied to the tip before this write. */ + readonly previousFullDisplayText: string; + /** Active tip body after prior break prefixes (what freeze should paint). */ + readonly previousTipBody: string; + /** Raw lastAssistantText before this write (kept for stream diffs). */ + readonly previousLastAssistantText: string; +}): { + /** Idle freeze body for the displaced tip, or null to skip the freeze edit. */ + readonly freezeContent: string | null; + /** Break prefix for subsequent post-break tips (already-shown display text only). */ + readonly nextBreakPrefix: string; + /** lastAssistantText after freeze (raw text already shown). */ + readonly nextLastAssistantText: string; +} { + const tipBody = input.previousTipBody.trim(); + // Never freeze an empty Working-only tip as a blank message; drop it and re-show + // the current write fully on a new tip under the user message. + if (tipBody === "" || tipBody === "…") { + return { + freezeContent: null, + nextBreakPrefix: "", + nextLastAssistantText: "", + }; + } + const freezeChunk = + chunkDiscordContent(input.previousTipBody, STREAM_CHUNK_LIMIT).at(-1) ?? input.previousTipBody; + return { + freezeContent: stripWorkingIndicator(freezeChunk), + nextBreakPrefix: input.previousFullDisplayText, + nextLastAssistantText: input.previousLastAssistantText, + }; +} + +/** + * Ensure a live bridge is subscribed for this Discord channel. + * Thin wrapper around {@link BridgeHub.ensure} (singleflight + fiber registry + cap). + */ +export const bridgeThreadToDiscord = (input: { + readonly discordChannelId: string; + readonly t3ThreadId: string; + /** Pre-posted "_Working.._" message — reused as stream tip and deleted on finalize. */ + readonly workingAckMessageId?: string | null; + /** Discord-originated T3 user message ids that may appear immediately after subscribe. */ + readonly sentDiscordUserMessageIds?: ReadonlyArray; + /** Final-only avoids progress chatter for ambient conversational turns. */ + readonly presentationMode?: DiscordBridgePresentationMode; + readonly mode?: BridgeEnsureInput["mode"]; + readonly lastActivityAt?: string; + readonly preferred?: boolean; +}) => + Effect.gen(function* () { + const hub = yield* BridgeHub; + yield* hub.ensure({ + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + ...(input.workingAckMessageId === undefined + ? {} + : { workingAckMessageId: input.workingAckMessageId }), + ...(input.sentDiscordUserMessageIds === undefined + ? {} + : { sentDiscordUserMessageIds: input.sentDiscordUserMessageIds }), + ...(input.presentationMode === undefined ? {} : { presentationMode: input.presentationMode }), + mode: input.mode ?? "interactive", + ...(input.lastActivityAt === undefined ? {} : { lastActivityAt: input.lastActivityAt }), + ...(input.preferred === undefined ? {} : { preferred: input.preferred }), + }); + }); + +/** + * Bridge fiber body — owned by ResponseBridge, started via BridgeHub. + * Exported for {@link BridgeHub} layer wiring only. + */ +export const runBridge = ( + input: BridgeEnsureInput, + ready: Deferred.Deferred, + controlSlot: BridgeControlSlot, +) => + Effect.gen(function* () { + const mode = input.mode ?? "interactive"; + yield* Effect.logInfo("Starting Discord↔T3 bridge", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + mode, + hasWorkingAck: Boolean(input.workingAckMessageId), + presentationMode: input.presentationMode ?? "full", + }); + + const t3 = yield* T3Session; + const rest = yield* DiscordREST; + const discordConfig = yield* DiscordConfig.DiscordConfig; + const links = yield* ThreadLinkStore; + const warmCache = yield* ThreadWarmCacheStore; + const me = yield* rest.getMyUser(); + const botUserId = me.id; + const persistedLink = yield* links.getByDiscordThreadId(input.discordChannelId); + const warmCacheEntry = yield* warmCache + .load(input.t3ThreadId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + // Fresh interactive Working ack = new user turn (not mid-turn steer / rehydrate). + // Do not re-attach previous-turn tip ids — that flashes/keeps old bodies next to + // the new Working bubble until finalize. + const freshWorkingTurn = + mode === "interactive" && + input.workingAckMessageId !== undefined && + input.workingAckMessageId !== null && + input.workingAckMessageId !== ""; + const streamSeed = seedStreamMessageIds({ + workingAckMessageId: input.workingAckMessageId ?? null, + persistedStreamMessageIds: persistedLink?.streamDiscordMessageIds ?? [], + discardPersistedTips: freshWorkingTurn, + }); + const seedLastFinalized = + persistedLink?.lastFinalizedAssistantId ?? warmCacheEntry?.lastFinalizedAssistantId ?? null; + const stateRef = yield* Ref.make({ + ...emptyState({ + workingAckMessageId: freshWorkingTurn ? (input.workingAckMessageId ?? null) : null, + lastFinalizedAssistantId: seedLastFinalized, + }), + discordMessageIds: streamSeed.discordMessageIds, + staleStreamMessageIds: streamSeed.staleStreamMessageIds, + seededWorkingAckPending: freshWorkingTurn, + taskDiscordMessageId: persistedLink?.taskDiscordMessageId ?? null, + // Rehydrate: seed finalizedTurnId later from catch-up / adopt path using durable hints. + sentDiscordUserMessageIds: uniqueMessageIds([ + ...(persistedLink?.sentDiscordUserMessageIds ?? []), + ...(input.sentDiscordUserMessageIds ?? []), + ]), + delivery: initialDeliveryEpochState({ + epoch: freshWorkingTurn ? 1 : 0, + phase: freshWorkingTurn ? "awaiting" : "idle", + lastFinalizedAssistantId: seedLastFinalized, + }), + }); + const latestThreadRef = yield* Ref.make(null); + const latestVcsStatusRef = yield* Ref.make(null); + /** Sticky PR evidence — never cleared by transient null lookups (only force-refresh). */ + const stickyTitlePrRef = yield* Ref.make(null); + /** + * True after VCS stream delivered a real remote payload (remoteUpdated or snapshot + * with remote). local-only events with fabricated pr:null must not count. + */ + const vcsRemoteObservedRef = yield* Ref.make(false); + const vcsStatusSubscriptionRef = yield* Ref.make<{ + readonly cwd: string; + readonly fiber: Fiber.Fiber; + } | null>(null); + + const streamWriteLock = yield* Semaphore.make(1); + const titleSyncLock = yield* Semaphore.make(1); + /** + * Latest desired Discord thread name that has not been confirmed on Discord yet. + * Survives REST failures / secondary timeouts so heartbeat can retry without + * waiting for another T3 snapshot (idle threads used to stay on ⏳ forever). + */ + const pendingDesiredThreadTitleRef = yield* Ref.make(null); + + // Seed title settle cache from Discord's live name so rehydrate demotion guards + // work immediately (in-memory mirrored starts null after every bridge restart). + yield* rest.getChannel(input.discordChannelId).pipe( + Effect.flatMap((channel) => { + const name = "name" in channel && typeof channel.name === "string" ? channel.name : null; + if (name === null || name.trim() === "") return Effect.void; + return Ref.update(stateRef, (current) => ({ + ...current, + mirroredThreadTitle: current.mirroredThreadTitle ?? name, + attemptedThreadTitle: current.attemptedThreadTitle ?? name, + })); + }), + Effect.catchCause((cause) => + Effect.logWarning("Failed to seed Discord thread title from channel", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 200), + }), + ), + ); + // Mutable watermark for trim + warm cache (updated on finalize). + const deliveredMemoryTrim = { + lastFinalizedAssistantId: (persistedLink?.lastFinalizedAssistantId ?? + warmCacheEntry?.lastFinalizedAssistantId ?? + null) as string | null, + }; + const projectThreadForDiscordMemory = (thread: OrchestrationThread): OrchestrationThread => + trimOrchestrationThreadForDiscordMemory({ + thread, + lastFinalizedAssistantId: deliveredMemoryTrim.lastFinalizedAssistantId, + buffer: DISCORD_DELIVERED_MESSAGE_MEMORY_BUFFER, + }); + const persistWarmThreadCache = (thread: OrchestrationThread, sequence: number) => + warmCache + .save({ + threadId: input.t3ThreadId, + snapshotSequence: sequence, + thread: projectThreadForDiscordMemory(thread), + lastFinalizedAssistantId: deliveredMemoryTrim.lastFinalizedAssistantId, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to persist warm thread cache", { + t3ThreadId: input.t3ThreadId, + sequence, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + const persistStreamMessageIds = (ids: ReadonlyArray) => + links.setStreamDiscordMessageIds(input.discordChannelId, uniqueDiscordMessageIds(ids)); + const persistFinalizedAssistant = (assistantId: string) => + Effect.gen(function* () { + deliveredMemoryTrim.lastFinalizedAssistantId = assistantId; + yield* links.updateBridgeHints(input.discordChannelId, { + lastFinalizedAssistantId: assistantId, + streamDiscordMessageIds: [], + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to persist finalize bridge hints").pipe( + Effect.andThen(Effect.logError(cause)), + ), + ), + Effect.asVoid, + ); + + // Best-effort delete of orphaned previous-turn tips so they never sit beside the + // new Working.. as "old body + new answer". + if (streamSeed.orphanTipIdsToDelete.length > 0) { + yield* Effect.logInfo("Deleting leftover stream tips before fresh Working turn", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + count: streamSeed.orphanTipIdsToDelete.length, + ids: streamSeed.orphanTipIdsToDelete, + }); + for (const id of streamSeed.orphanTipIdsToDelete) { + yield* rest.deleteMessage(input.discordChannelId, id).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to delete leftover stream tip", { + id, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + } + } + + yield* persistStreamMessageIds([ + ...streamSeed.discordMessageIds, + ...streamSeed.staleStreamMessageIds, + ]); + yield* links.touch(input.discordChannelId).pipe(Effect.ignore); + + controlSlot.noteSentUserMessageIds = (ids) => + Ref.update(stateRef, (current) => ({ + ...current, + sentDiscordUserMessageIds: uniqueMessageIds([...current.sentDiscordUserMessageIds, ...ids]), + })).pipe(Effect.asVoid); + + controlSlot.adoptWorkingAckMessageId = (messageId) => + Effect.gen(function* () { + if (messageId.trim() === "") return; + // Mid-turn / new user turn on a reused bridge: switch to the new Working tip. + // - Strip Working.. + Stop from prior tips (freeze idle) so they stay as history + // above the human message — do not keep editing them. + // - Clear lastAssistantText so stream/heartbeat paint only under the new tip. + const prior = yield* Ref.get(stateRef); + const next = nextBridgeStateAfterAdoptWorkingAck({ + priorDiscordMessageIds: prior.discordMessageIds, + priorStaleStreamMessageIds: prior.staleStreamMessageIds, + workingAckMessageId: messageId, + }); + const orphanTips = next.orphanTipsToDelete; + const frozenBody = stripWorkingIndicator(prior.lastAssistantText).trim(); + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: next.discordMessageIds, + // Orphans are frozen channel history, not live tips — drop from stale so finalize + // does not delete the pre-steer progress bubble above the human message. + staleStreamMessageIds: next.staleStreamMessageIds.filter( + (id) => !orphanTips.includes(id), + ), + lastAssistantText: next.lastAssistantText, + streamBreakPrefix: next.streamBreakPrefix, + currentTurnId: next.currentTurnId, + t3AssistantMessageId: next.t3AssistantMessageId, + finalizedTurnId: next.finalizedTurnId, + finalDiscordMessageIds: next.finalDiscordMessageIds, + streamHistoryPosted: next.streamHistoryPosted, + postedAttachmentIds: next.postedAttachmentIds, + postedMarkdownImageSrcs: next.postedMarkdownImageSrcs, + postedMarkdownFileSrcs: next.postedMarkdownFileSrcs, + seededWorkingAckPending: next.seededWorkingAckPending, + // New user turn clears prior wake-up notice so a later restart can convert again. + wakeUpNoticePosted: false, + // Structural: bump delivery epoch so finalized prior answer cannot re-stream. + delivery: beginDeliveryEpoch(current.delivery), + })); + // Freeze prior tips: remove Working.. + Stop. Empty Working-only tips are deleted. + if (orphanTips.length > 0) { + const emptyOrphans: string[] = []; + for (const id of orphanTips) { + // Only the last active tip carried streamed prose; earlier slots were chunks. + const isLastActiveTip = + prior.discordMessageIds.length > 0 && + id === prior.discordMessageIds[prior.discordMessageIds.length - 1]; + const body = isLastActiveTip ? frozenBody : ""; + if (body === "" || body === "…") { + emptyOrphans.push(id); + continue; + } + yield* rest + .updateMessage(input.discordChannelId, id, { + ...idleMessageFields(body), + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to freeze prior Working tip on mid-turn ack", { + id, + cause: formatAlertCause(cause, 300), + }).pipe( + Effect.andThen( + Effect.sync(() => { + emptyOrphans.push(id); + }), + ), + ), + ), + Effect.asVoid, + ); + } + if (emptyOrphans.length > 0) { + yield* deleteMessages(emptyOrphans).pipe( + Effect.catchCause(Effect.logWarning), + Effect.asVoid, + ); + } + } + const state = yield* Ref.get(stateRef); + yield* persistStreamMessageIds([ + ...state.discordMessageIds, + ...state.staleStreamMessageIds, + ]); + yield* Effect.logInfo("Adopted fresh Working ack for new Discord turn", { + t3ThreadId: input.t3ThreadId, + workingAckMessageId: messageId, + frozenPriorTips: orphanTips.length, + }); + }).pipe(Effect.asVoid); + + yield* Effect.logInfo("Bridge restored persisted Discord state", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + mode, + workingAckMessageId: input.workingAckMessageId ?? null, + persistedTaskDiscordMessageId: persistedLink?.taskDiscordMessageId ?? null, + lastFinalizedAssistantId: persistedLink?.lastFinalizedAssistantId ?? null, + activeStreamTips: streamSeed.discordMessageIds, + state: summarizeBridgeStateForLog(yield* Ref.get(stateRef)), + }); + yield* Effect.logInfo("Bridge services ready; subscribing to T3 thread", { + botUserId, + t3ThreadId: input.t3ThreadId, + mode, + activeStreamTips: streamSeed.discordMessageIds.length, + }); + + /** + * Latest *content* message id (skip channel-name / pin system messages). + * Limit > 1 so a burst of title renames cannot hide the Working tip. + */ + const latestContentChannelMessage = Effect.gen(function* () { + const messages = yield* rest.listMessages(input.discordChannelId, { limit: 15 }).pipe( + Effect.orElseSucceed( + () => + [] as ReadonlyArray<{ + readonly id: string; + readonly type?: number | null; + readonly author?: { readonly id?: string | null } | null; + }>, + ), + ); + return pickLatestContentMessage(messages); + }); + + /** True when a human (or other non-bot-owned) *content* message is newer than the tip. */ + const isStreamTipDisplaced = (streamTipId: string | null) => + Effect.gen(function* () { + if (streamTipId === null || streamTipId.trim() === "") return false; + const latest = yield* latestContentChannelMessage; + const state = yield* Ref.get(stateRef); + const link = yield* links + .getByDiscordThreadId(input.discordChannelId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + return isStreamTipDisplacedByForeignMessage({ + latestMessageId: latest?.id ?? null, + streamTipId, + latestAuthorIsSelfBot: latest?.author?.id === botUserId, + // Tasks + info pin + stream tips are bot side-channels; never freeze Working under them. + ownedMessageIds: discordBridgeOwnedMessageIds({ + discordMessageIds: state.discordMessageIds, + staleStreamMessageIds: state.staleStreamMessageIds, + finalDiscordMessageIds: state.finalDiscordMessageIds, + taskDiscordMessageId: state.taskDiscordMessageId ?? link?.taskDiscordMessageId, + infoDiscordMessageId: link?.infoDiscordMessageId, + streamDiscordMessageIds: link?.streamDiscordMessageIds ?? [], + }), + }); + }); + + const deleteMessages = (ids: ReadonlyArray) => + Effect.gen(function* () { + const unique = [...new Set(ids.filter((id) => id.trim() !== ""))]; + if (unique.length === 0) return; + yield* Effect.logInfo("Deleting Discord in-progress stream messages", { + count: unique.length, + ids: unique, + }); + for (const id of unique) { + yield* rest.deleteMessage(input.discordChannelId, id).pipe( + Effect.tap(() => Effect.logInfo("Deleted stream message", { id })), + Effect.catchCause((cause) => + Effect.logWarning("Failed to delete stream message", { id }).pipe( + Effect.andThen(Effect.logError(cause)), + ), + ), + Effect.asVoid, + ); + } + }).pipe(Effect.asVoid); + + const clearInProgressMessages = (reason: string) => + streamWriteLock.withPermit( + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const ids = allStreamIds(state); + if (ids.length === 0) return; + + yield* Effect.logInfo("Clearing Discord in-progress stream messages", { + t3ThreadId: input.t3ThreadId, + reason, + count: ids.length, + state: summarizeBridgeStateForLog(state), + }); + yield* deleteMessages(ids); + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: [], + staleStreamMessageIds: [], + streamBreakPrefix: "", + seededWorkingAckPending: false, + })); + yield* persistStreamMessageIds([]); + }).pipe(Effect.asVoid), + ); + + /** + * Replace the latest Working tip with a wake-up notice + Continue button. + * Keeps partial stream prose; removes Working.. and Stop. + */ + const convertWorkingTipsToWakeUp = (reason: string) => + streamWriteLock.withPermit( + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + if (state.wakeUpNoticePosted) return; + const tipIds = allStreamIds(state); + if (tipIds.length === 0) return; + + // Prefer the active tip slot (last discordMessageIds entry); fall back to any open id. + const tipId = + state.discordMessageIds.length > 0 + ? state.discordMessageIds[state.discordMessageIds.length - 1]! + : tipIds[tipIds.length - 1]!; + + const existingContent = yield* rest.getMessage(input.discordChannelId, tipId).pipe( + Effect.map((message) => message.content ?? ""), + Effect.catchCause(() => Effect.succeed(state.lastAssistantText)), + ); + const content = formatWakeUpTipContent( + existingContent.trim() !== "" ? existingContent : state.lastAssistantText, + ); + const fields = wakeUpMessageFields(content, input.t3ThreadId); + + const updated = yield* rest + .updateMessage(input.discordChannelId, tipId, { ...fields }) + .pipe(Effect.result); + + let wakeMessageId = tipId; + if (Result.isFailure(updated)) { + yield* Effect.logWarning("Wake-up tip update failed; posting a new wake-up message", { + t3ThreadId: input.t3ThreadId, + tipId, + cause: formatAlertCause(updated.failure, 300), + }); + // Drop dead tip ids so they do not linger as "stream" state. + yield* deleteMessages(tipIds).pipe(Effect.ignore); + const created = yield* rest.createMessage(input.discordChannelId, { ...fields }); + wakeMessageId = created.id; + } else { + // Older multi-chunk stream slots: leave history, only the tip is wake-up. + // Clear stream tracking so finalize/stop cleanup does not delete the notice. + const otherIds = tipIds.filter((id) => id !== tipId); + if (otherIds.length > 0) { + // Non-tip chunks keep partial prose; strip any stale Stop via idle edit best-effort. + for (const id of otherIds) { + yield* rest.getMessage(input.discordChannelId, id).pipe( + Effect.flatMap((message) => + rest.updateMessage(input.discordChannelId, id, { + ...idleMessageFields(stripWorkingIndicator(message.content ?? "")), + }), + ), + Effect.catchCause(() => Effect.void), + ); + } + } + } + + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: [], + staleStreamMessageIds: [], + streamBreakPrefix: "", + seededWorkingAckPending: false, + wakeUpNoticePosted: true, + adoptedInitialSnapshot: true, + finalDiscordMessageIds: uniqueDiscordMessageIds([ + ...current.finalDiscordMessageIds, + wakeMessageId, + ]), + delivery: { + ...current.delivery, + phase: "finalized" as const, + streamText: "", + settleReady: false, + }, + })); + yield* persistStreamMessageIds([]); + yield* Effect.logInfo("Converted Working tip to wake-up notice", { + t3ThreadId: input.t3ThreadId, + reason, + wakeMessageId, + previousTipIds: tipIds, + }); + }).pipe(Effect.asVoid), + ); + + const persistSentDiscordUserMessageIds = (ids: ReadonlyArray) => + links.setSentDiscordUserMessageIds(input.discordChannelId, uniqueMessageIds(ids)); + + const postExternalUserMessages = ( + messages: ReadonlyArray, + ) => + Effect.gen(function* () { + for (const message of messages) { + yield* rest.createMessage(input.discordChannelId, { + content: formatEchoedUserMessage(message), + }); + } + }).pipe(Effect.asVoid); + + const postOrEditTasks = (content: string, taskKey: string) => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const action = resolveTaskMessageAction({ + taskDiscordMessageId: state.taskDiscordMessageId, + lastTasksKey: state.lastTasksKey, + nextTasksKey: taskKey, + }); + if (action === "skip") return; + + if (action === "update" && state.taskDiscordMessageId !== null) { + yield* rest.updateMessage(input.discordChannelId, state.taskDiscordMessageId, { + content, + }); + yield* Ref.update(stateRef, (current) => ({ + ...current, + lastTasksKey: taskKey, + })); + return; + } + + const created = yield* rest.createMessage(input.discordChannelId, { + content, + }); + yield* Ref.update(stateRef, (current) => ({ + ...current, + taskDiscordMessageId: created.id, + lastTasksKey: taskKey, + })); + yield* links.setTaskDiscordMessageId(input.discordChannelId, created.id); + }).pipe(Effect.asVoid); + + /** + * Download T3 chat image attachments into raw bytes for Discord multipart upload. + */ + const loadAttachmentFiles = ( + attachments: ReadonlyArray, + maxFiles: number, + ) => + Effect.gen(function* () { + const limited = attachments.slice(0, Math.max(0, maxFiles)); + const files: DiscordUploadFile[] = []; + for (const attachment of limited) { + const file = yield* Effect.gen(function* () { + const url = yield* t3.createAttachmentUrl(attachment.id); + const response = yield* Effect.tryPromise({ + try: () => globalThis.fetch(url), + catch: (cause) => cause, + }); + if (!response.ok) { + yield* Effect.logWarning( + `Attachment download failed (${response.status}) for ${attachment.id}`, + ); + return null; + } + const buffer = yield* Effect.tryPromise({ + try: () => response.arrayBuffer(), + catch: (cause) => cause, + }); + return { + name: attachment.name, + mimeType: attachment.mimeType, + data: new Uint8Array(buffer), + } satisfies DiscordUploadFile; + }).pipe( + Effect.catchCause((cause) => + Effect.logError(cause).pipe(Effect.as(null as DiscordUploadFile | null)), + ), + ); + if (file !== null) files.push(file); + } + return files as ReadonlyArray; + }); + + /** + * Load Codex/ACP markdown image embeds as real binary files for Discord multipart. + * 1) Local disk (after stripping attachment:/file://) + * 2) Fallback: T3 assets.createUrl (workspace-file) + HTTP fetch + */ + const loadMarkdownImageFiles = ( + refs: ReadonlyArray, + alreadyPosted: ReadonlyArray, + maxFiles: number, + ) => + Effect.gen(function* () { + const posted = new Set(alreadyPosted); + // Deduplicate by normalized filesystem path so html+link of same file upload once. + const pendingByPath = new Map(); + for (const ref of refs) { + if (!isLocalImageSrc(ref.rawSrc || ref.src)) continue; + if (posted.has(ref.src) || pendingByPath.has(ref.src)) continue; + pendingByPath.set(ref.src, ref); + } + const pending = [...pendingByPath.values()].slice(0, Math.max(0, maxFiles)); + const files: DiscordUploadFile[] = []; + const loadedSrcs: string[] = []; + for (const ref of pending) { + // Grok emits session-relative `images/1.jpg`; Codex usually absolute. + // Resolve before disk read / assets so relative embeds actually upload. + const resolved = + resolveImagePathOnDisk(ref.src) ?? + resolveImagePathOnDisk(ref.rawSrc) ?? + assertFilesystemPath(ref.src); + const filePath = resolved; + const name = fileNameForImageRef(ref); + const mime = guessImageMimeType(filePath); + + yield* Effect.logInfo("Loading markdown image for Discord multipart", { + rawSrc: ref.rawSrc, + filePath, + resolvedFrom: ref.src, + name, + }); + + const fromDisk = yield* Effect.tryPromise({ + try: async () => { + const safePath = assertFilesystemPath(filePath); + const bytes = await NodeFSP.readFile(safePath); + return { + name, + mimeType: mime, + data: new Uint8Array(bytes), + } satisfies DiscordUploadFile; + }, + catch: (cause) => cause, + }).pipe(Effect.option); + + if (fromDisk._tag === "Some") { + files.push(fromDisk.value); + loadedSrcs.push(ref.src); + yield* Effect.logInfo("Loaded image from disk for Discord", { + filePath, + bytes: fromDisk.value.data.byteLength, + }); + continue; + } + + const fromAsset = yield* Effect.gen(function* () { + // Assets API needs a workspace-relative path; absolute agent paths rarely work. + // Prefer original relative src for workspace lookup when we failed disk resolve. + const assetPath = assertFilesystemPath(ref.src); + const url = yield* t3.createWorkspaceFileUrl({ + threadId: input.t3ThreadId as ThreadId, + path: assetPath, + }); + const response = yield* Effect.tryPromise({ + try: () => globalThis.fetch(url), + catch: (cause) => cause, + }); + if (!response.ok) { + yield* Effect.logWarning( + `Asset image download failed (${response.status}) for ${assetPath}`, + ); + return null as DiscordUploadFile | null; + } + const buffer = yield* Effect.tryPromise({ + try: () => response.arrayBuffer(), + catch: (cause) => cause, + }); + return { + name, + mimeType: mime, + data: new Uint8Array(buffer), + } satisfies DiscordUploadFile; + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning( + `Could not load markdown image for Discord (disk+asset): raw=${ref.rawSrc} path=${filePath}`, + ); + yield* Effect.logError(cause); + return null as DiscordUploadFile | null; + }), + ), + ); + + if (fromAsset !== null) { + files.push(fromAsset); + loadedSrcs.push(ref.src); + yield* Effect.logInfo("Loaded image via T3 assets for Discord", { + filePath, + bytes: fromAsset.data.byteLength, + }); + } + } + return { + files: files as ReadonlyArray, + loadedSrcs: loadedSrcs as ReadonlyArray, + }; + }); + + const loadMarkdownLinkedFiles = ( + refs: ReadonlyArray, + alreadyPosted: ReadonlyArray, + maxFiles: number, + worktreePath: string | null, + ) => + Effect.gen(function* () { + const posted = new Set(alreadyPosted); + const pendingByPath = new Map(); + for (const ref of refs) { + if (!isLocalFileSrc(ref.rawSrc || ref.src)) continue; + if (posted.has(ref.src) || pendingByPath.has(ref.src)) continue; + pendingByPath.set(ref.src, ref); + } + const pending = [...pendingByPath.values()].slice(0, Math.max(0, maxFiles)); + const files: DiscordUploadFile[] = []; + const loadedSrcs: string[] = []; + for (const ref of pending) { + // Agents write worktree-relative paths (e.g. `.plans/note.md`). Resolve + // against the thread worktree before reading — bot cwd is not the project. + const resolved = + resolveLocalFilePathOnDisk(ref.src, worktreePath) ?? + resolveLocalFilePathOnDisk(ref.rawSrc, worktreePath) ?? + assertFilesystemFilePath(ref.src); + const filePath = resolved; + const name = fileNameForLocalFileRef(ref); + const mime = guessFileMimeType(filePath); + + yield* Effect.logInfo("Loading markdown file for Discord multipart", { + rawSrc: ref.rawSrc, + filePath, + worktreePath, + name, + }); + + const fromDisk = yield* Effect.tryPromise({ + try: async () => { + const safePath = assertFilesystemFilePath(filePath); + const bytes = await NodeFSP.readFile(safePath); + return { + name, + mimeType: mime, + data: new Uint8Array(bytes), + } satisfies DiscordUploadFile; + }, + catch: (cause) => cause, + }).pipe(Effect.option); + + if (fromDisk._tag === "Some") { + files.push(fromDisk.value); + loadedSrcs.push(ref.src); + continue; + } + + // Asset URL preview rejects most text types (.md). Prefer a worktree-relative + // path for the RPC so the server can resolve under the project root. + const assetPathForUrl = (() => { + const raw = assertFilesystemFilePath(ref.src); + if (!NodePath.isAbsolute(raw)) return raw.replace(/^\.\//u, ""); + const root = worktreePath?.trim() ?? ""; + if (root !== "" && raw.startsWith(root)) { + const rel = raw.slice(root.length).replace(/^[/\\]+/u, ""); + if (rel !== "") return rel; + } + return raw; + })(); + + const fromAsset = yield* Effect.gen(function* () { + const url = yield* t3.createWorkspaceFileUrl({ + threadId: input.t3ThreadId as ThreadId, + path: assetPathForUrl, + }); + const response = yield* Effect.tryPromise({ + try: () => globalThis.fetch(url), + catch: (cause) => cause, + }); + if (!response.ok) { + yield* Effect.logWarning( + `Asset file download failed (${response.status}) for ${assetPathForUrl}`, + ); + return null as DiscordUploadFile | null; + } + const buffer = yield* Effect.tryPromise({ + try: () => response.arrayBuffer(), + catch: (cause) => cause, + }); + return { + name, + mimeType: mime, + data: new Uint8Array(buffer), + } satisfies DiscordUploadFile; + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning( + `Could not load markdown file for Discord (disk+asset): raw=${ref.rawSrc} path=${filePath} worktree=${worktreePath ?? "null"}`, + ); + yield* Effect.logError(cause); + return null as DiscordUploadFile | null; + }), + ), + ); + + if (fromAsset !== null) { + files.push(fromAsset); + loadedSrcs.push(ref.src); + } + } + return { + files: files as ReadonlyArray, + loadedSrcs: loadedSrcs as ReadonlyArray, + }; + }); + + const splitFilesForDiscordUpload = (files: ReadonlyArray) => { + const batches: DiscordUploadFile[][] = []; + const oversized: DiscordUploadFile[] = []; + let current: DiscordUploadFile[] = []; + let currentBytes = 0; + + for (const file of files) { + if (file.data.byteLength > DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES) { + oversized.push(file); + continue; + } + const nextTooLarge = + current.length > 0 && + currentBytes + file.data.byteLength > DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES; + const nextTooMany = current.length >= DISCORD_MAX_FILES_PER_MESSAGE; + if (nextTooLarge || nextTooMany) { + batches.push(current); + current = []; + currentBytes = 0; + } + current.push(file); + currentBytes += file.data.byteLength; + } + + if (current.length > 0) { + batches.push(current); + } + + return { + batches: batches as ReadonlyArray>, + oversized: oversized as ReadonlyArray, + }; + }; + + /** + * Create a Discord message. Binary files use native multipart FormData + fetch + * (HTTP/1.1). dfx `withFiles` goes through Effect/Undici HTTP2 and dies with + * NGHTTP2_PROTOCOL_ERROR on ~1MB payloads — never updateMessage with files. + * + * Empty contentLength is expected for image-only replies after markdown embeds + * are stripped; Discord allows content="" when files are present. + */ + const createMessageWithFiles = (content: string, files: ReadonlyArray) => + Effect.gen(function* () { + const body = stripWorkingIndicator(content); + if (files.length === 0) { + const message = yield* rest.createMessage(input.discordChannelId, { + content: body.trim() !== "" ? body : "\u200b", + }); + return { id: message.id as string }; + } + + yield* Effect.logInfo("Discord multipart createMessage (native FormData)", { + contentLen: body.length, + imageOnly: body.trim() === "", + files: files.map((f) => ({ + name: f.name, + mimeType: f.mimeType, + bytes: f.data.byteLength, + })), + }); + + const message = yield* Effect.tryPromise({ + try: () => + createMessageWithAttachments({ + baseUrl: discordConfig.rest.baseUrl, + botToken: Redacted.value(discordConfig.token), + channelId: input.discordChannelId, + content: body, + files, + }), + catch: (cause) => + cause instanceof DiscordUploadError + ? cause + : new DiscordUploadError(cause instanceof Error ? cause.message : String(cause)), + }); + + yield* Effect.logInfo("Discord multipart createMessage ok", { + messageId: message.id, + fileCount: files.length, + }); + return { id: message.id }; + }); + + const currentTurnToolCallCount = (thread: OrchestrationThread | null): number => { + if (thread === null) return 0; + // Latest in-progress work segment only (after last settled assistant for this turn). + return countTurnToolCalls( + thread.activities, + thread.latestTurn?.turnId ?? null, + thread.messages.map((message) => ({ + role: message.role, + turnId: message.turnId, + streaming: message.streaming, + createdAt: message.createdAt, + })), + ); + }; + + /** + * In-progress stream only: + * - edit latest bot message while it remains the channel tip and under 2000 chars + * - if someone posts after us, open a new message (old tip stays tracked for delete) + * - tip ends with italic _Working.._ (optional · N tool calls on the same line) + * + * On turn complete: stream messages are deleted, archived as stream-history.md, + * and the final answer is posted as Discord markdown (links live; no ASCII tables), + * with a T3 deep link always on the stats footer. + */ + const postOrEditAssistantUnlocked = (args: { + readonly turnId: string | null; + readonly t3MessageId: string; + readonly text: string; + readonly streaming: boolean; + readonly images: ReadonlyArray; + readonly worktreePath: string | null; + }) => + Effect.gen(function* () { + const { turnId, t3MessageId, text, streaming, images, worktreePath } = args; + let state = yield* Ref.get(stateRef); + const reopensFinalizedDelivery = shouldReopenFinalizedDelivery({ + finalizedTurnId: state.finalizedTurnId, + currentAssistantMessageId: state.t3AssistantMessageId, + turnId, + nextAssistantMessageId: t3MessageId, + }); + + if ( + state.currentTurnId === turnId && + state.t3AssistantMessageId === t3MessageId && + state.lastAssistantText === text && + (state.discordMessageIds.length > 0 || state.finalDiscordMessageIds.length > 0) && + streaming + ) { + return; + } + + const markdownImages = extractMarkdownImages(text).filter((ref) => + isLocalImageSrc(ref.src), + ); + const pendingMarkdown = markdownImages.filter( + (ref) => !state.postedMarkdownImageSrcs.includes(ref.src), + ); + const markdownFiles = extractMarkdownLocalFileLinks(text).filter((ref) => + isLocalFileSrc(ref.src), + ); + const pendingMarkdownFiles = markdownFiles.filter( + (ref) => !state.postedMarkdownFileSrcs.includes(ref.src), + ); + + // Finished turn with same text already finalized and attachments posted — skip. + if ( + !streaming && + !reopensFinalizedDelivery && + state.finalizedTurnId === turnId && + state.lastAssistantText === text && + unpostedAttachments(images, state.postedAttachmentIds).length === 0 && + pendingMarkdown.length === 0 && + pendingMarkdownFiles.length === 0 + ) { + return; + } + + // After finalize, only late-arriving images are handled (no more stream edits). + if (!streaming && !reopensFinalizedDelivery && state.finalizedTurnId === turnId) { + yield* finalizeAssistantMessage({ + turnId, + t3MessageId, + text, + images, + streamHistoryText: "", + worktreePath, + }); + return; + } + + // Structural gate: never stream after this epoch finalized (avoids final+Working mess). + if (streaming && state.delivery.phase === "finalized" && !reopensFinalizedDelivery) { + yield* Effect.logInfo("Skipping stream write; delivery epoch already finalized", { + t3ThreadId: input.t3ThreadId, + turnId, + t3MessageId, + epoch: state.delivery.epoch, + }); + return; + } + + if (streaming) { + const startsNewDelivery = startsNewStreamDelivery({ + currentTurnId: state.currentTurnId, + nextTurnId: turnId, + reopensFinalizedDelivery, + seededWorkingAckPending: state.seededWorkingAckPending, + }); + + // Queue drain / new Working: promote the prior tip body to a durable final + // before we wipe tip state. Otherwise the real answer only lived as an + // editable tip and vanishes when the next epoch starts (t3vm + // 16feaadd… / segment:5 lost under Working). + if ( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery, + lastAssistantText: state.lastAssistantText, + t3AssistantMessageId: state.t3AssistantMessageId, + finalizedTurnId: state.finalizedTurnId, + currentTurnId: state.currentTurnId, + }) + ) { + const priorText = state.lastAssistantText; + const priorAssistantId = state.t3AssistantMessageId!; + const priorTurnId = state.currentTurnId; + yield* Effect.logInfo( + "Finalizing prior stream tip before new delivery (queue-drain / turn boundary)", + { + t3ThreadId: input.t3ThreadId, + priorTurnId, + priorAssistantId, + nextTurnId: turnId, + textLen: priorText.length, + }, + ); + yield* finalizeAssistantMessage({ + turnId: priorTurnId, + t3MessageId: priorAssistantId, + text: priorText, + images: [], + streamHistoryText: priorText, + worktreePath, + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Failed to finalize prior stream before new delivery", { + t3ThreadId: input.t3ThreadId, + priorTurnId, + priorAssistantId, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + // Re-read state after finalize (tips deleted, lastFinalized updated). + const afterPriorFinalize = yield* Ref.get(stateRef); + state = afterPriorFinalize; + } + + // Multi-step agents open a new assistant id per bubble while the turn runs. + // Keep the same Discord tip(s) and edit them — never delete/recreate mid-turn + // while we still own the channel tip. + // Working.. ack (if seeded) is already in discordMessageIds. + // On a new delivery, only keep the newest tip slot (the Working ack) so we never + // try to edit prior-turn message ids (Discord 10008 Unknown Message). + const tipSlots = activeStreamTipIdsForDelivery({ + startsNewDelivery, + discordMessageIds: state.discordMessageIds, + staleStreamMessageIds: state.staleStreamMessageIds, + }); + let discordMessageIds: readonly string[] = [...tipSlots.discordMessageIds]; + let staleStreamMessageIds: readonly string[] = [...tipSlots.staleStreamMessageIds]; + let streamBreakPrefix = startsNewDelivery ? "" : state.streamBreakPrefix; + // Force a full content rewrite when the tracked assistant id changes so a + // shorter replacement bubble doesn't leave stale suffix text. + let lastText = startsNewDelivery ? "" : state.lastAssistantText; + + const fullDisplayText = streamDisplayText(text); + const previousFullDisplayText = streamDisplayText(lastText); + + // If a *foreign* message is under us (human reply), freeze the tip in place — + // do NOT delete (that reorders history) and do NOT re-copy its body after the + // user message. Later content only appears as a post-break tip. + // Bot-owned side posts (live Tasks / approvals / info pin) must NOT break the + // tip — they steal channel-tip ownership and left Discord on empty Working.. + // while T3 streamed intermediate prose. + if (discordMessageIds.length > 0) { + const tipId = discordMessageIds[discordMessageIds.length - 1] ?? null; + if (tipId !== null && (yield* isStreamTipDisplaced(tipId))) { + const previousTipBody = activeStreamTipText( + previousFullDisplayText, + streamBreakPrefix, + ); + const freezePlan = planStreamTipFreezeOnDisplacement({ + previousFullDisplayText, + previousTipBody, + previousLastAssistantText: lastText, + }); + if (freezePlan.freezeContent !== null) { + yield* rest + .updateMessage(input.discordChannelId, tipId, { + ...idleMessageFields(freezePlan.freezeContent), + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to freeze displaced stream tip", { + id: tipId, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + } + yield* Effect.logInfo( + "Discord stream tip lost channel-tip ownership; freezing in place", + { + t3ThreadId: input.t3ThreadId, + assistantId: t3MessageId, + frozenDiscordMessageId: tipId, + // Prefix is *already-shown* text only — never the in-flight body, or mid-turn + // human replies hide everything under an empty Working tip below the user. + breakPrefixLen: freezePlan.nextBreakPrefix.length, + hadFrozenProse: freezePlan.freezeContent !== null, + }, + ); + // Frozen messages stay visible above the user message; finalize deletes them. + staleStreamMessageIds = uniqueDiscordMessageIds([ + ...staleStreamMessageIds, + ...discordMessageIds, + ]); + discordMessageIds = []; + streamBreakPrefix = freezePlan.nextBreakPrefix; + // Keep lastText as what was already streamed so the next post-break write + // diffs against shown content, not against the never-posted full body. + lastText = freezePlan.nextLastAssistantText; + } + } + + const tipDisplayText = activeStreamTipText(fullDisplayText, streamBreakPrefix); + const previousTipDisplayText = activeStreamTipText( + previousFullDisplayText, + streamBreakPrefix, + ); + + // After a tip break, always keep a post-break Working tip for liveness even when + // the model is only running tools (no new prose yet). Previously we returned + // early with empty suffix → frozen tip above the user and *no* Working below, + // so Discord looked dead while T3 was still busy. + const desiredChunks = + tipDisplayText.trim() === "" + ? ([""] as string[]) + : chunkDiscordContent(tipDisplayText, STREAM_CHUNK_LIMIT); + + // Preserve tool-call progress on the Working line when stream text changes so the + // 10s heartbeat is not the only place the count can appear mid-prose. + const latestForTools = yield* Ref.get(latestThreadRef); + const toolCallCount = currentTurnToolCallCount(latestForTools); + + for (let index = 0; index < desiredChunks.length; index += 1) { + const chunk = desiredChunks[index] ?? ""; + const existingId = discordMessageIds[index] ?? null; + const isLastChunk = index === desiredChunks.length - 1; + const content = formatInProgressChunk( + chunk, + isLastChunk, + DISCORD_LIMIT, + 2, + toolCallCount, + ); + + if (existingId !== null) { + const previousChunks = chunkDiscordContent( + previousTipDisplayText, + STREAM_CHUNK_LIMIT, + ); + const previousChunk = previousChunks[index] ?? ""; + const previousIsLastChunk = index === previousChunks.length - 1; + const contentChanged = previousChunk !== chunk; + const stopButtonVisibilityChanged = previousIsLastChunk !== isLastChunk; + + if (contentChanged || isLastChunk || stopButtonVisibilityChanged) { + // Tip ownership is checked at the start of the update; here we only edit. + // After bot restart, durable tip ids may point at deleted messages — recreate. + const fields = isLastChunk + ? workingMessageFields(content, input.t3ThreadId) + : idleMessageFields(content); + const updated = yield* rest + .updateMessage(input.discordChannelId, existingId, { ...fields }) + .pipe(Effect.result); + if ( + shouldRecreateStreamTipOnUpdateFailure({ + turnInProgress: true, + updateFailed: Result.isFailure(updated), + }) + ) { + yield* Effect.logWarning( + "Stream tip update failed (likely deleted on restart); creating replacement", + { + t3ThreadId: input.t3ThreadId, + deadTipId: existingId, + cause: formatAlertCause( + Result.isFailure(updated) ? updated.failure : "unknown", + 300, + ), + }, + ); + staleStreamMessageIds = uniqueDiscordMessageIds([ + ...staleStreamMessageIds, + existingId, + ]); + const created = yield* rest.createMessage(input.discordChannelId, { + ...fields, + }); + discordMessageIds = [ + ...discordMessageIds.slice(0, index), + created.id, + ...discordMessageIds.slice(index + 1), + ]; + } + } + } else { + const created = yield* rest.createMessage(input.discordChannelId, { + ...(isLastChunk + ? workingMessageFields(content, input.t3ThreadId) + : idleMessageFields(content)), + }); + discordMessageIds = [...discordMessageIds, created.id]; + } + } + + // Messages beyond the new post-break chunk count are obsolete (shorter rewrite). + // Mark stale for finalize cleanup — do not delete mid-turn (preserves order). + if (discordMessageIds.length > desiredChunks.length) { + const extra = discordMessageIds.slice(desiredChunks.length); + staleStreamMessageIds = uniqueDiscordMessageIds([...staleStreamMessageIds, ...extra]); + discordMessageIds = discordMessageIds.slice(0, desiredChunks.length); + } + + yield* Ref.update(stateRef, (current) => ({ + ...current, + currentTurnId: turnId, + t3AssistantMessageId: t3MessageId, + lastAssistantText: text, + discordMessageIds, + staleStreamMessageIds, + streamBreakPrefix, + postedAttachmentIds: startsNewDelivery ? [] : current.postedAttachmentIds, + postedMarkdownImageSrcs: startsNewDelivery ? [] : current.postedMarkdownImageSrcs, + postedMarkdownFileSrcs: startsNewDelivery ? [] : current.postedMarkdownFileSrcs, + finalizedTurnId: startsNewDelivery ? null : current.finalizedTurnId, + finalDiscordMessageIds: startsNewDelivery ? [] : current.finalDiscordMessageIds, + streamHistoryPosted: startsNewDelivery ? false : current.streamHistoryPosted, + seededWorkingAckPending: false, + })); + yield* persistStreamMessageIds([...discordMessageIds, ...staleStreamMessageIds]); + return; + } + + // Turn finished — archive stream tips as stream-history.md, post final answer, + // delete in-progress messages. stream-history is ONLY produced here (not mid-turn). + const prior = yield* Ref.get(stateRef); + const streamHistoryText = shouldArchiveStreamHistory({ + presentationMode: input.presentationMode ?? "full", + hasStreamMessages: allStreamIds(prior).length > 0, + }) + ? prior.lastAssistantText + : ""; + + yield* Ref.update(stateRef, (current) => { + // Keep tip ids for deletion; reset attachment bookkeeping only if this is a + // new assistant id after a prior finalize (should be rare with turn-scoped stream). + const freshAfterFinalize = shouldReopenFinalizedDelivery({ + finalizedTurnId: current.finalizedTurnId, + currentAssistantMessageId: current.t3AssistantMessageId, + turnId, + nextAssistantMessageId: t3MessageId, + }); + return { + ...current, + currentTurnId: turnId, + t3AssistantMessageId: t3MessageId, + lastAssistantText: text, + discordMessageIds: current.discordMessageIds, + staleStreamMessageIds: current.staleStreamMessageIds, + postedAttachmentIds: freshAfterFinalize ? [] : current.postedAttachmentIds, + postedMarkdownImageSrcs: freshAfterFinalize ? [] : current.postedMarkdownImageSrcs, + postedMarkdownFileSrcs: freshAfterFinalize ? [] : current.postedMarkdownFileSrcs, + finalizedTurnId: freshAfterFinalize ? null : current.finalizedTurnId, + finalDiscordMessageIds: freshAfterFinalize ? [] : current.finalDiscordMessageIds, + streamHistoryPosted: freshAfterFinalize ? false : current.streamHistoryPosted, + seededWorkingAckPending: false, + }; + }); + yield* finalizeAssistantMessage({ + turnId, + t3MessageId, + text, + images, + streamHistoryText, + worktreePath, + }); + }).pipe(Effect.asVoid); + + const postOrEditAssistant = (args: Parameters[0]) => { + // Finalize has its own inner timeouts; stream path needs an outer cap so a + // hung listMessages/updateMessage cannot pin delivery + streamWrite locks. + const write = postOrEditAssistantUnlocked(args); + if (!args.streaming) { + return streamWriteLock.withPermit(write); + } + return streamWriteLock.withPermit( + Effect.gen(function* () { + const outcome = yield* write.pipe( + Effect.timeout(BRIDGE_STREAM_DISCORD_TIMEOUT), + Effect.result, + ); + if (Result.isFailure(outcome)) { + yield* Effect.logError("Discord stream tip write failed or timed out", { + t3ThreadId: input.t3ThreadId, + turnId: args.turnId, + t3MessageId: args.t3MessageId, + timeout: BRIDGE_STREAM_DISCORD_TIMEOUT, + cause: formatAlertCause(outcome.failure, 300), + }); + } + }), + ); + }; + + /** + * Final delivery for a completed assistant turn: + * 1. Post Discord markdown content (chunked if needed; links stay clickable) + * 2. Always append · [T3](…#message-…) on the stats footer + * 3. Attach stream-history.md + chat/local images as files + * 4. Delete the in-progress stream messages so only the final answer remains visible + */ + const finalizeAssistantMessage = (args: { + readonly turnId: string | null; + readonly t3MessageId: string; + readonly text: string; + readonly images: ReadonlyArray; + readonly streamHistoryText: string; + readonly worktreePath: string | null; + }) => + Effect.gen(function* () { + const { turnId, t3MessageId, text, images, streamHistoryText, worktreePath } = args; + const state = yield* Ref.get(stateRef); + // Turn-id mismatch used to hard-return with no log, which left Working.. tips + // stranded when catch-up/rehydrate raced a mid-turn id reset. Prefer finishing + // delivery when we still have open tips or non-empty final text. + if (state.currentTurnId !== turnId) { + const openTips = allStreamIds(state).length; + const hasText = text.trim() !== ""; + if (openTips === 0 && !hasText) { + yield* Effect.logInfo("Skipping finalize: turn id mismatch and nothing to post", { + t3ThreadId: input.t3ThreadId, + expectedTurnId: turnId, + currentTurnId: state.currentTurnId, + t3MessageId, + }); + return; + } + yield* Effect.logWarning("Finalize turn id mismatch; proceeding to clear tips / post", { + t3ThreadId: input.t3ThreadId, + expectedTurnId: turnId, + currentTurnId: state.currentTurnId, + t3MessageId, + openTips, + textLen: text.length, + }); + } + + const pendingImages = unpostedAttachments(images, state.postedAttachmentIds); + const markdownRefs = extractMarkdownImages(text).filter((ref) => isLocalImageSrc(ref.src)); + const pendingMarkdown = markdownRefs.filter( + (ref) => !state.postedMarkdownImageSrcs.includes(ref.src), + ); + const markdownFileRefs = extractMarkdownLocalFileLinks(text).filter((ref) => + isLocalFileSrc(ref.src), + ); + const githubUrlsBySrc = yield* resolveGitHubLinksForMarkdownFiles(markdownFileRefs); + const pendingMarkdownFiles = markdownFileRefs.filter( + (ref) => !state.postedMarkdownFileSrcs.includes(ref.src) && !githubUrlsBySrc.has(ref.src), + ); + const durableLink = yield* links + .getByDiscordThreadId(input.discordChannelId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const alreadyFinalized = isAssistantAlreadyFinalizedOnDiscord({ + assistantId: t3MessageId, + finalizedTurnId: state.finalizedTurnId, + turnId, + lastFinalizedAssistantId: state.delivery.lastFinalizedAssistantId, + durableLastFinalizedAssistantId: durableLink?.lastFinalizedAssistantId ?? null, + }); + + // Nothing left to do. + if ( + alreadyFinalized && + pendingImages.length === 0 && + pendingMarkdown.length === 0 && + pendingMarkdownFiles.length === 0 + ) { + // Still clear any leftover Working tips if durable finalize outran tip cleanup. + if (allStreamIds(state).length > 0) { + yield* deleteMessages(allStreamIds(state)); + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: [], + staleStreamMessageIds: [], + streamBreakPrefix: "", + finalizedTurnId: turnId ?? current.finalizedTurnId, + seededWorkingAckPending: false, + })); + yield* persistStreamMessageIds([]); + } + return; + } + + // Late images after a prior finalize: must be a *new* createMessage with files. + if ( + alreadyFinalized && + (pendingImages.length > 0 || + pendingMarkdown.length > 0 || + pendingMarkdownFiles.length > 0) + ) { + const imageFiles = yield* loadAttachmentFiles( + pendingImages, + DISCORD_MAX_FILES_PER_MESSAGE, + ); + const slotsLeft = DISCORD_MAX_FILES_PER_MESSAGE - imageFiles.length; + const mdLoaded = yield* loadMarkdownImageFiles( + pendingMarkdown, + state.postedMarkdownImageSrcs, + slotsLeft, + ); + const fileSlotsLeft = slotsLeft - mdLoaded.files.length; + const linkedFilesLoaded = yield* loadMarkdownLinkedFiles( + pendingMarkdownFiles, + state.postedMarkdownFileSrcs, + fileSlotsLeft, + worktreePath, + ); + const files = [...imageFiles, ...mdLoaded.files, ...linkedFilesLoaded.files]; + if (files.length === 0) return; + const created = yield* createMessageWithFiles("", files); + const postedFromFiles = pendingImages + .slice(0, imageFiles.length) + .map((entry) => entry.id); + yield* Ref.update(stateRef, (current) => ({ + ...current, + postedAttachmentIds: [...new Set([...current.postedAttachmentIds, ...postedFromFiles])], + postedMarkdownImageSrcs: [ + ...new Set([...current.postedMarkdownImageSrcs, ...mdLoaded.loadedSrcs]), + ], + postedMarkdownFileSrcs: [ + ...new Set([...current.postedMarkdownFileSrcs, ...linkedFilesLoaded.loadedSrcs]), + ], + finalDiscordMessageIds: [...current.finalDiscordMessageIds, created.id], + })); + return; + } + + // Archive in-progress stream as .md only when it has intermediate progress beyond the + // final answer (not a lone Working.. tip, and not message body === stream body). + const historySource = stripWorkingIndicator( + streamHistoryText.trim() !== "" + ? streamHistoryText + : state.t3AssistantMessageId === t3MessageId + ? state.lastAssistantText + : "", + ); + const hadStreamMessages = allStreamIds(state).length > 0; + const finalSource = stripWorkingIndicator(text); + const shouldAttachStreamHistory = + !state.streamHistoryPosted && + hadStreamMessages && + streamHistoryHasAdditionalContent(historySource, finalSource); + + let streamHistoryTextBody: string | null = null; + if (shouldAttachStreamHistory) { + const historyMarkdownFiles = extractMarkdownLocalFileLinks(historySource).filter((ref) => + isLocalFileSrc(ref.src), + ); + const historyGitHubUrlsBySrc = + yield* resolveGitHubLinksForMarkdownFiles(historyMarkdownFiles); + const rewrittenHistorySource = rewriteMarkdownLocalFileLinksForDiscord({ + text: historySource, + githubUrlsBySrc: historyGitHubUrlsBySrc, + }); + const historyInlineGitHubUrlsByToken = yield* resolveGitHubLinksForInlinePathCodeSpans( + extractInlinePathCodeSpanRefs(rewrittenHistorySource), + worktreePath, + ); + const renderedHistorySource = rewriteInlinePathCodeSpansForDiscord({ + text: rewrittenHistorySource, + githubUrlsByToken: historyInlineGitHubUrlsByToken, + }); + streamHistoryTextBody = buildStreamHistoryMarkdownText(renderedHistorySource); + } + + let slots = DISCORD_MAX_FILES_PER_MESSAGE; + const files: DiscordUploadFile[] = []; + if (streamHistoryTextBody !== null) { + files.push(textFile(STREAM_HISTORY_MARKDOWN_NAME, streamHistoryTextBody)); + slots -= 1; + } + + const imageFiles = yield* loadAttachmentFiles(pendingImages, slots); + files.push(...imageFiles); + slots -= imageFiles.length; + + const mdLoaded = yield* loadMarkdownImageFiles( + pendingMarkdown, + state.postedMarkdownImageSrcs, + slots, + ); + files.push(...mdLoaded.files); + slots -= mdLoaded.files.length; + + const linkedFilesLoaded = yield* loadMarkdownLinkedFiles( + pendingMarkdownFiles, + state.postedMarkdownFileSrcs, + slots, + worktreePath, + ); + files.push(...linkedFilesLoaded.files); + + yield* Effect.logInfo("Discord finalize attachments ready", { + fileCount: files.length, + names: files.map((f) => f.name), + totalBytes: files.reduce((sum, f) => sum + f.data.byteLength, 0), + }); + + const postedFromFiles = pendingImages.slice(0, imageFiles.length).map((entry) => entry.id); + + // Split once for local-file rewrite notes (no table .txt attachments). + const { batches: uploadBatches, oversized: oversizedFiles } = + splitFilesForDiscordUpload(files); + const oversizedByName = new Set(oversizedFiles.map((file) => file.name)); + const attachedFileNames = new Set( + uploadBatches.flatMap((batch) => batch.map((file) => file.name)), + ); + + // Final channel text: strip image embeds but keep readable local file references. + // Never leave Working.. or the stream placeholder. + // Keep Discord markdown as-is (links stay clickable). Do not ASCII-ify tables. + // Stats footer always gets · [T3](deep link) when the web UI base is configured. + const finalText = rewriteMarkdownLocalFileLinksForDiscord({ + text: stripWorkingIndicator(stripMarkdownImages(text)), + githubUrlsBySrc, + attachedFileNames, + oversizedByName, + }) + .replace(/_\(attachment will attach when done\)_/giu, "") + .replace(/_\(\d+ attachments will attach when done\)_/giu, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + const finalInlineGitHubUrlsByToken = yield* resolveGitHubLinksForInlinePathCodeSpans( + extractInlinePathCodeSpanRefs(finalText), + worktreePath, + ); + const renderedFinalText = rewriteInlinePathCodeSpansForDiscord({ + text: finalText, + githubUrlsByToken: finalInlineGitHubUrlsByToken, + }); + + // Avoid posting a lone "…" placeholder (what you saw in Discord when an image-only + // turn failed to attach and had no remaining text). Prefer empty content + files, + // or a short failure note if we expected images but loaded none. + const baseFinalChunks: string[] = + renderedFinalText !== "" + ? chunkDiscordContent(renderedFinalText, DISCORD_LIMIT) + : files.length > 0 + ? [""] + : pendingMarkdown.length > 0 || + pendingImages.length > 0 || + pendingMarkdownFiles.length > 0 + ? ["_(Could not attach file.)_"] + : text.trim() !== "" + ? ["_(done)_"] + : []; + + // Small italic turn stats on the final answer (model / effort / duration / tokens), + // then always append · [T3](deep link) on that footer section when the URL is known. + const statsThread = yield* Ref.get(latestThreadRef); + const statsLine = formatTurnResponseStatsLine({ + modelSelection: statsThread?.modelSelection ?? null, + ...(statsThread?.activities !== undefined ? { activities: statsThread.activities } : {}), + turnId, + latestTurn: statsThread?.latestTurn ?? null, + }); + let finalChunks = appendStatsToMessageChunks(baseFinalChunks, statsLine, DISCORD_LIMIT); + + const botConfig = yield* DiscordBotConfig; + const t3Url = buildOmegentThreadMessageUrl({ + webUiBaseUrl: botConfig.webUiBaseUrl, + threadId: input.t3ThreadId, + messageId: t3MessageId, + }); + finalChunks = appendT3DeepLinkToChunks(finalChunks, t3Url, DISCORD_LIMIT); + + if (finalChunks.length === 0 && files.length === 0) { + // Nothing useful to post — just clear any leftover Working.. stream messages. + yield* deleteMessages(allStreamIds(state)); + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: [], + staleStreamMessageIds: [], + streamBreakPrefix: "", + currentTurnId: turnId, + lastAssistantText: text, + finalizedTurnId: turnId, + finalDiscordMessageIds: [], + streamHistoryPosted: current.streamHistoryPosted || streamHistoryTextBody !== null, + postedAttachmentIds: [...new Set([...current.postedAttachmentIds, ...postedFromFiles])], + postedMarkdownImageSrcs: [ + ...new Set([...current.postedMarkdownImageSrcs, ...mdLoaded.loadedSrcs]), + ], + postedMarkdownFileSrcs: [ + ...new Set([...current.postedMarkdownFileSrcs, ...linkedFilesLoaded.loadedSrcs]), + ], + seededWorkingAckPending: false, + })); + yield* persistStreamMessageIds([]); + yield* persistFinalizedAssistant(t3MessageId); + return; + } + + const streamIds = [...state.discordMessageIds]; + const staleIds = [...state.staleStreamMessageIds]; + const toDelete = [...streamIds, ...staleIds]; + yield* Effect.logInfo("Finalizing Discord assistant delivery", { + t3ThreadId: input.t3ThreadId, + turnId, + t3MessageId, + hadPriorFinalize: alreadyFinalized, + finalTextLength: finalText.length, + finalChunkCount: finalChunks.length, + pendingChatImageCount: pendingImages.length, + pendingMarkdownImageCount: pendingMarkdown.length, + pendingMarkdownFileCount: pendingMarkdownFiles.length, + streamHistoryWillPost: streamHistoryTextBody !== null, + streamIds, + staleIds, + }); + + /** + * Always create final message(s) then delete every in-progress tip. + * Never edit stream tips in place: multi-bubble turns leave long interim + * narration in those tips, and "edit to final" either keeps that text or + * fails to drop extra Working.. chunks. Discord cannot add attachments via edit. + * + * Accept-without-ack: if a prior attempt already landed text chunks (timeout + * before we recorded ids), adopt those message ids instead of createMessage again. + */ + const createFinalWithAttachments = Effect.gen(function* () { + const ids: string[] = []; + for (let index = 0; index < finalChunks.length; index += 1) { + const chunk = finalChunks[index] ?? ""; + const content = chunk.trim() !== "" ? chunk : "_(done)_"; + const created = yield* createMessageWithFiles(content, []); + ids.push(created.id); + // Persist durable finalize marker as soon as the first text chunk lands so a + // later timeout cannot treat this assistant as undelivered and re-post. + if (index === 0) { + yield* persistFinalizedAssistant(t3MessageId); + yield* Ref.update(stateRef, (current) => ({ + ...current, + finalizedTurnId: turnId, + t3AssistantMessageId: t3MessageId, + delivery: { + ...current.delivery, + lastFinalizedAssistantId: t3MessageId, + lastFinalizedText: text, + phase: "finalized" as const, + }, + })); + } + } + + for (const batch of uploadBatches) { + yield* Effect.logInfo("Creating Discord attachment batch", { + files: batch.map((f) => ({ + name: f.name, + mimeType: f.mimeType, + bytes: f.data.byteLength, + })), + totalBytes: batch.reduce((sum, f) => sum + f.data.byteLength, 0), + }); + const created = yield* createMessageWithFiles("", batch); + ids.push(created.id); + } + + if (oversizedFiles.length > 0) { + const note = [ + "**Some files could not be attached due to Discord upload limits:**", + ...oversizedFiles.map( + (file) => `- \`${file.name}\` (${Math.ceil(file.data.byteLength / 1_000_000)} MB)`, + ), + ].join("\n"); + const created = yield* rest.createMessage(input.discordChannelId, { content: note }); + ids.push(created.id); + } + return ids; + }); + + let createdIds: ReadonlyArray = []; + + // Scan recent channel messages for an already-landed final (retry after timeout). + const recent = yield* rest.listMessages(input.discordChannelId, { limit: 25 }).pipe( + Effect.catchCause(() => + Effect.succeed( + [] as ReadonlyArray<{ + readonly id: string; + readonly content?: string; + readonly author?: { readonly id: string }; + }>, + ), + ), + ); + const adoptedFinalIds = findAlreadyPostedFinalChunkIds({ + recentMessages: recent.map((message) => ({ + id: message.id, + authorId: message.author?.id ?? "", + content: message.content ?? "", + })), + botUserId, + finalChunks, + excludeMessageIds: toDelete, + }); + if (adoptedFinalIds !== null) { + yield* Effect.logInfo( + "Finalize adopting already-posted Discord final chunks (idempotent retry)", + { + t3ThreadId: input.t3ThreadId, + turnId, + t3MessageId, + adoptedIds: adoptedFinalIds, + }, + ); + createdIds = adoptedFinalIds; + // Claim durable finalize before tip cleanup so concurrent recover cannot re-create. + yield* persistFinalizedAssistant(t3MessageId); + } else { + const primary = yield* createFinalWithAttachments.pipe( + Effect.timeout(BRIDGE_FINALIZE_DISCORD_TIMEOUT), + Effect.result, + ); + if (Result.isSuccess(primary)) { + createdIds = primary.success; + } else { + yield* Effect.logError(primary.failure); + // Re-scan before fallback create — primary may have partially landed. + const recentAfterTimeout = yield* rest + .listMessages(input.discordChannelId, { limit: 25 }) + .pipe( + Effect.catchCause(() => + Effect.succeed( + [] as ReadonlyArray<{ + readonly id: string; + readonly content?: string; + readonly author?: { readonly id: string }; + }>, + ), + ), + ); + const adoptedAfterTimeout = findAlreadyPostedFinalChunkIds({ + recentMessages: recentAfterTimeout.map((message) => ({ + id: message.id, + authorId: message.author?.id ?? "", + content: message.content ?? "", + })), + botUserId, + finalChunks, + excludeMessageIds: toDelete, + }); + if (adoptedAfterTimeout !== null) { + yield* Effect.logInfo( + "Finalize adopting partial Discord final after timeout (skip fallback create)", + { + t3ThreadId: input.t3ThreadId, + adoptedIds: adoptedAfterTimeout, + }, + ); + createdIds = adoptedAfterTimeout; + yield* persistFinalizedAssistant(t3MessageId); + } else { + // Last resort: text createMessages, then a separate multipart create for files only. + // Also bounded so a hung Discord REST call cannot pin the bridge forever. + const fallback = Effect.gen(function* () { + const ids: string[] = []; + for (const chunk of finalChunks.length > 0 ? finalChunks : ([] as string[])) { + const created = yield* rest.createMessage(input.discordChannelId, { + content: stripWorkingIndicator(chunk.trim() !== "" ? chunk : "_(done)_"), + }); + ids.push(created.id); + if (ids.length === 1) { + yield* persistFinalizedAssistant(t3MessageId); + } + } + if (files.length > 0) { + const fileResult = yield* createMessageWithFiles("", files).pipe(Effect.result); + if (Result.isSuccess(fileResult)) { + ids.push(fileResult.success.id); + } else { + yield* Effect.logError(fileResult.failure); + } + } + return ids; + }).pipe(Effect.timeout(BRIDGE_FINALIZE_DISCORD_TIMEOUT), Effect.result); + const fallbackResult = yield* fallback; + if (Result.isSuccess(fallbackResult)) { + createdIds = fallbackResult.success; + } else { + yield* Effect.logError(fallbackResult.failure); + createdIds = []; + } + } + } + } + + // Always wipe in-progress tips (including Working.. ack), even if create failed + // partially — better empty channel than a stuck interim stream. + yield* deleteMessages(toDelete); + + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: [], + staleStreamMessageIds: [], + streamBreakPrefix: "", + currentTurnId: turnId, + lastAssistantText: text, + finalizedTurnId: turnId, + t3AssistantMessageId: t3MessageId, + finalDiscordMessageIds: [...createdIds], + streamHistoryPosted: current.streamHistoryPosted || streamHistoryTextBody !== null, + postedAttachmentIds: [...new Set([...current.postedAttachmentIds, ...postedFromFiles])], + postedMarkdownImageSrcs: [ + ...new Set([...current.postedMarkdownImageSrcs, ...mdLoaded.loadedSrcs]), + ], + postedMarkdownFileSrcs: [ + ...new Set([...current.postedMarkdownFileSrcs, ...linkedFilesLoaded.loadedSrcs]), + ], + seededWorkingAckPending: false, + delivery: { + ...current.delivery, + phase: "finalized" as const, + lastFinalizedAssistantId: t3MessageId, + lastFinalizedText: text, + finalizedAssistantId: t3MessageId, + streamText: "", + settleReady: false, + }, + })); + yield* persistStreamMessageIds([]); + // Durable marker may already be set after first chunk; write again is a no-op merge. + yield* persistFinalizedAssistant(t3MessageId); + + // Collect any GitHub PR URLs from the finalized answer into the pinned thread-info. + const prUrlsFromAnswer = extractPullRequestUrls(text); + if (prUrlsFromAnswer.length > 0) { + const botConfig = yield* DiscordBotConfig; + yield* upsertThreadInfoPin({ + discordThreadId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + botConfig, + incomingPrUrls: prUrlsFromAnswer, + worktreePath, + local: worktreePath === null, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to refresh thread info pin with PR links", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + error: String(error), + }), + ), + ); + } + + yield* Effect.logInfo("Discord assistant delivery finalized", { + t3ThreadId: input.t3ThreadId, + turnId, + t3MessageId, + createdIds, + deletedInProgressIds: toDelete, + postedFromFiles, + uploadedMarkdownImageSrcs: mdLoaded.loadedSrcs, + uploadedMarkdownFileSrcs: linkedFilesLoaded.loadedSrcs, + }); + }).pipe(Effect.asVoid); + + const postApprovals = (approvals: ReadonlyArray) => + Effect.gen(function* () { + if (approvals.length === 0) return; + const key = approvals.map((entry) => entry.requestId).join(","); + const state = yield* Ref.get(stateRef); + if (state.lastApprovalKey === key) return; + + for (const approval of approvals) { + yield* rest.createMessage(input.discordChannelId, { + content: [ + `**Approval required** (${approval.requestKind})`, + approval.detail ?? "_No detail provided_", + ].join("\n"), + components: UI.grid([ + [ + UI.button({ + custom_id: `t3_approve:${input.t3ThreadId}:${approval.requestId}`, + label: "Allow", + style: Discord.ButtonStyleTypes.SUCCESS, + }), + UI.button({ + custom_id: `t3_deny:${input.t3ThreadId}:${approval.requestId}`, + label: "Deny", + style: Discord.ButtonStyleTypes.DANGER, + }), + ], + ]), + }); + } + + yield* Ref.update(stateRef, (current) => ({ + ...current, + lastApprovalKey: key, + })); + }).pipe(Effect.asVoid); + + /** + * Apply a Discord thread title only after a successful rename. + * Never mark `attemptedThreadTitle` / `mirroredThreadTitle` before the REST call — + * a rate-limit / network failure used to poison retries so busy/wake badges never + * appeared. Failed applies keep `pendingDesiredThreadTitle` for heartbeat retry. + * + * Must run under `titleSyncLock`. + */ + const applyDiscordThreadTitle = (desiredTitle: string, reason: string) => + Effect.gen(function* () { + const latest = yield* Ref.get(stateRef); + if (latest.mirroredThreadTitle === desiredTitle) { + yield* Ref.set(pendingDesiredThreadTitleRef, null); + return; + } + // Record desired *before* REST so interrupt/timeout/rate-limit still retries. + yield* Ref.set(pendingDesiredThreadTitleRef, desiredTitle); + const result = yield* rest + .updateChannel(input.discordChannelId, { name: desiredTitle }) + .pipe(Effect.result); + const pending = yield* Ref.get(pendingDesiredThreadTitleRef); + if (Result.isFailure(result)) { + const afterFail = nextMirroredThreadTitleAfterApply({ + mirroredThreadTitle: latest.mirroredThreadTitle, + pendingDesiredThreadTitle: pending, + appliedTitle: desiredTitle, + success: false, + }); + yield* Ref.set(pendingDesiredThreadTitleRef, afterFail.pendingDesiredThreadTitle); + yield* Effect.logWarning("Discord thread title rename failed; will retry", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + desiredTitle, + reason, + cause: formatAlertCause(result.failure, 300), + }); + return; + } + const afterOk = nextMirroredThreadTitleAfterApply({ + mirroredThreadTitle: latest.mirroredThreadTitle, + pendingDesiredThreadTitle: pending, + appliedTitle: desiredTitle, + success: true, + }); + yield* Effect.logInfo("Discord thread title mirrored to Discord", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + desiredTitle, + reason, + }); + yield* Ref.set(pendingDesiredThreadTitleRef, afterOk.pendingDesiredThreadTitle); + yield* Ref.update(stateRef, (current) => ({ + ...current, + attemptedThreadTitle: afterOk.attemptedThreadTitle ?? desiredTitle, + mirroredThreadTitle: afterOk.mirroredThreadTitle, + })); + }); + + /** + * Idempotent, desired-state title badge sync. + * + * Dual optional columns (like T3 client): `| PR | activity | Title`. + * Activity (busy / wake-required) is independent of sticky PR evidence. + * Unknown remote never paints ▫️. + * + * **Always re-reads `latestThreadRef` under the lock** — never apply a caller- + * captured thread snapshot for activity. VCS callbacks used to re-paint ⏳ after + * settle when they held a stale "running" thread across the lock queue. + * + * Callers must publish the latest orchestration thread to `latestThreadRef` + * (via `ensureVcsStatusSubscription` / snapshot path) *before* invoking this. + */ + const syncDiscordThreadTitle = () => + titleSyncLock + .withPermit( + Effect.gen(function* () { + const commitComputedTitle = (desiredTitle: string | null, reason: string) => + Effect.gen(function* () { + const latest = yield* Ref.get(stateRef); + const pending = yield* Ref.get(pendingDesiredThreadTitleRef); + const plan = planDiscordThreadTitleApply({ + mirroredThreadTitle: latest.mirroredThreadTitle, + pendingDesiredThreadTitle: pending, + computedDesiredTitle: desiredTitle, + }); + yield* Ref.set(pendingDesiredThreadTitleRef, plan.pendingDesiredThreadTitle); + if (plan.applyTitle === null) return; + yield* applyDiscordThreadTitle(plan.applyTitle, reason); + }); + + // Coalesce: re-read the latest thread under the lock before every compute + // and again right before apply so a settle that landed while we held the + // lock (or during GH lookup) always wins over a stale busy state. + for (let attempt = 0; attempt < 3; attempt += 1) { + const thread = yield* Ref.get(latestThreadRef); + if (thread === null) { + const pendingOnly = yield* Ref.get(pendingDesiredThreadTitleRef); + const stateOnly = yield* Ref.get(stateRef); + const planOnly = planDiscordThreadTitleApply({ + mirroredThreadTitle: stateOnly.mirroredThreadTitle, + pendingDesiredThreadTitle: pendingOnly, + computedDesiredTitle: null, + }); + yield* Ref.set(pendingDesiredThreadTitleRef, planOnly.pendingDesiredThreadTitle); + if (planOnly.applyTitle !== null) { + yield* applyDiscordThreadTitle(planOnly.applyTitle, "pending-retry-no-thread"); + } + return; + } + + const state = yield* Ref.get(stateRef); + const titleBase = (value: string) => + decorateDiscordThreadTitle(value, null, 100, false); + const currentBase = titleBase(thread.title); + const alreadySettled = + (state.mirroredThreadTitle !== null && + titleBase(state.mirroredThreadTitle) === currentBase) || + (state.attemptedThreadTitle !== null && + titleBase(state.attemptedThreadTitle) === currentBase); + + const cachedStatus = yield* Ref.get(latestVcsStatusRef); + const remoteObserved = yield* Ref.get(vcsRemoteObservedRef); + const stickyBefore = yield* Ref.get(stickyTitlePrRef); + const statusPr = toStickyTitlePrEvidence( + resolveThreadTitleChangeRequestFromStatus(thread, cachedStatus), + ); + + // Fast path: settled base + no new PR evidence from warm VCS — compose + // PR (sticky) + activity without GH dual-lookup. Covers turn start/end + // busy toggles so settle never waits on branch lookup. + if (alreadySettled && statusPr === null) { + const evidence = resolveDiscordTitlePrEvidence({ + stickyPr: stickyBefore, + statusPr: null, + remoteStatusObserved: remoteObserved, + }); + yield* Ref.set(stickyTitlePrRef, evidence.stickyPr); + + // Re-read immediately before decorating activity so we do not paint ⏳ + // from a thread that settled while we waited for this lock. + const threadNow = (yield* Ref.get(latestThreadRef)) ?? thread; + if (threadNow !== thread && attempt < 2) { + continue; + } + const upgradeTitle = resolveSettledDiscordThreadTitleUpgrade({ + thread: threadNow, + mirroredThreadTitle: state.mirroredThreadTitle, + attemptedThreadTitle: state.attemptedThreadTitle, + cachedPr: evidence.effectivePr, + canApplyNoPrBadge: evidence.canApplyNoPrBadge, + }); + const activityNow = resolveDiscordThreadActivityBadgeState({ + sessionStatus: threadNow.session?.status ?? null, + activeTurnId: threadNow.session?.activeTurnId ?? null, + latestTurnState: threadNow.latestTurn?.state ?? null, + latestTurnCompletedAt: threadNow.latestTurn?.completedAt ?? null, + }); + yield* commitComputedTitle( + upgradeTitle, + activityNow !== null ? "dual-badge-settled" : "settled-title-badge-upgrade", + ); + return; + } + + const project = yield* t3.getProjectShell(thread.projectId); + const branch = thread.branch; + + // Prefer warm VCS stream. Only cold-start GH lookup when remote is unknown + // and sticky is empty — never dual-source every snapshot. + let branchLookupPr: StickyTitlePrEvidence | null = null; + let branchLookupCompleted = false; + let prSource: "vcs-status-stream" | "sticky" | "branch-lookup" | "none" = "none"; + let lookupCwds: ReadonlyArray = []; + + if (statusPr !== null) { + prSource = "vcs-status-stream"; + } else if (stickyBefore !== null) { + prSource = "sticky"; + } else if (!remoteObserved && branch !== null && project !== null) { + lookupCwds = resolveThreadChangeRequestLookupCwds(thread, project); + for (const cwd of lookupCwds) { + const resolved = yield* t3 + .resolveBranchChangeRequest({ + cwd, + refName: branch, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Discord thread title PR lookup failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + title: thread.title, + branch, + cwd, + cause: formatAlertCause(cause, 400), + }).pipe(Effect.as({ pr: null })), + ), + ); + yield* Effect.logInfo("Discord thread title PR lookup result", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + title: thread.title, + branch, + cwd, + prNumber: resolved.pr?.number ?? null, + prState: resolved.pr?.state ?? null, + prHeadRef: resolved.pr?.headRef ?? null, + prBaseRef: resolved.pr?.baseRef ?? null, + }); + if (resolved.pr !== null) { + branchLookupPr = toStickyTitlePrEvidence(resolved.pr); + prSource = "branch-lookup"; + break; + } + } + // Completed the cold-start lookup path (possibly with no PR). + branchLookupCompleted = true; + if (prSource === "none") prSource = "branch-lookup"; + } else if (branch === null || project === null) { + yield* Effect.logInfo("Discord thread title sync skipping PR lookup", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + title: thread.title, + branch, + hasProject: project !== null, + worktreePath: thread.worktreePath, + }); + } + + const evidence = resolveDiscordTitlePrEvidence({ + stickyPr: stickyBefore, + statusPr, + remoteStatusObserved: remoteObserved, + branchLookupPr, + branchLookupCompleted, + }); + yield* Ref.set(stickyTitlePrRef, evidence.stickyPr); + + // Re-read after slow GH lookup — settle may have landed mid-wait. + const threadAfterLookup = (yield* Ref.get(latestThreadRef)) ?? thread; + if (threadAfterLookup !== thread && attempt < 2) { + continue; + } + + const activityAfterLookup = resolveDiscordThreadActivityBadgeState({ + sessionStatus: threadAfterLookup.session?.status ?? null, + activeTurnId: threadAfterLookup.session?.activeTurnId ?? null, + latestTurnState: threadAfterLookup.latestTurn?.state ?? null, + latestTurnCompletedAt: threadAfterLookup.latestTurn?.completedAt ?? null, + }); + + const prState = threadTitleChangeRequestState( + threadAfterLookup, + evidence.effectivePr, + ); + // Unknown evidence: do not paint ▫️ / no-PR from missing data. + const effectivePrState: DiscordThreadPrBadgeState = + prState === "initialized" && !evidence.canApplyNoPrBadge ? null : prState; + + const latest = yield* Ref.get(stateRef); + const mirroredBadges = parseDiscordThreadTitleBadges(latest.mirroredThreadTitle); + const currentBadges = + mirroredBadges.pr !== null || mirroredBadges.activity !== null + ? mirroredBadges + : parseDiscordThreadTitleBadges(latest.attemptedThreadTitle); + const prAllowed = shouldApplyDiscordThreadPrBadge(currentBadges.pr, effectivePrState); + const appliedPr = prAllowed ? effectivePrState : currentBadges.pr; + + // Still nothing actionable (no PR column, no activity, nothing mirrored). + if (appliedPr === null && activityAfterLookup === null && currentBadges.pr === null) { + yield* Effect.logInfo("Discord thread title sync deferred; PR evidence not ready", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + remoteObserved, + stickyPr: evidence.stickyPr?.number ?? null, + prSource, + activity: activityAfterLookup, + }); + yield* commitComputedTitle(null, "pending-retry-deferred"); + return; + } + + const desiredTitle = decorateDiscordThreadTitle( + threadAfterLookup.title, + { + pr: appliedPr, + activity: activityAfterLookup, + hasFailingChecks: + appliedPr === "open" && evidence.effectivePr?.hasFailingChecks === true, + }, + 100, + ); + yield* Effect.logInfo("Discord thread title sync resolved", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + title: threadAfterLookup.title, + branch, + worktreePath: threadAfterLookup.worktreePath, + projectWorkspaceRoot: project?.workspaceRoot ?? null, + lookupCwds, + cachedStatusRefName: cachedStatus?.refName ?? null, + prNumber: evidence.effectivePr?.number ?? null, + prState: effectivePrState, + appliedPr, + activity: activityAfterLookup, + sessionStatus: threadAfterLookup.session?.status ?? null, + prSource, + remoteObserved, + canApplyNoPrBadge: evidence.canApplyNoPrBadge, + desiredTitle, + currentBadges, + prAllowed, + mirroredThreadTitle: latest.mirroredThreadTitle, + attemptedThreadTitle: latest.attemptedThreadTitle, + }); + + yield* commitComputedTitle(desiredTitle, "title-sync"); + return; + } + }), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to mirror T3 thread title to Discord thread", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 400), + }), + ), + Effect.asVoid, + ); + + controlSlot.refreshThreadIndicators = () => + Effect.gen(function* () { + let thread = yield* Ref.get(latestThreadRef); + if (thread === null) { + for (let attempt = 0; attempt < 20; attempt += 1) { + yield* Effect.sleep("100 millis"); + thread = yield* Ref.get(latestThreadRef); + if (thread !== null) break; + } + } + if (thread === null) { + return { + ok: false as const, + error: "T3 thread not available yet for indicator refresh; try again in a moment", + }; + } + + // Force full re-evaluation: drop settle cache, sticky PR, and VCS cache. + yield* Ref.update(stateRef, (current) => ({ + ...current, + mirroredThreadTitle: null, + attemptedThreadTitle: null, + })); + yield* Ref.set(latestVcsStatusRef, null); + yield* Ref.set(stickyTitlePrRef, null); + yield* Ref.set(vcsRemoteObservedRef, false); + + if (thread.worktreePath !== null) { + yield* t3.refreshVcsStatus(thread.worktreePath).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Forced VCS status refresh failed during indicator refresh", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 300), + }), + ), + ); + } + + yield* Ref.set(latestThreadRef, thread); + yield* syncDiscordThreadTitle(); + const state = yield* Ref.get(stateRef); + if (state.mirroredThreadTitle !== null) { + yield* Effect.logInfo("Discord thread indicators refreshed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + title: state.mirroredThreadTitle, + }); + return { ok: true as const, title: state.mirroredThreadTitle }; + } + + // Last resort: mirror plain decorated title without PR (still clears stale badges). + const fallbackTitle = decorateDiscordThreadTitle( + thread.title, + threadTitleChangeRequestState(thread, null), + 100, + false, + ); + yield* rest.updateChannel(input.discordChannelId, { name: fallbackTitle }); + yield* Ref.update(stateRef, (current) => ({ + ...current, + mirroredThreadTitle: fallbackTitle, + attemptedThreadTitle: fallbackTitle, + })); + yield* Effect.logInfo("Discord thread indicators refreshed (fallback title)", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + title: fallbackTitle, + }); + return { ok: true as const, title: fallbackTitle }; + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed({ + ok: false as const, + error: error instanceof Error ? error.message : String(error), + }), + ), + ); + + const stopVcsStatusSubscription = Effect.gen(function* () { + const subscription = yield* Ref.get(vcsStatusSubscriptionRef); + if (subscription === null) return; + yield* Fiber.interrupt(subscription.fiber).pipe(Effect.ignore); + yield* Ref.set(vcsStatusSubscriptionRef, null); + yield* Ref.set(latestVcsStatusRef, null); + // Keep sticky PR + remoteObserved across cwd resubscribe so a worktree path + // bounce does not re-open the null→▫️ window. Force-refresh clears them. + yield* Effect.logInfo("Discord thread title VCS status subscription stopped", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cwd: subscription.cwd, + }); + }); + + const ensureVcsStatusSubscription = (thread: OrchestrationThread) => + Effect.gen(function* () { + yield* Ref.set(latestThreadRef, thread); + + const cwd = thread.worktreePath; + const subscription = yield* Ref.get(vcsStatusSubscriptionRef); + if (cwd === null) { + yield* stopVcsStatusSubscription; + return; + } + if (subscription?.cwd === cwd) { + return; + } + + yield* stopVcsStatusSubscription; + // Server-side VcsStatusBroadcaster already runs one remote poller per cwd when + // clients subscribe. Do NOT also force-refresh every 30s from the bot — that + // doubled GitHub/gh fan-out across every Discord bridge and starved the server. + // forkDetach (not forkScoped): bridge fibers are started with forkDetach and + // have no ambient Scope. forkScoped here used to fail every onThread after + // rehydrate, so Discord stream/finalize never ran while T3 kept working. + const fiber = yield* t3 + .subscribeVcsStatus(cwd, (event) => + Effect.gen(function* () { + // Only real remote payloads count as observed. localUpdated / snapshot + // with remote:null must not unlock the no-PR (▫️) badge. + if ( + event._tag === "remoteUpdated" || + (event._tag === "snapshot" && event.remote !== null) + ) { + yield* Ref.set(vcsRemoteObservedRef, true); + } + yield* Ref.update(latestVcsStatusRef, (current) => + applyGitStatusStreamEvent(current, event), + ); + // Do not capture a thread snapshot here — activity must be re-read under + // titleSyncLock from latestThreadRef so a concurrent settle cannot lose to + // a stale "running" VCS callback (⏳ flip-flop after the turn ends). + if ((yield* Ref.get(latestThreadRef)) === null) return; + + yield* Effect.logInfo("Discord thread title VCS status update received", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cwd, + eventTag: event._tag, + remoteObserved: + event._tag === "remoteUpdated" || + (event._tag === "snapshot" && event.remote !== null), + }); + yield* syncDiscordThreadTitle(); + }), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Discord thread title VCS status subscription failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cwd, + cause: formatAlertCause(cause, 400), + }), + ), + Effect.asVoid, + Effect.forkDetach, + ); + yield* Ref.set(vcsStatusSubscriptionRef, { cwd, fiber }); + yield* Effect.logInfo("Discord thread title VCS status subscription started", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cwd, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("ensureVcsStatusSubscription failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 400), + }); + yield* postBridgeAlert( + `vcs-sub:${input.discordChannelId}`, + "VCS title subscription failed", + [ + `channel=\`${input.discordChannelId}\``, + `thread=\`${input.t3ThreadId}\``, + formatAlertCause(cause), + ].join("\n"), + ); + }).pipe(Effect.asVoid), + ), + ); + + let subscriptionReady = false; + const signalReady = Effect.gen(function* () { + if (subscriptionReady) return; + subscriptionReady = true; + yield* Deferred.succeed(ready, undefined); + yield* Effect.logInfo("First T3 thread snapshot received; subscription live", { + t3ThreadId: input.t3ThreadId, + }); + }); + + const updateWorkingHeartbeat = Effect.fn("ResponseBridge.updateWorkingHeartbeat")(function* ( + workingDots: WorkingDotCount, + ) { + yield* streamWriteLock.withPermit( + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + let tipId = state.discordMessageIds.at(-1) ?? null; + const latest = yield* Ref.get(latestThreadRef); + const turnRunning = latest !== null && isTurnInProgress(latest); + const toolCallCount = currentTurnToolCallCount(latest); + + const hb = decideHeartbeat({ + state: state.delivery, + turnInProgress: turnRunning, + hasOpenTip: tipId !== null, + }); + if (hb._tag === "noop") return; + + // No tip yet but epoch still awaiting/streaming: recreate Working dots only. + if (tipId === null) { + if ( + !shouldRecreateTip({ + state: state.delivery, + updateFailed: true, + turnInProgress: turnRunning, + }) + ) { + return; + } + const content = formatInProgressChunk( + "", + true, + DISCORD_LIMIT, + workingDots, + toolCallCount, + ); + const created = yield* rest.createMessage(input.discordChannelId, { + ...workingMessageFields(content, input.t3ThreadId), + }); + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: [created.id], + seededWorkingAckPending: current.delivery.phase === "awaiting", + })); + yield* persistStreamMessageIds([created.id]); + yield* Effect.logInfo("Heartbeat recreated missing Working tip for running turn", { + t3ThreadId: input.t3ThreadId, + messageId: created.id, + epoch: state.delivery.epoch, + phase: state.delivery.phase, + toolCallCount, + }); + return; + } + + // If a human replied under us, freeze on the next stream write; heartbeat must + // not recreate a full-content tip under the user message. Bot Tasks posts are + // owned and must not silence the Working tip. + if (yield* isStreamTipDisplaced(tipId)) return; + + // Epoch FSM: awaiting → dots only; streaming → current epoch streamText only. + const tipDisplay = hb.tipBody; + if (state.streamBreakPrefix !== "" && tipDisplay.trim() === "") return; + + const chunks = + tipDisplay.trim() === "" + ? ([""] as string[]) + : chunkDiscordContent(tipDisplay, STREAM_CHUNK_LIMIT); + const tipChunk = chunks.at(-1) ?? ""; + const content = formatInProgressChunk( + tipChunk, + true, + DISCORD_LIMIT, + workingDots, + toolCallCount, + ); + const fields = workingMessageFields(content, input.t3ThreadId); + const updated = yield* rest + .updateMessage(input.discordChannelId, tipId, { ...fields }) + .pipe(Effect.result); + if ( + shouldRecreateTip({ + state: state.delivery, + updateFailed: Result.isFailure(updated), + turnInProgress: turnRunning, + }) + ) { + const created = yield* rest.createMessage(input.discordChannelId, { ...fields }); + tipId = created.id; + yield* Ref.update(stateRef, (current) => ({ + ...current, + discordMessageIds: [created.id], + staleStreamMessageIds: uniqueDiscordMessageIds([ + ...current.staleStreamMessageIds, + ...current.discordMessageIds, + ]), + })); + const after = yield* Ref.get(stateRef); + yield* persistStreamMessageIds(allStreamIds(after)); + yield* Effect.logWarning("Heartbeat recreated dead Working tip", { + t3ThreadId: input.t3ThreadId, + messageId: created.id, + cause: formatAlertCause(Result.isFailure(updated) ? updated.failure : "unknown", 200), + }); + } + }), + ); + }); + + const alertStreamFailure = (phase: string) => (cause: unknown) => + Effect.gen(function* () { + const pretty = formatAlertCause(cause); + yield* Effect.logError("Discord stream post/edit failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + phase, + cause: pretty, + }); + yield* postBridgeAlert( + `stream:${input.discordChannelId}:${phase}`, + "Discord stream post/edit failed", + [ + `channel=\`${input.discordChannelId}\``, + `thread=\`${input.t3ThreadId}\``, + `phase=\`${phase}\``, + pretty, + ].join("\n"), + ); + }).pipe(Effect.asVoid); + + /** + * Keep the pinned thread-info Model line in sync when the T3 thread model changes + * (Discord `--model` flag, web UI, etc.). Includes "since …, started with …" history. + */ + const syncThreadInfoModelPin = (thread: OrchestrationThread) => + Effect.gen(function* () { + const modelSelection = thread.modelSelection; + if (modelSelection === undefined || modelSelection === null) return; + + const modelLine = formatModelSelectionLine(modelSelection); + const link = yield* links.getByDiscordThreadId(input.discordChannelId); + if (link === null) return; + if (link.currentModelLine === modelLine) return; + + const botConfig = yield* DiscordBotConfig; + yield* upsertThreadInfoPin({ + discordThreadId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + botConfig, + modelSelection, + worktreePath: thread.worktreePath, + local: thread.worktreePath === null, + }); + yield* Effect.logInfo("Thread info pin model updated", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + previousModel: link.currentModelLine ?? null, + nextModel: modelLine, + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to sync thread info model pin", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + error: String(error), + }), + ), + ); + + /** + * Apply one thread snapshot to Discord (stream tip / finalize / tasks / title). + * Must not run concurrently for the same bridge — callers serialize via deliveryLock. + * + * Order matters: assistant stream/finalize runs **before** title/pin/side work. + * Mid-turn thread renames used to run first (with optional GH PR lookups) and hold + * this queue so intermediate tip edits coalesced away — Discord stayed on + * `_Working.._` until the final, while the channel name still updated. Finals + * still landed; live progress did not. + */ + const processThreadSnapshot = (thread: OrchestrationThread) => + Effect.gen(function* () { + yield* ensureVcsStatusSubscription(thread); + + // Cheap bookkeeping only — no Discord REST — so we do not delay the tip path. + const threadUserMessageIds = uniqueMessageIds( + thread.messages.filter((message) => message.role === "user").map((message) => message.id), + ); + const priorState = yield* Ref.get(stateRef); + const unseenExternalUserMessages = externalUserMessagesToEcho({ + messages: thread.messages, + observedInitialUserSnapshot: priorState.observedInitialUserSnapshot, + seenUserMessageIds: priorState.seenUserMessageIds, + sentDiscordUserMessageIds: priorState.sentDiscordUserMessageIds, + }); + const unresolvedSentDiscordUserMessageIds = priorState.sentDiscordUserMessageIds.filter( + (messageId) => !threadUserMessageIds.includes(messageId), + ); + yield* Ref.update(stateRef, (current) => ({ + ...current, + seenUserMessageIds: uniqueMessageIds([ + ...current.seenUserMessageIds, + ...threadUserMessageIds, + ]), + observedInitialUserSnapshot: true, + sentDiscordUserMessageIds: unresolvedSentDiscordUserMessageIds, + })); + if ( + unresolvedSentDiscordUserMessageIds.length !== priorState.sentDiscordUserMessageIds.length + ) { + yield* persistSentDiscordUserMessageIds(unresolvedSentDiscordUserMessageIds); + } + + const activeTurnId = thread.latestTurn?.turnId ?? null; + const turnInProgressNow = isTurnInProgress(thread); + + // Interrupted session (Wake Required): convert open Working tips to a Continue notice + // before catch-up finalize would treat partial stream as a finished answer. + { + const preWake = yield* Ref.get(stateRef); + if ( + shouldConvertWorkingTipsToWakeUp({ + sessionStatus: thread.session?.status ?? null, + activeTurnId: thread.session?.activeTurnId ?? null, + latestTurnState: thread.latestTurn?.state ?? null, + latestTurnCompletedAt: thread.latestTurn?.completedAt ?? null, + turnInProgress: turnInProgressNow, + openStreamTipCount: allStreamIds(preWake).length, + wakeUpNoticePosted: preWake.wakeUpNoticePosted, + }) + ) { + yield* convertWorkingTipsToWakeUp("session-interrupted").pipe( + Effect.catchCause(alertStreamFailure("wake-up-convert")), + Effect.asVoid, + ); + } + } + + const assistant = thread.messages.findLast((message) => message.role === "assistant"); + + // --- Primary: stream tip / finalize (user-visible progress) --- + // Skip stream/finalize when we just converted (or previously posted) a wake-up notice + // for a real mid-turn interrupt — partial content is not a completed final. + const afterWakeState = yield* Ref.get(stateRef); + if (isSessionWakeRequired(thread) && afterWakeState.wakeUpNoticePosted) { + // Secondary path still runs for title (❗) / pin / tasks. + } else if (assistant !== undefined) { + const prior = yield* Ref.get(stateRef); + + // Structural delivery: assistants allowed for Discord this snapshot (epoch FSM). + // lastFinalizedAssistantId excludes prior-turn bodies even while the new turn + // is already in progress (snapshot race before the new user message lands). + // Re-read durable cursor each snapshot so reconnect/catch-up cannot ignore a + // finalize written by a prior epoch or after a delivery-state reset. + const durableLinkNow = yield* links + .getByDiscordThreadId(input.discordChannelId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const lastFinalizedForDelivery = + prior.delivery.lastFinalizedAssistantId ?? + durableLinkNow?.lastFinalizedAssistantId ?? + persistedLink?.lastFinalizedAssistantId ?? + warmCacheEntry?.lastFinalizedAssistantId ?? + null; + const lastFinalizedTextForDelivery = prior.delivery.lastFinalizedText ?? null; + const threadMessagesForDelivery = thread.messages.map((message) => ({ + id: message.id, + role: message.role, + turnId: message.turnId, + text: message.text, + })); + const deliveryAssistants = assistantMessagesForDelivery({ + messages: threadMessagesForDelivery, + turnId: activeTurnId, + turnInProgress: turnInProgressNow, + hasLatestTurn: thread.latestTurn !== null && thread.latestTurn !== undefined, + lastFinalizedAssistantId: lastFinalizedForDelivery, + lastFinalizedText: lastFinalizedTextForDelivery, + }); + const deliveryAssistantIds = new Set(deliveryAssistants.map((entry) => entry.id)); + const turnAssistants = thread.messages.filter( + (message) => message.role === "assistant" && deliveryAssistantIds.has(message.id), + ); + const images = turnAssistants.flatMap((message) => + imageAttachmentsOf(message.attachments), + ); + const streaming = + turnInProgressNow || + turnAssistants.some( + (message) => isAssistantStreaming(thread, message.id) || message.streaming === true, + ); + + const decision = decideAssistantDelivery({ + state: { + ...prior.delivery, + // Keep durable finalize memory from the link store in sync. + lastFinalizedAssistantId: lastFinalizedForDelivery, + lastFinalizedText: lastFinalizedTextForDelivery, + }, + turnId: activeTurnId, + turnInProgress: turnInProgressNow, + assistants: deliveryAssistants.map((entry) => ({ + id: entry.id, + text: entry.text, + })), + messages: threadMessagesForDelivery, + streaming, + presentationFull: (input.presentationMode ?? "full") === "full", + // Idle catch-up (rehydrate *or* interactive recovery after a comment) often + // gets a single settled snapshot — do not stream the previous final under + // `_Working.._` + Stop waiting for a second settle tick that never arrives. + // Substantial answers also finalize immediately inside decideAssistantDelivery. + skipSettleGrace: !turnInProgressNow, + }); + + yield* Effect.logInfo("Discord delivery decision", { + t3ThreadId: input.t3ThreadId, + turnId: activeTurnId, + intent: decision.intent._tag, + reason: + decision.intent._tag === "noop" || decision.intent._tag === "hold" + ? decision.intent.reason + : null, + phase: decision.state.phase, + epoch: decision.state.epoch, + settleReady: decision.state.settleReady, + deliveryAssistants: deliveryAssistants.length, + streaming, + turnInProgress: turnInProgressNow, + }); + + // Apply non-terminal state before I/O; finalize commits only after successful post + // so a failed finalize can retry without being stuck in phase=finalized. + if (decision.intent._tag !== "finalize") { + // Late multi-bubble reopen: epoch left finalized → streaming/awaiting with a + // new assistant after lastFinalized. Clear bridge finalizedTurnId so stream / + // re-finalize is not treated as a no-op duplicate of the short status final. + const reopenedAfterPrematureFinal = + prior.delivery.phase === "finalized" && + (decision.state.phase === "streaming" || decision.state.phase === "awaiting"); + yield* Ref.update(stateRef, (current) => ({ + ...current, + delivery: decision.state, + adoptedInitialSnapshot: true, + seededWorkingAckPending: decision.state.phase === "awaiting", + lastAssistantText: + decision.state.phase === "streaming" + ? decision.state.streamText + : decision.state.phase === "awaiting" + ? "" + : current.lastAssistantText, + finalizedTurnId: + decision.state.phase === "awaiting" || reopenedAfterPrematureFinal + ? null + : current.finalizedTurnId, + currentTurnId: decision.state.turnId ?? current.currentTurnId, + t3AssistantMessageId: decision.state.assistantId ?? current.t3AssistantMessageId, + })); + } else { + yield* Ref.update(stateRef, (current) => ({ + ...current, + adoptedInitialSnapshot: true, + })); + } + + if (decision.intent._tag === "stream") { + yield* postOrEditAssistant({ + turnId: decision.intent.turnId, + t3MessageId: decision.intent.assistantId, + text: decision.intent.text, + streaming: true, + images, + worktreePath: thread.worktreePath, + }).pipe(Effect.catchCause(alertStreamFailure("live-stream")), Effect.asVoid); + } else if (decision.intent._tag === "finalize") { + yield* postOrEditAssistant({ + turnId: decision.intent.turnId, + t3MessageId: decision.intent.assistantId, + text: decision.intent.text, + streaming: false, + images, + worktreePath: thread.worktreePath, + }).pipe(Effect.catchCause(alertStreamFailure("finalize")), Effect.asVoid); + // Commit terminal epoch only after finalize attempt (success path updates tips). + yield* Ref.update(stateRef, (current) => ({ + ...current, + delivery: decision.state, + seededWorkingAckPending: false, + lastAssistantText: "", + finalizedTurnId: decision.state.turnId ?? activeTurnId, + currentTurnId: decision.state.turnId ?? current.currentTurnId, + t3AssistantMessageId: decision.state.assistantId, + })); + } else if ( + decision.intent._tag === "noop" && + decision.intent.reason === "settled-without-content" && + !prior.seededWorkingAckPending && + prior.delivery.phase !== "finalized" + ) { + // Prefer posting whatever we already streamed over wiping Working with silence. + const fallbackText = prior.lastAssistantText.trim(); + if (fallbackText !== "" && prior.t3AssistantMessageId !== null) { + yield* Effect.logInfo( + "Finalizing from last streamed body (settled without new content)", + { + t3ThreadId: input.t3ThreadId, + turnId: activeTurnId, + textLen: fallbackText.length, + }, + ); + yield* postOrEditAssistant({ + turnId: activeTurnId, + t3MessageId: prior.t3AssistantMessageId, + text: fallbackText, + streaming: false, + images: [], + worktreePath: thread.worktreePath, + }).pipe( + Effect.catchCause(alertStreamFailure("finalize-stream-fallback")), + Effect.asVoid, + ); + yield* Ref.update(stateRef, (current) => ({ + ...current, + delivery: { + ...current.delivery, + phase: "finalized" as const, + streamText: "", + settleReady: false, + finalizedAssistantId: prior.t3AssistantMessageId, + lastFinalizedAssistantId: prior.t3AssistantMessageId, + lastFinalizedText: fallbackText, + }, + seededWorkingAckPending: false, + lastAssistantText: "", + finalizedTurnId: activeTurnId, + })); + } else { + yield* clearInProgressMessages("settled-without-final-content").pipe( + Effect.catchCause(Effect.logError), + Effect.asVoid, + ); + } + } + } else { + const stateBefore = yield* Ref.get(stateRef); + const turnRunningNoAssistant = isTurnInProgress(thread); + yield* Ref.update(stateRef, (current) => + current.adoptedInitialSnapshot ? current : { ...current, adoptedInitialSnapshot: true }, + ); + const state = yield* Ref.get(stateRef); + yield* Effect.logInfo("Bridge thread update (no assistant message yet)", { + t3ThreadId: input.t3ThreadId, + messageCount: thread.messages.length, + sessionStatus: thread.session?.status ?? null, + turnState: thread.latestTurn?.state ?? null, + seededWorkingAckPending: state.seededWorkingAckPending, + state: summarizeBridgeStateForLog(state), + }); + if ( + turnRunningNoAssistant && + !state.seededWorkingAckPending && + shouldPublishAssistantUpdate({ + presentationMode: input.presentationMode ?? "full", + streaming: true, + }) + ) { + // Rehydrate / live: turn is spinning (tools only) with no assistant bubble yet. + // Keep or recreate a Working tip so Discord shows the turn is alive. + const needsWorkingTip = + mode === "rehydrate" || + !stateBefore.adoptedInitialSnapshot || + allStreamIds(state).length === 0; + if (needsWorkingTip) { + yield* Effect.logInfo("Ensuring Working tip for in-progress turn without assistant", { + t3ThreadId: input.t3ThreadId, + turnId: activeTurnId, + mode, + openTips: allStreamIds(state).length, + }); + yield* postOrEditAssistant({ + turnId: activeTurnId, + t3MessageId: REHYDRATE_WORKING_PLACEHOLDER_ID, + text: "", + streaming: true, + images: [], + worktreePath: thread.worktreePath, + }).pipe( + Effect.catchCause(alertStreamFailure("rehydrate-working-no-assistant")), + Effect.asVoid, + ); + } + } else if (!turnRunningNoAssistant && !state.seededWorkingAckPending) { + // A stopped/interrupted turn with no assistant content still needs to clear the + // initial Working.. ack from Discord. + yield* clearInProgressMessages("settled-without-assistant-message").pipe( + Effect.catchCause(Effect.logError), + Effect.asVoid, + ); + } + } + + // --- Secondary: title / pin / side posts (must not starve tip edits) --- + // Title first, with its own budget: turn settle must clear ⏳ even when GH + // lookup / tasks would burn the shared secondary timeout. Pending renames + // also retry on the Working heartbeat so idle threads do not stay stale. + yield* syncDiscordThreadTitle().pipe( + Effect.timeout("12 seconds"), + Effect.catchCause((cause) => + Effect.logWarning("Discord thread title sync failed or timed out", { + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + + // Best-effort + hard time budget for remaining secondary work. + yield* Effect.gen(function* () { + yield* syncThreadInfoModelPin(thread); + + // Tasks first: first-class Discord progress UI from turn.plan (not External User Input). + // Keep updating mid-turn / rehydrate; owned via taskDiscordMessageId so it never freezes Working. + const tasks = presentTasks(thread.activities, thread.latestTurn?.turnId ?? null); + if (tasks !== null && input.presentationMode !== "final-only") { + const content = formatTasksForDiscord(tasks); + const key = `${tasks.explanation ?? ""}:${tasks.tasks.map((task) => `${task.status}:${task.step}`).join("|")}`; + yield* postOrEditTasks(content, key).pipe( + Effect.catchCause(Effect.logError), + Effect.asVoid, + ); + } + + // Cross-surface user text only (github / t3-client whitelist) — after Tasks. + if (unseenExternalUserMessages.length > 0) { + yield* postExternalUserMessages(unseenExternalUserMessages).pipe( + Effect.catchCause(Effect.logError), + Effect.asVoid, + ); + } + + const pending = derivePendingInteractions(thread.activities); + const approvals = pending.filter( + (entry): entry is PendingApproval => entry.kind === "approval", + ); + yield* postApprovals(approvals).pipe(Effect.catchCause(Effect.logError), Effect.asVoid); + + if (thread.session?.status === "error" && thread.session.lastError) { + yield* rest + .createMessage(input.discordChannelId, { + content: `**T3 error:** ${thread.session.lastError}`, + }) + .pipe(Effect.catchCause(Effect.logError), Effect.asVoid); + } + }).pipe( + Effect.timeout(BRIDGE_SECONDARY_DISCORD_TIMEOUT), + Effect.catchCause((cause) => + Effect.logWarning("Bridge secondary Discord work failed or timed out", { + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + }).pipe(Effect.asVoid); + + /** + * Coalescing delivery queue. + * + * Previously every WS event awaited full Discord I/O inside Stream.runForEach. + * A slow/hung createMessage blocked the subscription fiber, so later assistant + * bubbles never reached Discord while the T3 client still advanced. + * + * Now: WS only publishes the latest snapshot + bumps a generation. A single + * worker holds deliveryLock, always applies the newest snapshot, and loops if + * more arrivals happened during Discord work. + */ + const deliveryGenerationRef = yield* Ref.make(0); + const deliveryProcessedRef = yield* Ref.make(0); + const deliveryFailureCountRef = yield* Ref.make(0); + const deliveryLock = yield* Semaphore.make(1); + // Dual cursor: orchestration sequence observed from T3 (WS/HTTP), vs sequence + // successfully applied to Discord after processThreadSnapshot. + const latestObservedSequenceRef = yield* Ref.make( + persistedLink?.lastThreadSnapshotSequence ?? null, + ); + const persistDeliverySequence = (sequence: number) => + links + .updateBridgeHints(input.discordChannelId, { + lastDeliveredSequence: sequence, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to persist delivery sequence cursor", { + discordChannelId: input.discordChannelId, + sequence, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + + /** + * After processThreadSnapshot TimeoutError / failure: refresh from HTTP and keep + * the delivery generation open so we retry instead of going silent until a new WS + * event or the 12s reconcile tick. + */ + const recoverAfterDeliveryFailure = (failureCount: number) => + Effect.gen(function* () { + const backoffSec = deliveryFailureBackoffSeconds(failureCount); + yield* Effect.logWarning("Bridge delivery auto-recover: backoff then HTTP reseed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + failureCount, + backoffSec, + }); + yield* Effect.sleep(`${backoffSec} seconds`); + const snapshot = yield* t3.fetchThreadDetail(input.t3ThreadId as ThreadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Bridge delivery auto-recover fetch failed", { + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 300), + }).pipe(Effect.as(null)), + ), + ); + if (snapshot === null) return; + yield* Ref.set(latestObservedSequenceRef, snapshot.snapshotSequence); + yield* links + .updateBridgeHints(input.discordChannelId, { + lastThreadSnapshotSequence: snapshot.snapshotSequence, + }) + .pipe(Effect.catchCause(Effect.logWarning), Effect.asVoid); + yield* Ref.set(latestThreadRef, snapshot.thread); + // Bump generation so even if we marked the failed gen processed, a fresh + // attempt is queued. Worker loop also continues when we leave processed behind. + yield* Ref.update(deliveryGenerationRef, (n) => n + 1); + yield* Effect.logInfo("Bridge delivery auto-recover reseeded snapshot", { + t3ThreadId: input.t3ThreadId, + sequence: snapshot.snapshotSequence, + turnState: snapshot.thread.latestTurn?.state ?? null, + messageCount: snapshot.thread.messages.length, + }); + }).pipe(Effect.asVoid); + + const runDeliveryWorker = Effect.gen(function* () { + while (true) { + const pending = yield* Ref.get(deliveryGenerationRef); + const processed = yield* Ref.get(deliveryProcessedRef); + if (pending <= processed) return; + const latest = yield* Ref.get(latestThreadRef); + if (latest === null) { + yield* Ref.set(deliveryProcessedRef, pending); + return; + } + const processResult = yield* processThreadSnapshot(latest).pipe( + // Outer safety net: stream timeouts above usually suffice; secondary work is + // separately capped so title/tasks cannot burn this whole budget. + Effect.timeout(BRIDGE_PROCESS_SNAPSHOT_TIMEOUT), + Effect.result, + ); + if (Result.isFailure(processResult)) { + const pretty = formatAlertCause(processResult.failure); + const failureCount = yield* Ref.updateAndGet(deliveryFailureCountRef, (n) => n + 1); + yield* Effect.logError("Thread bridge processThreadSnapshot failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + failureCount, + cause: pretty, + }); + // Alert on first failure and every 5th thereafter so recover loops are not spam. + if (failureCount === 1 || failureCount % 5 === 0) { + yield* postBridgeAlert( + `onThread:${input.discordChannelId}`, + "Thread bridge processThreadSnapshot failed (auto-recovering)", + [ + `channel=\`${input.discordChannelId}\``, + `thread=\`${input.t3ThreadId}\``, + `mode=\`${mode}\``, + `failureCount=${failureCount}`, + pretty, + ].join("\n"), + ); + } + // Do not advance lastDeliveredSequence — delivery lag keeps HTTP reconcile on. + // Close this generation, reseed from HTTP (backoff), bump a new generation, retry. + yield* Ref.set(deliveryProcessedRef, pending); + if ( + shouldRetryDeliveryFailure({ + failureCount, + maxRetries: BRIDGE_DELIVERY_FAILURE_MAX_RETRIES, + }) + ) { + yield* recoverAfterDeliveryFailure(failureCount); + continue; + } + // Exhausted in-worker retries — one last reseed, reset counter, defer further + // attempts to the 12s HTTP reconcile fiber (open tips / delivery lag). + yield* Effect.logError( + "Bridge delivery auto-recover exhausted in-worker retries; deferring to HTTP reconcile", + { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + failureCount, + }, + ); + yield* recoverAfterDeliveryFailure(failureCount); + yield* Ref.set(deliveryFailureCountRef, 0); + continue; + } + + yield* Ref.set(deliveryFailureCountRef, 0); + // Snapshot applied to Discord (stream/finalize/noop). Advance delivery cursor + // to the latest orchestration sequence we have observed for this thread. + const observed = yield* Ref.get(latestObservedSequenceRef); + if (observed !== null && Number.isFinite(observed)) { + yield* persistDeliverySequence(observed); + // Durable warm base (trimmed) for restart resume without full HTTP tip. + yield* persistWarmThreadCache(latest, observed); + } + // Mark the generation we *started* with as done; if newer arrived mid-run, + // the loop continues and re-applies the latest snapshot. + yield* Ref.set(deliveryProcessedRef, pending); + } + }); + + const scheduleThreadDelivery = (thread: OrchestrationThread) => + Effect.gen(function* () { + // Unblock BridgeHub.ensure as soon as any snapshot arrives (do not wait for Discord). + yield* signalReady; + const bridgeState = yield* Ref.get(stateRef); + if (bridgeState.delivery.lastFinalizedAssistantId !== null) { + deliveredMemoryTrim.lastFinalizedAssistantId = + bridgeState.delivery.lastFinalizedAssistantId; + } + yield* Ref.set(latestThreadRef, projectThreadForDiscordMemory(thread)); + yield* Ref.update(deliveryGenerationRef, (n) => n + 1); + yield* deliveryLock.withPermit(runDeliveryWorker).pipe(Effect.forkDetach); + }); + + /** + * Retry pending / desynced Discord titles without waiting for another T3 snapshot. + * Rate-limits and secondary timeouts used to leave ⏳ after settle forever. + * Only wakes full sync when needed — never cold-starts GH every tick. + */ + const flushDiscordThreadTitleIfNeeded = Effect.gen(function* () { + const pendingTitle = yield* Ref.get(pendingDesiredThreadTitleRef); + const stateNow = yield* Ref.get(stateRef); + const threadNow = yield* Ref.get(latestThreadRef); + const mirroredActivity = parseDiscordThreadTitleBadges(stateNow.mirroredThreadTitle).activity; + const desiredActivity = + threadNow === null + ? null + : resolveDiscordThreadActivityBadgeState({ + sessionStatus: threadNow.session?.status ?? null, + activeTurnId: threadNow.session?.activeTurnId ?? null, + latestTurnState: threadNow.latestTurn?.state ?? null, + latestTurnCompletedAt: threadNow.latestTurn?.completedAt ?? null, + }); + const titleNeedsSync = + (pendingTitle !== null && pendingTitle !== stateNow.mirroredThreadTitle) || + desiredActivity !== mirroredActivity; + if (!titleNeedsSync) return; + yield* syncDiscordThreadTitle(); + }); + + const titleRetryFiber = yield* Effect.gen(function* () { + while (true) { + yield* Effect.sleep("15 seconds"); + yield* flushDiscordThreadTitleIfNeeded.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Periodic Discord title retry failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 200), + }), + ), + Effect.asVoid, + ); + } + }).pipe(Effect.forkChild); + + const heartbeatFiber = + input.presentationMode === "final-only" + ? null + : yield* Effect.gen(function* () { + let workingDots: WorkingDotCount = 2; + while (true) { + yield* Effect.sleep("10 seconds"); + workingDots = nextWorkingDotCount(workingDots); + yield* updateWorkingHeartbeat(workingDots).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const pretty = formatAlertCause(cause); + yield* Effect.logWarning("Failed to update Discord Working heartbeat", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: pretty, + }); + yield* postBridgeAlert( + `heartbeat:${input.discordChannelId}`, + "Working heartbeat failed", + [ + `channel=\`${input.discordChannelId}\``, + `thread=\`${input.t3ThreadId}\``, + pretty, + ].join("\n"), + ); + }).pipe(Effect.asVoid), + ), + ); + } + }).pipe(Effect.forkChild); + + /** + * HTTP reconcile: when Working tips stay open or a turn is running, re-fetch the + * thread over HTTP even if the WS stream has gone quiet. This is the recovery path + * for "T3 client has the answer, Discord still says Working..". + */ + const reconcileFiber = yield* Effect.gen(function* () { + while (true) { + yield* Effect.sleep(BRIDGE_HTTP_RECONCILE_INTERVAL); + const state = yield* Ref.get(stateRef); + const latest = yield* Ref.get(latestThreadRef); + const openStreamTipCount = allStreamIds(state).length; + const turnInProgress = latest !== null && isTurnInProgress(latest); + // We started work from Discord (user message and/or Working ack) but never + // recorded a finalize for the current turn — keep reconciling until we do. + const awaitingDiscordFinal = + state.finalizedTurnId === null && + (state.seededWorkingAckPending || + state.sentDiscordUserMessageIds.length > 0 || + openStreamTipCount > 0); + const linkCursors = yield* links + .getByDiscordThreadId(input.discordChannelId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const deliveryLagging = isDeliveryBehindOrchestration({ + lastDeliveredSequence: + linkCursors?.lastDeliveredSequence ?? persistedLink?.lastDeliveredSequence ?? null, + lastThreadSnapshotSequence: + (yield* Ref.get(latestObservedSequenceRef)) ?? + linkCursors?.lastThreadSnapshotSequence ?? + persistedLink?.lastThreadSnapshotSequence ?? + null, + }); + if ( + !bridgeNeedsHttpReconcile({ + openStreamTipCount, + seededWorkingAckPending: state.seededWorkingAckPending, + turnInProgress, + awaitingDiscordFinal, + deliveryLagging, + }) + ) { + continue; + } + + const snapshot = yield* t3.fetchThreadDetail(input.t3ThreadId as ThreadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Bridge HTTP reconcile fetch failed", { + t3ThreadId: input.t3ThreadId, + cause: formatAlertCause(cause, 300), + }).pipe(Effect.as(null)), + ), + ); + if (snapshot === null) continue; + + yield* Effect.logInfo("Bridge HTTP reconcile applying snapshot", { + t3ThreadId: input.t3ThreadId, + sequence: snapshot.snapshotSequence, + openStreamTipCount, + turnState: snapshot.thread.latestTurn?.state ?? null, + sessionStatus: snapshot.thread.session?.status ?? null, + messageCount: snapshot.thread.messages.length, + awaitingDiscordFinal, + deliveryLagging, + }); + // Orchestration cursor only — delivery cursor advances after process succeeds. + yield* Ref.set(latestObservedSequenceRef, snapshot.snapshotSequence); + yield* links + .updateBridgeHints(input.discordChannelId, { + lastThreadSnapshotSequence: snapshot.snapshotSequence, + }) + .pipe(Effect.catchCause(Effect.logWarning), Effect.asVoid); + yield* scheduleThreadDelivery(snapshot.thread); + } + }).pipe(Effect.forkChild); + + // botUserId is used for finalize accept-without-ack adoption scans. + + const storedSequence = persistedLink?.lastThreadSnapshotSequence ?? null; + const storedDeliveredSequence = persistedLink?.lastDeliveredSequence ?? null; + const initialAfterSequence = resolveSubscribeAfterSequence({ + lastDeliveredSequence: storedDeliveredSequence, + lastThreadSnapshotSequence: storedSequence, + }); + const subscribeSeed = resolveThreadSubscribeSeed({ + warm: + warmCacheEntry !== null + ? { + snapshotSequence: warmCacheEntry.snapshotSequence, + thread: warmCacheEntry.thread, + } + : null, + afterSequence: initialAfterSequence, + }); + yield* Effect.logInfo("Bridge subscribing to T3 thread stream", { + t3ThreadId: input.t3ThreadId, + storedSequence, + storedDeliveredSequence, + afterSequence: initialAfterSequence, + seedKind: subscribeSeed.kind, + warmSequence: warmCacheEntry?.snapshotSequence ?? null, + warmMessageCount: warmCacheEntry?.thread.messages.length ?? null, + deliveryLagging: isDeliveryBehindOrchestration({ + lastDeliveredSequence: storedDeliveredSequence, + lastThreadSnapshotSequence: storedSequence, + }), + }); + // WS callback only enqueues; Discord I/O runs on the delivery worker. + const onThreadGuarded = (thread: OrchestrationThread) => + scheduleThreadDelivery(thread).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const pretty = formatAlertCause(cause); + yield* Effect.logError("Thread bridge scheduleThreadDelivery failed", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: pretty, + }); + yield* postBridgeAlert( + `onThread:${input.discordChannelId}`, + "Thread bridge scheduleThreadDelivery failed", + [ + `channel=\`${input.discordChannelId}\``, + `thread=\`${input.t3ThreadId}\``, + `mode=\`${mode}\``, + pretty, + ].join("\n"), + ); + }).pipe(Effect.asVoid), + ), + ); + + // Orchestration cursor: advances as soon as T3 state is observed (performant WS + // resume). Delivery cursor is separate and only moves after Discord I/O succeeds. + // Both are O(1) scalars — never an event/history log. + const persistSequenceMarker = (sequence: number) => + Effect.gen(function* () { + yield* Ref.set(latestObservedSequenceRef, sequence); + yield* links.updateBridgeHints(input.discordChannelId, { + lastThreadSnapshotSequence: sequence, + }); + }).pipe(Effect.asVoid); + + // Client-aligned follow (DiscordThreadFollower) + durable warm tip: + // prefer warm base + afterSequence (web/desktop EnvironmentCacheStore pattern); + // HTTP full tip only when warm is missing. Dual-cursor afterSequence prefers + // delivery high-water. Follower owns reload-required + durable resubscribe. + // ResponseBridge only projects OrchestrationThread → Discord (coalesce queue). + const warmForSubscribe = yield* warmCache + .load(input.t3ThreadId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const seed = resolveThreadSubscribeSeed({ + warm: + warmForSubscribe !== null + ? { + snapshotSequence: warmForSubscribe.snapshotSequence, + thread: warmForSubscribe.thread, + } + : null, + afterSequence: initialAfterSequence, + }); + yield* t3 + .subscribeThread(input.t3ThreadId as ThreadId, onThreadGuarded, { + ...(seed.kind === "warm" + ? { + afterSequence: seed.afterSequence, + warmSeed: { + snapshotSequence: seed.afterSequence, + thread: seed.thread, + }, + } + : seed.kind === "http" + ? { afterSequence: seed.afterSequence } + : initialAfterSequence !== null && initialAfterSequence >= 0 + ? { afterSequence: initialAfterSequence } + : {}), + onSequence: persistSequenceMarker, + projectThread: projectThreadForDiscordMemory, + }) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + // Follower retries internally; this is only if the outer effect is interrupted + // or fails without recovery. + const pretty = formatAlertCause(cause); + yield* Effect.logError("Bridge subscribeThread exited", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + cause: pretty, + }); + yield* postFatalAlert( + `subscribe:${input.t3ThreadId}`, + "T3 thread subscription exited", + `channel=\`${input.discordChannelId}\` thread=\`${input.t3ThreadId}\`\n${pretty}`, + ); + }).pipe(Effect.asVoid), + ), + Effect.ensuring( + Effect.gen(function* () { + if (heartbeatFiber !== null) { + yield* Fiber.interrupt(heartbeatFiber).pipe(Effect.ignore); + } + yield* Fiber.interrupt(titleRetryFiber).pipe(Effect.ignore); + yield* Fiber.interrupt(reconcileFiber).pipe(Effect.ignore); + yield* stopVcsStatusSubscription; + // Do NOT delete open stream tips on stop when the turn is still running. + // Restart/reconnect must leave Working.. visible so rehydrate can resume it. + // Interactive re-ensure already orphan-cleans via seedStreamMessageIds when a + // fresh Working ack is posted for a new user turn. + const state = yield* Ref.get(stateRef); + const latest = yield* Ref.get(latestThreadRef); + const openTips = allStreamIds(state); + const turnRunning = latest !== null && isTurnInProgress(latest); + if ( + shouldPreserveStreamTipsOnBridgeStop({ + turnInProgress: turnRunning, + openStreamTipCount: openTips.length, + }) + ) { + yield* Effect.logInfo( + "Discord↔T3 bridge stopped; preserving in-progress stream tips for rehydrate", + { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + tipIds: openTips, + turnState: latest?.latestTurn?.state ?? null, + }, + ); + } else if (openTips.length > 0) { + // Idle bridge replacement: drop leftover tips so they do not linger. + yield* streamWriteLock.withPermit(deleteMessages(openTips)); + yield* persistStreamMessageIds([]); + yield* Effect.logInfo("Discord↔T3 bridge stopped; cleared idle stream tips", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + tipIds: openTips, + }); + } else { + yield* Effect.logInfo("Discord↔T3 bridge stopped", { + discordChannelId: input.discordChannelId, + t3ThreadId: input.t3ThreadId, + }); + } + }), + ), + ); + }); diff --git a/apps/discord-bot/src/features/TeamsModule.ts b/apps/discord-bot/src/features/TeamsModule.ts new file mode 100644 index 00000000000..5a0218a6c39 --- /dev/null +++ b/apps/discord-bot/src/features/TeamsModule.ts @@ -0,0 +1,634 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off globalFetch:off globalTimers:off missingEffectError:off globalDateInEffect:off globalDate:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off globalFetchInEffect:off preferSchemaOverJson:off +import { DiscordConfig, DiscordREST } from "dfx"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +import type { DiscordBotConfig } from "../config.ts"; +import { createMessageWithAttachments, DiscordUploadError } from "../presentation/discordFiles.ts"; +import { TeamsSeenStore } from "../store/TeamsSeenStore.ts"; +import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; +import { truncateTitle } from "../presentation/messages.ts"; +import { startOrContinueLinkedTurn, startOrContinueT3Turn } from "./LinkedTurnRouter.ts"; +import type { DiscordUploadFile } from "../presentation/discordFiles.ts"; +import { downloadTeamsMessageImages } from "../teams/attachments.ts"; +import { loadTeamsChannelConfigsFromFileSync, type TeamsChannelConfig } from "../teams/config.ts"; +import { + buildTeamsIncidentTitle, + buildTeamsPrompt, + buildTeamsSeedMessage, + hasAllowlistedReaction, + hasInternalTagTrigger, + isHumanTeamsMessage, + looksLikeGermanProblemReport, + mentionsTeamsBot, + rootTeamsMessageId, + teamsMessageTimestamp, + type TeamsMessage, +} from "../teams/presentation.ts"; + +interface GraphListResponse { + readonly value?: ReadonlyArray | undefined; + readonly "@odata.nextLink"?: string | undefined; +} + +interface GraphHostedContentResponse { + readonly value?: + | ReadonlyArray<{ + readonly id?: string | undefined; + readonly contentType?: string | undefined; + }> + | undefined; +} + +function channelKey(channel: TeamsChannelConfig): string { + return `${channel.teamId}/${channel.channelId}`; +} + +function sourceThreadKey(channel: TeamsChannelConfig, rootMessageId: string): string { + return `${channel.teamId}/${channel.channelId}/${rootMessageId}`; +} + +function oauthRequestBody(config: DiscordBotConfig): URLSearchParams { + const body = new URLSearchParams(); + body.set("grant_type", "client_credentials"); + body.set("client_id", config.teamsClientId ?? ""); + body.set("client_secret", config.teamsClientSecret ?? ""); + body.set("scope", "https://graph.microsoft.com/.default"); + return body; +} + +async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await globalThis.fetch(input, init); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return (await response.json()) as unknown; +} + +function nowLookbackStart(): Date { + const now = new Date(); + const dayOfWeek = now.getUTCDay(); + if (dayOfWeek === 6 || dayOfWeek === 0 || dayOfWeek === 1) { + const daysSinceFriday = dayOfWeek === 6 ? 1 : dayOfWeek === 0 ? 2 : 3; + const friday = new Date(now); + friday.setUTCDate(now.getUTCDate() - daysSinceFriday); + friday.setUTCHours(0, 0, 0, 0); + return friday; + } + + return new Date(now.getTime() - 24 * 60 * 60 * 1000); +} + +export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: DiscordBotConfig) { + if (!config.teamsEnabled) { + yield* Effect.logInfo("Teams module disabled"); + return; + } + if ( + !config.teamsTenantId || + !config.teamsClientId || + !config.teamsClientSecret || + !config.teamsChannelsPath + ) { + yield* Effect.logWarning("Teams module enabled but auth/config is incomplete; skipping"); + return; + } + + const channels = loadTeamsChannelConfigsFromFileSync(config.teamsChannelsPath); + if (channels.length === 0) { + yield* Effect.logWarning("Teams module enabled but channel config is empty; skipping"); + return; + } + + const rest = yield* DiscordREST; + const discordConfig = yield* DiscordConfig.DiscordConfig; + const seenStore = yield* TeamsSeenStore; + const links = yield* ThreadLinkStore; + + let cachedAccessToken: { readonly token: string; readonly expiresAt: number } | null = null; + + const getAccessToken = Effect.fn("runTeamsModule.getAccessToken")(function* () { + const now = Date.now(); + if (cachedAccessToken !== null && cachedAccessToken.expiresAt > now + 60_000) { + return cachedAccessToken.token; + } + + const tokenResponse = (yield* Effect.tryPromise({ + try: () => + fetchJson(`https://login.microsoftonline.com/${config.teamsTenantId}/oauth2/v2.0/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: oauthRequestBody(config), + }), + catch: (cause) => new Error(String(cause)), + })) as { + readonly access_token?: string; + readonly expires_in?: number; + }; + + const token = tokenResponse.access_token ?? ""; + const expiresIn = tokenResponse.expires_in ?? 3600; + if (token.length === 0) { + return yield* Effect.fail( + new Error("Microsoft Graph token response did not include access_token."), + ); + } + cachedAccessToken = { + token, + expiresAt: now + expiresIn * 1000, + }; + return token; + }); + + const graphGet = Effect.fn("runTeamsModule.graphGet")(function* (pathOrUrl: string) { + const accessToken = yield* getAccessToken(); + return (yield* Effect.tryPromise({ + try: () => + fetchJson( + pathOrUrl.startsWith("https://") + ? pathOrUrl + : `https://graph.microsoft.com/v1.0${pathOrUrl}`, + { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ), + catch: (cause) => new Error(String(cause)), + })) as GraphListResponse; + }); + + const graphGetUnknown = Effect.fn("runTeamsModule.graphGetUnknown")(function* (path: string) { + const accessToken = yield* getAccessToken(); + return yield* Effect.tryPromise({ + try: () => + fetchJson(`https://graph.microsoft.com/v1.0${path}`, { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }), + catch: (cause) => new Error(String(cause)), + }); + }); + + const messageIsWithinLookback = (message: TeamsMessage, lookbackStartMs: number): boolean => { + const createdAt = Date.parse(message.createdDateTime ?? ""); + return !Number.isFinite(createdAt) || createdAt >= lookbackStartMs; + }; + + const listPagedMessages = Effect.fn("runTeamsModule.listPagedMessages")(function* ( + initialPath: string, + lookbackStartMs: number, + ) { + const collected: TeamsMessage[] = []; + let cursor: string | null = initialPath; + + while (cursor !== null) { + const page: GraphListResponse = yield* graphGet(cursor); + const pageMessages: TeamsMessage[] = [...(page.value ?? [])]; + collected.push(...pageMessages); + + const shouldContinue: boolean = pageMessages.some((message: TeamsMessage) => + messageIsWithinLookback(message, lookbackStartMs), + ); + cursor = shouldContinue ? (page["@odata.nextLink"] ?? null) : null; + } + + return collected.filter((message) => messageIsWithinLookback(message, lookbackStartMs)); + }); + + const postTeamsWebhookAck = Effect.fn("runTeamsModule.postTeamsWebhookAck")(function* ( + channel: TeamsChannelConfig, + message: TeamsMessage, + ) { + if (!channel.respondToMentions || !channel.teamsIncomingWebhookUrl) return; + yield* Effect.tryPromise({ + try: () => + globalThis.fetch(channel.teamsIncomingWebhookUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + text: `Triage started for ${channel.company}/${channel.environment}: ${truncateTitle( + buildTeamsIncidentTitle({ + company: channel.company, + environment: channel.environment, + message, + }), + 140, + )}`, + }), + }), + catch: (cause) => new Error(String(cause)), + }).pipe(Effect.ignore); + }); + + const openDiscordThread = Effect.fn("runTeamsModule.openDiscordThread")(function* ( + channel: TeamsChannelConfig, + message: TeamsMessage, + reason: "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag", + imageFiles: ReadonlyArray, + ) { + const discordChannelId = channel.discordChannelId; + if (discordChannelId === undefined) { + return yield* Effect.fail( + new Error( + `Teams channel ${channel.teamId}/${channel.channelId} uses Discord delivery without discordChannelId.`, + ), + ); + } + const seedContent = buildTeamsSeedMessage({ + company: channel.company, + environment: channel.environment, + channelName: channel.channelName, + message, + reason, + }); + const seed = + imageFiles.length === 0 + ? yield* rest.createMessage(discordChannelId, { + content: seedContent, + }) + : yield* Effect.tryPromise({ + try: () => + createMessageWithAttachments({ + baseUrl: discordConfig.rest.baseUrl, + botToken: Redacted.value(discordConfig.token), + channelId: discordChannelId, + content: seedContent, + files: imageFiles, + }), + catch: (cause) => + cause instanceof DiscordUploadError + ? cause + : new DiscordUploadError(cause instanceof Error ? cause.message : String(cause)), + }); + const discordThread = yield* rest.createThreadFromMessage(discordChannelId, seed.id, { + name: truncateTitle( + buildTeamsIncidentTitle({ + company: channel.company, + environment: channel.environment, + message, + }), + ), + auto_archive_duration: 1440, + }); + return discordThread.id; + }); + + const hostedContentsPath = (channel: TeamsChannelConfig, message: TeamsMessage): string => + message.replyToId + ? `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.replyToId)}/replies/${encodeURIComponent(message.id)}/hostedContents` + : `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.id)}/hostedContents`; + + const buildHostedContentValueUrl = ( + channel: TeamsChannelConfig, + message: TeamsMessage, + hostedContentId: string, + ): string => + `https://graph.microsoft.com/v1.0${ + message.replyToId + ? `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.replyToId)}/replies/${encodeURIComponent(message.id)}/hostedContents/${encodeURIComponent(hostedContentId)}/$value` + : `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(message.id)}/hostedContents/${encodeURIComponent(hostedContentId)}/$value` + }`; + + const listHostedContentsForMessage = Effect.fn("runTeamsModule.listHostedContentsForMessage")( + function* (channel: TeamsChannelConfig, message: TeamsMessage) { + const response = (yield* graphGetUnknown(hostedContentsPath(channel, message)).pipe( + Effect.orElseSucceed(() => ({ value: [] }) satisfies GraphHostedContentResponse), + )) as GraphHostedContentResponse; + return (response.value ?? []) + .filter( + (entry): entry is { readonly id: string; readonly contentType?: string | undefined } => + typeof entry.id === "string" && entry.id.length > 0, + ) + .map((entry) => ({ + id: entry.id, + contentType: entry.contentType, + valueUrl: buildHostedContentValueUrl(channel, message, entry.id), + })); + }, + ); + + const recentHistoryForMessage = ( + channel: TeamsChannelConfig, + messages: ReadonlyArray, + targetMessage: TeamsMessage, + processedRootKeys: ReadonlySet, + ): ReadonlyArray => { + const targetIndex = messages.findIndex((message) => message.id === targetMessage.id); + if (targetIndex <= 0) return []; + + const targetTime = Date.parse(targetMessage.createdDateTime ?? ""); + const history: TeamsMessage[] = []; + for (let index = targetIndex - 1; index >= 0; index -= 1) { + const candidate = messages[index]!; + if (!isHumanTeamsMessage(candidate)) continue; + if (processedRootKeys.has(sourceThreadKey(channel, rootTeamsMessageId(candidate)))) break; + const candidateTime = Date.parse(candidate.createdDateTime ?? ""); + if ( + Number.isFinite(targetTime) && + Number.isFinite(candidateTime) && + targetTime - candidateTime > 2 * 60 * 60 * 1000 + ) { + break; + } + history.push(candidate); + } + + return history.toReversed(); + }; + + const processedRootKeysForChannel = Effect.fn("runTeamsModule.processedRootKeysForChannel")( + function* (channel: TeamsChannelConfig) { + const prefix = `${channel.teamId}/${channel.channelId}/`; + const allLinks = yield* links.list(); + return new Set( + allLinks + .filter((link) => (link.sourceKind ?? "discord") === "teams") + .map((link) => link.sourceThreadId) + .filter( + (value): value is string => typeof value === "string" && value.startsWith(prefix), + ), + ); + }, + ); + + const findMessageById = ( + messages: ReadonlyArray, + messageId: string | null | undefined, + ): TeamsMessage | null => + typeof messageId === "string" + ? (messages.find((message) => message.id === messageId) ?? null) + : null; + + const resolveTagTargetMessage = ( + message: TeamsMessage, + messages: ReadonlyArray, + ): TeamsMessage => findMessageById(messages, message.replyToId) ?? message; + + type TriggerReason = "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag"; + + const classifyTrigger = (input: { + readonly channel: TeamsChannelConfig; + readonly message: TeamsMessage; + readonly messages: ReadonlyArray; + readonly alreadySeen: boolean; + }): { + readonly reason: TriggerReason; + readonly targetMessage: TeamsMessage; + readonly triggerMessage?: TeamsMessage | undefined; + } | null => { + const mentionTrigger = + !input.alreadySeen && mentionsTeamsBot(input.message, config.teamsBotDisplayName); + if (mentionTrigger) { + return { + reason: "mention", + targetMessage: input.message, + }; + } + + const reactionTrigger = hasAllowlistedReaction({ + message: input.message, + allowlistedUserIds: input.channel.internalUserIds, + reactionTriggerTypes: input.channel.reactionTriggerTypes, + }); + if (reactionTrigger) { + return { + reason: "allowlisted-reaction", + targetMessage: input.message, + }; + } + + const tagTrigger = + !input.alreadySeen && + hasInternalTagTrigger({ + message: input.message, + allowlistedUserIds: input.channel.internalUserIds, + messageTagTriggers: input.channel.messageTagTriggers, + }); + if (tagTrigger) { + return { + reason: "internal-tag", + targetMessage: resolveTagTargetMessage(input.message, input.messages), + triggerMessage: input.message, + }; + } + + const automaticAssessmentEnabled = input.channel.automaticAssessmentEnabled ?? true; + const germanProblemTrigger = + automaticAssessmentEnabled && + !input.alreadySeen && + looksLikeGermanProblemReport({ + message: input.message, + companyKeywords: input.channel.companyKeywords, + environmentKeywords: input.channel.environmentKeywords, + problemKeywords: input.channel.problemKeywords, + }); + if (germanProblemTrigger) { + return { + reason: "german-problem", + targetMessage: input.message, + }; + } + + return null; + }; + + const processMessage = Effect.fn("runTeamsModule.processMessage")(function* ( + channel: TeamsChannelConfig, + message: TeamsMessage, + messages: ReadonlyArray, + seenIds: Set, + processedRootKeys: Set, + ) { + if (!isHumanTeamsMessage(message)) return; + + const alreadySeen = seenIds.has(message.id); + const trigger = classifyTrigger({ + channel, + message, + messages, + alreadySeen, + }); + + if (!alreadySeen) { + yield* seenStore.markSeen(channelKey(channel), message.id); + seenIds.add(message.id); + } + + if (trigger === null) return; + + const rootMessageId = rootTeamsMessageId(trigger.targetMessage); + const rootKey = sourceThreadKey(channel, rootMessageId); + if (processedRootKeys.has(rootKey)) return; + + const accessToken = yield* getAccessToken(); + const hostedContents = yield* listHostedContentsForMessage(channel, trigger.targetMessage); + const imageDownloads = yield* Effect.tryPromise({ + try: () => + downloadTeamsMessageImages({ + message: trigger.targetMessage, + accessToken, + hostedContentEntries: hostedContents, + }), + catch: (cause) => new Error(String(cause)), + }).pipe( + Effect.catch((error) => + Effect.logError("Failed to download Teams image attachments", { + error: String(error), + }).pipe( + Effect.as({ + discordFiles: [] as ReadonlyArray, + t3Uploads: [], + skipped: [], + }), + ), + ), + ); + if (imageDownloads.skipped.length > 0) { + yield* Effect.logWarning("Skipped some Teams image attachments", { + skipped: imageDownloads.skipped, + }); + } + + const history = recentHistoryForMessage( + channel, + messages, + trigger.targetMessage, + processedRootKeys, + ); + + const prompt = buildTeamsPrompt({ + channelName: channel.channelName, + company: channel.company, + environment: channel.environment, + projectShortName: channel.projectShortName, + reason: trigger.reason, + message: trigger.targetMessage, + triggerMessage: trigger.triggerMessage, + history, + }); + if (channel.deliveryMode === "discord") { + const discordChannelId = channel.discordChannelId; + if (discordChannelId === undefined) { + return yield* Effect.fail( + new Error( + `Teams channel ${channel.teamId}/${channel.channelId} uses Discord delivery without discordChannelId.`, + ), + ); + } + const existing = yield* links.getBySourceThread("teams", rootKey); + const discordThreadId = + existing?.discordThreadId ?? + (yield* openDiscordThread( + channel, + trigger.targetMessage, + trigger.reason, + imageDownloads.discordFiles, + )); + yield* startOrContinueLinkedTurn(config, { + source: { + sourceKind: "teams", + sourceThreadId: rootKey, + }, + discordThreadId, + discordParentChannelId: discordChannelId, + discordGuildId: "", + projectShortName: channel.projectShortName, + prompt, + flags: {}, + ...(imageDownloads.t3Uploads.length > 0 ? { attachments: imageDownloads.t3Uploads } : {}), + announceLines: [ + `Linked **${channel.projectShortName}**`, + `Source: Teams / ${channel.channelName}`, + `Company: **${channel.company}**`, + `Environment: **${channel.environment}**`, + ], + promptContext: { + kind: "raw", + value: prompt, + }, + }); + } else { + yield* startOrContinueT3Turn(config, { + source: { + sourceKind: "teams", + sourceThreadId: rootKey, + }, + externalConversationId: rootKey, + externalParentId: `${channel.teamId}/${channel.channelId}`, + externalTenantId: config.teamsTenantId ?? "", + projectShortName: channel.projectShortName, + prompt, + flags: {}, + ...(imageDownloads.t3Uploads.length > 0 ? { attachments: imageDownloads.t3Uploads } : {}), + promptContext: { + kind: "raw", + value: prompt, + }, + }); + } + processedRootKeys.add(rootKey); + + if (trigger.reason === "mention" || channel.deliveryMode !== "discord") { + yield* postTeamsWebhookAck(channel, trigger.targetMessage); + } + }); + + const listMessagesForChannel = Effect.fn("runTeamsModule.listMessagesForChannel")(function* ( + channel: TeamsChannelConfig, + ) { + const lookbackStartMs = nowLookbackStart().getTime(); + const roots = yield* listPagedMessages( + `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages?$top=50`, + lookbackStartMs, + ); + + const collected: TeamsMessage[] = []; + for (const root of roots) { + collected.push(root); + const replies = yield* listPagedMessages( + `/teams/${encodeURIComponent(channel.teamId)}/channels/${encodeURIComponent(channel.channelId)}/messages/${encodeURIComponent(root.id)}/replies?$top=50`, + lookbackStartMs, + ).pipe(Effect.orElseSucceed(() => [] as TeamsMessage[])); + collected.push(...replies); + } + + return collected.toSorted((left, right) => + teamsMessageTimestamp(left).localeCompare(teamsMessageTimestamp(right)), + ); + }); + + yield* Effect.logInfo("Teams module enabled", { + channels: channels.length, + pollIntervalSeconds: config.teamsPollIntervalSeconds, + }); + + while (true) { + for (const channel of channels) { + yield* Effect.gen(function* () { + const messages = yield* listMessagesForChannel(channel); + const seenIds = new Set(yield* seenStore.listSeenIds(channelKey(channel))); + const processedRootKeys = yield* processedRootKeysForChannel(channel); + for (const message of messages) { + yield* processMessage(channel, message, messages, seenIds, processedRootKeys); + } + }).pipe( + Effect.catch((error) => + Effect.logError("Teams channel poll failed", { + teamId: channel.teamId, + channelId: channel.channelId, + error: String(error), + }), + ), + ); + } + + yield* Effect.sleep(Duration.seconds(config.teamsPollIntervalSeconds)); + } +}); diff --git a/apps/discord-bot/src/features/TeamsNativeApp.test.ts b/apps/discord-bot/src/features/TeamsNativeApp.test.ts new file mode 100644 index 00000000000..8e9bea12f14 --- /dev/null +++ b/apps/discord-bot/src/features/TeamsNativeApp.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { sourceConversationKey, splitTeamsMessage } from "./TeamsNativeApp.ts"; + +describe("sourceConversationKey", () => { + it("keeps native Teams conversations distinct by tenant and location", () => { + expect( + sourceConversationKey({ + tenantId: "tenant", + teamId: "team", + channelId: "channel", + conversationId: "conversation;messageid=root", + }), + ).toBe("native/tenant/team/channel/conversation;messageid=root"); + }); + + it("supports personal chats", () => { + expect( + sourceConversationKey({ + tenantId: "tenant", + teamId: undefined, + channelId: undefined, + conversationId: "personal-chat", + }), + ).toBe("native/tenant/chat/chat/personal-chat"); + }); +}); + +describe("splitTeamsMessage", () => { + it("keeps short answers intact", () => { + expect(splitTeamsMessage("done")).toEqual(["done"]); + }); + + it("splits large answers below the Teams delivery ceiling without losing text", () => { + const input = `${"a".repeat(15_000)}\n\n${"b".repeat(15_000)}`; + const chunks = splitTeamsMessage(input); + expect(chunks).toHaveLength(2); + expect(chunks.every((chunk) => chunk.length <= 25_000)).toBe(true); + expect(chunks.join("\n\n")).toBe(input); + }); +}); diff --git a/apps/discord-bot/src/features/TeamsNativeApp.ts b/apps/discord-bot/src/features/TeamsNativeApp.ts new file mode 100644 index 00000000000..e2bb1fc23bd --- /dev/null +++ b/apps/discord-bot/src/features/TeamsNativeApp.ts @@ -0,0 +1,440 @@ +// @effect-diagnostics anyUnknownInErrorContext:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off globalPromise:off missingEffectContext:off tryCatchInEffectGen:off preferSchemaOverJson:off +import { App } from "@microsoft/teams.apps"; +import { ProviderUserInputAnswers, type ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { DiscordBotConfig } from "../config.ts"; +import { derivePendingInteractions } from "../presentation/pendingInteractions.ts"; +import { ProjectAliasStore } from "../projectAliases.ts"; +import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; +import { T3Session } from "../t3/T3Session.ts"; +import { loadTeamsChannelConfigsFromFileSync, type TeamsChannelConfig } from "../teams/config.ts"; +import { teamsMessageText } from "../teams/presentation.ts"; +import { startOrContinueT3Turn } from "./LinkedTurnRouter.ts"; +import { finalAnswerText } from "./ResponseBridge.ts"; + +interface TeamsConversationCoordinates { + readonly tenantId: string; + readonly teamId: string | undefined; + readonly channelId: string | undefined; + readonly conversationId: string; +} + +function coordinates(activity: { + readonly conversation: { readonly id: string; readonly tenantId?: string | undefined }; + readonly channelData?: { + readonly tenant?: { readonly id?: string | undefined } | undefined; + readonly team?: { readonly id?: string | undefined } | undefined; + readonly channel?: { readonly id?: string | undefined } | undefined; + }; +}): TeamsConversationCoordinates { + return { + tenantId: activity.channelData?.tenant?.id ?? activity.conversation.tenantId ?? "", + teamId: activity.channelData?.team?.id, + channelId: activity.channelData?.channel?.id, + conversationId: activity.conversation.id, + }; +} + +function channelForCoordinates( + channels: ReadonlyArray, + input: TeamsConversationCoordinates, +): TeamsChannelConfig | undefined { + return channels.find( + (channel) => channel.teamId === input.teamId && channel.channelId === input.channelId, + ); +} + +export function sourceConversationKey(input: TeamsConversationCoordinates): string { + return `native/${input.tenantId}/${input.teamId ?? "chat"}/${input.channelId ?? "chat"}/${input.conversationId}`; +} + +function settled(status: string | null | undefined): boolean { + return status !== "starting" && status !== "running"; +} + +export function splitTeamsMessage(text: string): ReadonlyArray { + const normalized = text.trim(); + if (normalized.length <= 25_000) return [normalized]; + const chunks: string[] = []; + let remaining = normalized; + while (remaining.length > 25_000) { + const candidate = remaining.slice(0, 25_000); + const splitAt = Math.max(candidate.lastIndexOf("\n\n"), candidate.lastIndexOf("\n")); + const end = splitAt >= 10_000 ? splitAt : 25_000; + chunks.push(remaining.slice(0, end).trimEnd()); + remaining = remaining.slice(end).trimStart(); + } + if (remaining.length > 0) chunks.push(remaining); + return chunks; +} + +const waitForFinalAnswer = Effect.fn("waitForFinalAnswer")(function* (input: { + readonly t3ThreadId: ThreadId; + readonly baseline: string; + readonly baselineTurnId: string | null; + readonly send: (text: string) => Promise; +}) { + const t3 = yield* T3Session; + const announcedRequests = new Set(); + while (true) { + yield* Effect.sleep("1 second"); + const snapshot = yield* t3.fetchThreadDetail(input.t3ThreadId).pipe( + Effect.catch((error) => + Effect.logWarning("Teams final-answer poll failed", { + threadId: input.t3ThreadId, + error: String(error), + }).pipe(Effect.as(null)), + ), + ); + if (snapshot === null) continue; + for (const interaction of derivePendingInteractions(snapshot.thread.activities)) { + if (announcedRequests.has(interaction.requestId)) continue; + announcedRequests.add(interaction.requestId); + if (interaction.kind === "approval") { + yield* Effect.tryPromise({ + try: () => + input.send( + [ + `T3 needs **${interaction.requestKind}** approval.`, + interaction.detail, + `Reply \`approve ${interaction.requestId}\` or \`deny ${interaction.requestId}\`.`, + ] + .filter((line): line is string => line !== null) + .join("\n\n"), + ), + catch: (cause) => new Error(String(cause)), + }); + } else { + const questions = interaction.questions + .map( + (question) => + `- \`${question.id}\`: ${question.question}${ + question.options.length === 0 + ? "" + : ` (${question.options.map((option) => option.label).join(", ")})` + }`, + ) + .join("\n"); + yield* Effect.tryPromise({ + try: () => + input.send( + [ + "T3 needs more information:", + questions, + `Reply \`answer ${interaction.requestId} {"question-id":"answer"}\`.`, + ].join("\n\n"), + ), + catch: (cause) => new Error(String(cause)), + }); + } + } + const answer = finalAnswerText(snapshot.thread); + const latestTurnId = snapshot.thread.latestTurn?.turnId ?? null; + if ( + answer.length === 0 || + (answer === input.baseline && latestTurnId === input.baselineTurnId) || + !settled(snapshot.thread.session?.status) + ) { + continue; + } + for (const chunk of splitTeamsMessage(answer)) { + yield* Effect.tryPromise({ + try: () => input.send(chunk), + catch: (cause) => new Error(String(cause)), + }); + } + return; + } +}); + +function messageActionCard(defaultProject: string | undefined) { + return { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Start a T3 investigation from this Teams message", + weight: "Bolder", + wrap: true, + }, + { + type: "Input.Text", + id: "projectShortName", + label: "Project alias", + value: defaultProject ?? "", + isRequired: true, + errorMessage: "Enter a project alias configured in T3_PROJECT_ALIASES_PATH.", + }, + { + type: "Input.Text", + id: "instructions", + label: "Additional instructions", + isMultiline: true, + placeholder: "Optional context or desired outcome", + }, + { + type: "ActionSet", + actions: [ + { + type: "Action.Submit", + title: "Start investigation", + data: { action: "startT3Investigation" }, + }, + ], + }, + ], + $schema: "https://adaptivecards.io/schemas/adaptive-card.json", + }, + } as const; +} + +/** + * Native Teams SDK endpoint. This is independent from Discord gateway/services and can be + * deployed with only Teams + T3 credentials. + */ +export const runTeamsNativeApp = Effect.fn("runTeamsNativeApp")(function* ( + config: DiscordBotConfig, +) { + if (!config.teamsNativeEnabled) { + yield* Effect.logInfo("Native Teams app disabled"); + return; + } + if ( + config.teamsClientId === undefined || + config.teamsClientSecret === undefined || + config.teamsTenantId === undefined + ) { + return yield* Effect.fail( + new Error( + "TEAMS_NATIVE_ENABLED requires TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID.", + ), + ); + } + + const channels = + config.teamsChannelsPath === undefined + ? [] + : loadTeamsChannelConfigsFromFileSync(config.teamsChannelsPath); + const t3 = yield* T3Session; + const links = yield* ThreadLinkStore; + const services = yield* Effect.context(); + const run = Effect.runPromiseWith(services); + + const app = new App({ + clientId: config.teamsClientId, + clientSecret: config.teamsClientSecret, + tenantId: config.teamsTenantId, + messagingEndpoint: config.teamsMessagingEndpoint, + activity: { mentions: { stripText: true } }, + }); + + app.on("message", async ({ activity, send, reply }) => { + await run( + Effect.gen(function* () { + const location = coordinates(activity); + const sourceKey = sourceConversationKey(location); + const channel = channelForCoordinates(channels, location); + const projectShortName = channel?.projectShortName ?? config.teamsDefaultProjectShortName; + const prompt = (activity.text ?? "").trim(); + const existing = yield* links.getBySourceThread("teams", sourceKey); + + const stop = /^(?:\/?stop|cancel)$/iu.test(prompt); + if (stop) { + if (existing === null) { + yield* Effect.promise(() => reply("There is no linked T3 thread to stop.")); + return; + } + yield* t3.interrupt(existing.t3ThreadId); + yield* Effect.promise(() => reply("Stopped the active T3 turn.")); + return; + } + + const approval = /^(?:\/?)(approve|deny)\s+(\S+)$/iu.exec(prompt); + if (approval !== null) { + if (existing === null) { + yield* Effect.promise(() => reply("There is no linked T3 thread for that approval.")); + return; + } + yield* t3.respondToApproval( + existing.t3ThreadId, + approval[2]!, + approval[1]!.toLowerCase() === "approve" ? "accept" : "decline", + ); + yield* Effect.promise(() => reply(`Approval ${approval[1]!.toLowerCase()}ed.`)); + return; + } + + const userInput = /^(?:\/?)answer\s+(\S+)\s+(.+)$/isu.exec(prompt); + if (userInput !== null) { + if (existing === null) { + yield* Effect.promise(() => + reply("There is no linked T3 thread for that input request."), + ); + return; + } + const parsed = yield* Effect.try({ + try: () => JSON.parse(userInput[2]!) as unknown, + catch: () => new Error("Answers must be a JSON object."), + }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(ProviderUserInputAnswers))); + yield* t3.respondToUserInput(existing.t3ThreadId, userInput[1]!, parsed); + yield* Effect.promise(() => reply("Submitted the requested input to T3.")); + return; + } + + if (prompt.length === 0) return; + if (projectShortName === undefined) { + yield* Effect.promise(() => + reply( + "No T3 project is mapped to this Teams location. Configure TEAMS_CHANNELS_PATH or TEAMS_DEFAULT_PROJECT_SHORT_NAME.", + ), + ); + return; + } + + const baselineSnapshot = + existing === null + ? null + : yield* t3 + .fetchThreadDetail(existing.t3ThreadId) + .pipe(Effect.orElseSucceed(() => null)); + const baseline = baselineSnapshot === null ? "" : finalAnswerText(baselineSnapshot.thread); + const baselineTurnId = baselineSnapshot?.thread.latestTurn?.turnId ?? null; + const turn = yield* startOrContinueT3Turn(config, { + source: { sourceKind: "teams", sourceThreadId: sourceKey }, + externalConversationId: location.conversationId, + externalParentId: `${location.teamId ?? "chat"}/${location.channelId ?? "chat"}`, + externalTenantId: location.tenantId, + projectShortName, + prompt, + flags: {}, + stickyModelOnContinue: true, + promptContext: { kind: "raw", value: prompt }, + }); + + const webLink = + config.webUiBaseUrl === undefined + ? null + : `${config.webUiBaseUrl.replace(/\/$/u, "")}/?thread=${turn.threadId}`; + yield* Effect.promise(() => + reply( + [ + turn.isNew + ? `Started T3 for **${projectShortName}**.` + : "Continued the linked T3 thread.", + webLink === null ? null : `[Open in T3 Code](${webLink})`, + ] + .filter((line): line is string => line !== null) + .join("\n"), + ), + ); + yield* Effect.forkDetach( + waitForFinalAnswer({ + t3ThreadId: turn.threadId, + baseline, + baselineTurnId, + send: (text) => send(text), + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Native Teams answer delivery failed", { + threadId: turn.threadId, + cause, + }), + ), + ), + ); + }).pipe( + Effect.catch((error) => + Effect.logError("Native Teams message failed", { error: String(error) }).pipe( + Effect.andThen( + Effect.promise(() => + reply( + "T3 could not process this message. Check the bridge logs and configuration.", + ), + ), + ), + ), + ), + ), + ); + }); + + app.on("message.ext.open", async ({ activity }) => { + const location = coordinates(activity); + const channel = channelForCoordinates(channels, location); + return { + task: { + type: "continue", + value: { + title: "Start T3 investigation", + height: "medium", + width: "medium", + card: messageActionCard(channel?.projectShortName ?? config.teamsDefaultProjectShortName), + }, + }, + }; + }); + + app.on("message.ext.submit", async ({ activity }) => { + const data = activity.value.data as + | { readonly projectShortName?: unknown; readonly instructions?: unknown } + | undefined; + const projectShortName = + typeof data?.projectShortName === "string" ? data.projectShortName.trim() : ""; + if (projectShortName.length === 0) { + return { task: { type: "message", value: "A project alias is required." } }; + } + const sourceMessage = teamsMessageText({ + id: activity.value.messagePayload?.id ?? activity.id, + body: activity.value.messagePayload?.body, + from: activity.value.messagePayload?.from, + }); + const instructions = typeof data?.instructions === "string" ? data.instructions.trim() : ""; + const prompt = [ + "Investigate the following Microsoft Teams message.", + sourceMessage, + instructions.length === 0 ? null : `Additional instructions: ${instructions}`, + ] + .filter((line): line is string => line !== null && line.length > 0) + .join("\n\n"); + const location = coordinates(activity); + const messageId = activity.value.messagePayload?.id ?? activity.id; + const sourceKey = `message-action/${location.tenantId}/${messageId}`; + + Effect.runForkWith(services)( + startOrContinueT3Turn(config, { + source: { sourceKind: "teams", sourceThreadId: sourceKey }, + externalConversationId: sourceKey, + externalParentId: `${location.teamId ?? "chat"}/${location.channelId ?? "chat"}`, + externalTenantId: location.tenantId, + projectShortName, + prompt, + flags: {}, + promptContext: { kind: "raw", value: prompt }, + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Teams message action failed", { sourceKey, cause }), + ), + ), + ); + return { + task: { + type: "message", + value: "T3 investigation started. Open T3 Code to follow progress.", + }, + }; + }); + + yield* Effect.tryPromise({ + try: () => app.start(config.teamsPort), + catch: (cause) => new Error(`Could not start native Teams app: ${String(cause)}`), + }); + yield* Effect.logInfo("Native Teams app listening", { + port: config.teamsPort, + endpoint: config.teamsMessagingEndpoint, + }); +}); diff --git a/apps/discord-bot/src/features/ThreadInfoPin.ts b/apps/discord-bot/src/features/ThreadInfoPin.ts new file mode 100644 index 00000000000..c043466b24f --- /dev/null +++ b/apps/discord-bot/src/features/ThreadInfoPin.ts @@ -0,0 +1,779 @@ +// @effect-diagnostics globalFetch:off globalFetchInEffect:off unknownInEffectCatch:off anyUnknownInErrorContext:off outdatedApi:off +import type { ProjectId, ThreadId } from "@t3tools/contracts"; +import { DiscordConfig, DiscordREST } from "dfx"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +import type { DiscordBotConfig } from "../config.ts"; +import { resolveGitHubUrlForWorkspace } from "../presentation/githubLinks.ts"; +import { + extractJiraIssueKeysFromDiscordMessage, + mergeJiraIssueKeys, +} from "../presentation/jiraLinks.ts"; +import { + buildDiscordThreadJumpUrl, + buildT3WebThreadUrl, + ensureDiscordPrAttributionFooters, + formatDiscordPrAttributionFooter, + starterDisplayName, + starterUserId, + type DiscordThreadStarterLike, +} from "../presentation/discordPrAttribution.ts"; +import { + extractPullRequestUrlsFromDiscordMessage, + mergePullRequestUrls, + normalizeGithubRepoSlug, +} from "../presentation/prLinks.ts"; +import { + applyModelHistoryUpdate, + formatModelSelectionLine, + formatThreadInfoModelLine, + isThreadInfoPinContent, + renderThreadInfoPin, + type ThreadInfoPinRenderInput, + type ThreadModelHistory, +} from "../presentation/threadInfoPin.ts"; +import { ThreadLinkStore, type ThreadLink } from "../store/ThreadLinkStore.ts"; +import { T3Session } from "../t3/T3Session.ts"; + +interface DiscordMessageSummary { + readonly id: string; + readonly content?: string | null; + readonly embeds?: ReadonlyArray<{ + readonly url?: string | null; + readonly title?: string | null; + readonly description?: string | null; + readonly footer?: { readonly text?: string | null } | null; + }> | null; + readonly author?: { + readonly id?: string; + readonly bot?: boolean; + readonly username?: string; + readonly global_name?: string | null; + } | null; + readonly timestamp?: string | null; +} + +interface DiscordChannelSummary { + readonly id: string; + readonly name?: string | null; + readonly parent_id?: string | null; + readonly owner_id?: string | null; +} + +export interface ThreadInfoPinMessageRef { + readonly channelId: string; + readonly messageId: string; + readonly jiraIssueKeys: ReadonlyArray; + readonly prUrls: ReadonlyArray; +} + +async function discordApiJson(input: { + readonly baseUrl: string; + readonly botToken: string; + readonly path: string; + readonly method?: string; + readonly body?: unknown; +}): Promise { + const response = await globalThis.fetch(`${input.baseUrl.replace(/\/+$/u, "")}${input.path}`, { + method: input.method ?? "GET", + headers: { + Authorization: `Bot ${input.botToken}`, + "Content-Type": "application/json", + "User-Agent": "DiscordBot (t3-discord-bot, 0.0.0)", + }, + ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Discord API ${input.method ?? "GET"} ${input.path} failed (${response.status}): ${body}`, + ); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} + +async function discordApiVoid(input: { + readonly baseUrl: string; + readonly botToken: string; + readonly path: string; + readonly method: string; +}): Promise { + const response = await globalThis.fetch(`${input.baseUrl.replace(/\/+$/u, "")}${input.path}`, { + method: input.method, + headers: { + Authorization: `Bot ${input.botToken}`, + "User-Agent": "DiscordBot (t3-discord-bot, 0.0.0)", + }, + }); + if (!response.ok && response.status !== 204) { + const body = await response.text().catch(() => ""); + throw new Error( + `Discord API ${input.method} ${input.path} failed (${response.status}): ${body}`, + ); + } +} + +function t3WebThreadUrl(webUiBaseUrl: string | undefined, threadId: string): string | null { + if (webUiBaseUrl === undefined) return null; + return `${webUiBaseUrl.replace(/\/$/u, "")}/?thread=${threadId}`; +} + +export function modelHistoryFromLink( + link: + | Pick + | null + | undefined, +): ThreadModelHistory { + return { + initialModelLine: link?.initialModelLine ?? null, + currentModelLine: link?.currentModelLine ?? null, + modelSinceAt: link?.modelSinceAt ?? null, + }; +} + +export function buildThreadInfoRenderInput(input: { + readonly modelSelection?: + | { readonly instanceId: string; readonly model: string } + | null + | undefined; + /** When set, preferred over a bare modelSelection line (includes since/started-with). */ + readonly modelHistory?: ThreadModelHistory | null | undefined; + readonly worktreePath?: string | null | undefined; + readonly baseBranchLabel?: string | null | undefined; + readonly local?: boolean | undefined; + readonly webLink?: string | null | undefined; + readonly extraLines?: ReadonlyArray | undefined; + readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly jiraBrowseBaseUrl?: string | undefined; + readonly prUrls?: ReadonlyArray | undefined; + readonly channelGithubRepoSlug?: string | null | undefined; + readonly titleLine?: string | null | undefined; +}): ThreadInfoPinRenderInput { + const modelLineFromHistory = + input.modelHistory === null || input.modelHistory === undefined + ? null + : formatThreadInfoModelLine(input.modelHistory); + const modelLine = + modelLineFromHistory ?? + (input.modelSelection === null || input.modelSelection === undefined + ? null + : formatModelSelectionLine(input.modelSelection)); + + let worktreeLine: string | null = null; + if (input.local === true) { + worktreeLine = "Mode: local (no worktree)"; + } else if (input.worktreePath !== null && input.worktreePath !== undefined) { + worktreeLine = `Worktree: \`${input.worktreePath}\``; + } else if (input.baseBranchLabel !== null && input.baseBranchLabel !== undefined) { + worktreeLine = `Worktree off \`${input.baseBranchLabel}\``; + } + + return { + modelLine, + worktreeLine, + webLink: input.webLink ?? null, + extraLines: [...(input.titleLine ? [input.titleLine] : []), ...(input.extraLines ?? [])], + jiraIssueKeys: input.jiraIssueKeys ?? [], + jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, + prUrls: input.prUrls ?? [], + channelGithubRepoSlug: input.channelGithubRepoSlug ?? null, + }; +} + +/** + * Resolve the channel/project GitHub `owner/repo` for PR label disambiguation. + * Prefers the thread worktree, then the linked project workspace root. + */ +const resolveChannelGithubRepoSlug = (input: { + readonly worktreePath?: string | null | undefined; + readonly projectId?: ProjectId | string | null | undefined; +}) => + Effect.gen(function* () { + const t3 = yield* T3Session; + let cwd = + input.worktreePath !== null && + input.worktreePath !== undefined && + input.worktreePath.trim() !== "" + ? input.worktreePath + : null; + + if (cwd === null && input.projectId !== null && input.projectId !== undefined) { + const project = yield* t3.getProjectShell(input.projectId as ProjectId); + const root = project?.workspaceRoot?.trim() ?? ""; + cwd = root.length > 0 ? root : null; + } + + if (cwd === null) return null; + + const githubUrl = yield* Effect.tryPromise({ + try: () => resolveGitHubUrlForWorkspace(cwd), + catch: () => null as string | null, + }).pipe(Effect.orElseSucceed(() => null as string | null)); + + return normalizeGithubRepoSlug(githubUrl); + }); + +/** + * Load Discord thread starter (public-thread parent message or oldest thread message) + * and the current thread title for hardcoded PR attribution footers. + */ +const loadDiscordThreadAttributionContext = (input: { + readonly discordThreadId: string; + readonly parentChannelId: string | null; + readonly guildId: string; + readonly baseUrl: string; + readonly botToken: string; +}) => + Effect.gen(function* () { + const channel = yield* Effect.tryPromise({ + try: () => + discordApiJson({ + baseUrl: input.baseUrl, + botToken: input.botToken, + path: `/channels/${input.discordThreadId}`, + }), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed((): DiscordChannelSummary | null => null)); + + const parentChannelId = + input.parentChannelId ?? + (channel?.parent_id !== null && channel?.parent_id !== undefined && channel.parent_id !== "" + ? channel.parent_id + : null); + + let starter: DiscordThreadStarterLike | null = null; + if (parentChannelId !== null) { + // Public threads created from a message use the starter message id as the thread id. + starter = yield* Effect.tryPromise({ + try: () => + discordApiJson({ + baseUrl: input.baseUrl, + botToken: input.botToken, + path: `/channels/${parentChannelId}/messages/${input.discordThreadId}`, + }), + catch: (cause) => cause, + }).pipe( + Effect.map( + (message): DiscordThreadStarterLike => ({ + id: message.id, + author: { + id: message.author?.id, + username: message.author?.username, + displayName: message.author?.global_name ?? message.author?.username, + }, + }), + ), + Effect.orElseSucceed((): DiscordThreadStarterLike | null => null), + ); + } + + if (starter === null) { + const listed = yield* Effect.tryPromise({ + try: () => + discordApiJson>({ + baseUrl: input.baseUrl, + botToken: input.botToken, + path: `/channels/${input.discordThreadId}/messages?limit=5&after=0`, + }), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const oldest = listed.at(-1) ?? listed[0]; + if (oldest !== undefined) { + starter = { + id: oldest.id, + author: { + id: oldest.author?.id, + username: oldest.author?.username, + displayName: oldest.author?.global_name ?? oldest.author?.username, + }, + }; + } + } + + const threadTitle = + channel?.name !== null && channel?.name !== undefined && channel.name.trim() !== "" + ? channel.name.trim() + : "Discord thread"; + + const userId = starterUserId(starter); + if (userId === null) return null; + + return { + footer: formatDiscordPrAttributionFooter({ + starterDisplayName: starterDisplayName(starter), + starterUserId: userId, + threadTitle, + threadJumpUrl: buildDiscordThreadJumpUrl({ + guildId: input.guildId, + discordThreadId: input.discordThreadId, + messageId: starter?.id ?? null, + }), + }), + threadTitle, + starterUserId: userId, + } as const; + }); + +/** + * When GitHub PR URLs are observed on a Discord-linked thread, hard-append the + * Discord attribution footer using the **thread starter** + thread title. + * Idempotent (skips bodies that already have the footer). Best-effort only. + */ +const ensureAttributionFootersForIncomingPrs = (input: { + readonly discordThreadId: string; + readonly link: ThreadLink | null; + readonly incomingPrUrls: ReadonlyArray; + readonly webUiBaseUrl?: string | undefined; +}) => + Effect.gen(function* () { + const prUrls = mergePullRequestUrls([], input.incomingPrUrls); + if (prUrls.length === 0) return; + + const links = yield* ThreadLinkStore; + const link = input.link ?? (yield* links.getByDiscordThreadId(input.discordThreadId)); + if (link === null) { + yield* Effect.logWarning("Skipping Discord PR attribution footer: no thread link", { + discordThreadId: input.discordThreadId, + prCount: prUrls.length, + }); + return; + } + + const discordConfig = yield* DiscordConfig.DiscordConfig; + const botToken = Redacted.value(discordConfig.token); + const baseUrl = discordConfig.rest.baseUrl; + + const attribution = yield* loadDiscordThreadAttributionContext({ + discordThreadId: input.discordThreadId, + parentChannelId: link.channelId, + guildId: link.guildId, + baseUrl, + botToken, + }); + if (attribution === null) { + yield* Effect.logWarning("Skipping Discord PR attribution footer: no thread starter", { + discordThreadId: input.discordThreadId, + prCount: prUrls.length, + }); + return; + } + + const t3FullThreadUrl = buildT3WebThreadUrl(input.webUiBaseUrl, link.t3ThreadId); + + const results = yield* Effect.tryPromise({ + try: () => + ensureDiscordPrAttributionFooters({ + prUrls, + footer: attribution.footer, + t3FullThreadUrl, + }), + catch: (cause) => cause, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Discord PR attribution footer ensure failed", { + discordThreadId: input.discordThreadId, + error: String(error), + }).pipe(Effect.as([] as const)), + ), + ); + + for (const result of results) { + if (result.status === "updated") { + yield* Effect.logInfo("Appended Discord PR attribution footer", { + discordThreadId: input.discordThreadId, + prUrl: result.url, + threadTitle: attribution.threadTitle, + starterUserId: attribution.starterUserId, + }); + } else if (result.status === "error") { + yield* Effect.logWarning("Failed to append Discord PR attribution footer", { + discordThreadId: input.discordThreadId, + prUrl: result.url, + detail: result.detail ?? null, + }); + } + } + }); + +/** + * Create or update the pinned thread-info message and ensure it stays pinned. + */ +export const ensureThreadInfoPin = (input: { + readonly channelId: string; + readonly content: string; + readonly existingMessageId?: string | null; +}) => + Effect.gen(function* () { + const discordConfig = yield* DiscordConfig.DiscordConfig; + const botToken = Redacted.value(discordConfig.token); + const baseUrl = discordConfig.rest.baseUrl; + + const pinned = yield* Effect.tryPromise({ + try: () => + discordApiJson>({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins`, + }), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const infoPins = pinned.filter((message) => isThreadInfoPinContent(message.content)); + const existingFromPins = infoPins[0] ?? null; + const stale = infoPins.slice(1); + + let messageId = + input.existingMessageId && input.existingMessageId.trim() !== "" + ? input.existingMessageId + : (existingFromPins?.id ?? null); + + // Prefer the stored id when still present among pins or when it still exists. + if (messageId !== null) { + const patchOk = yield* Effect.tryPromise({ + try: () => + discordApiJson({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/messages/${messageId}`, + method: "PATCH", + body: { content: input.content }, + }), + catch: (cause) => cause, + }).pipe( + Effect.as(true as const), + Effect.orElseSucceed(() => false as const), + ); + + if (!patchOk) { + messageId = existingFromPins?.id ?? null; + if (messageId !== null) { + yield* Effect.tryPromise({ + try: () => + discordApiJson({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/messages/${messageId}`, + method: "PATCH", + body: { content: input.content }, + }), + catch: (cause) => cause, + }); + } + } + } + + if (messageId === null) { + const created = yield* Effect.tryPromise({ + try: () => + discordApiJson<{ readonly id: string }>({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/messages`, + method: "POST", + body: { content: input.content }, + }), + catch: (cause) => cause, + }); + messageId = created.id; + } + + // Ensure pin (idempotent PUT). + yield* Effect.tryPromise({ + try: () => + discordApiVoid({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins/${messageId}`, + method: "PUT", + }), + catch: (cause) => cause, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to pin thread info message", { + channelId: input.channelId, + messageId, + error: String(error), + }), + ), + ); + + for (const message of stale) { + if (message.id === messageId) continue; + yield* Effect.tryPromise({ + try: () => + discordApiVoid({ + baseUrl, + botToken, + path: `/channels/${input.channelId}/pins/${message.id}`, + method: "DELETE", + }), + catch: (cause) => cause, + }).pipe(Effect.catch(() => Effect.void)); + } + + return { + channelId: input.channelId, + messageId, + } as const; + }); + +/** + * Persist any newly observed Jira keys / PR URLs, then create/update + pin the thread-info message. + */ +export const upsertThreadInfoPin = (input: { + readonly discordThreadId: string; + readonly t3ThreadId: string; + readonly botConfig: DiscordBotConfig; + readonly incomingJiraKeys?: ReadonlyArray; + readonly incomingPrUrls?: ReadonlyArray; + readonly modelSelection?: { readonly instanceId: string; readonly model: string } | null; + readonly worktreePath?: string | null; + readonly baseBranchLabel?: string | null; + readonly local?: boolean; + /** Skip GitHub repo slug enrichment on latency-sensitive bootstrap paths. */ + readonly skipChannelRepoLookup?: boolean; + readonly extraLines?: ReadonlyArray; + readonly titleLine?: string | null; +}) => + Effect.gen(function* () { + const links = yield* ThreadLinkStore; + const existing = yield* links.getByDiscordThreadId(input.discordThreadId); + + let jiraIssueKeys = mergeJiraIssueKeys(existing?.jiraIssueKeys, input.incomingJiraKeys ?? []); + if ((input.incomingJiraKeys?.length ?? 0) > 0) { + const updated = yield* links.appendJiraIssueKeys( + input.discordThreadId, + input.incomingJiraKeys ?? [], + ); + if (updated !== null) { + jiraIssueKeys = updated.jiraIssueKeys ?? jiraIssueKeys; + } + } + + let prUrls = mergePullRequestUrls(existing?.prUrls, input.incomingPrUrls ?? []); + if ((input.incomingPrUrls?.length ?? 0) > 0) { + const updated = yield* links.appendPrUrls(input.discordThreadId, input.incomingPrUrls ?? []); + if (updated !== null) { + prUrls = updated.prUrls ?? prUrls; + } + } + + // Hardcode Discord PR attribution (thread starter + title) — no agent prompt. + // Only runs for *incoming* PR URLs this call (pin refresh with empty incoming is a no-op). + // ensureDiscordPrAttributionFooters is idempotent if the footer is already present. + // Also appends T3 thread link: full host for private GH repos, short t3vm host for public. + yield* ensureAttributionFootersForIncomingPrs({ + discordThreadId: input.discordThreadId, + link: existing, + incomingPrUrls: input.incomingPrUrls ?? [], + webUiBaseUrl: input.botConfig.webUiBaseUrl, + }).pipe( + Effect.catch((error) => + Effect.logWarning("Discord PR attribution side-effect failed", { + discordThreadId: input.discordThreadId, + error: String(error), + }), + ), + ); + + const nextModelLine = + input.modelSelection === null || input.modelSelection === undefined + ? null + : formatModelSelectionLine(input.modelSelection); + const modelHistory = applyModelHistoryUpdate(modelHistoryFromLink(existing), nextModelLine); + if ( + modelHistory.initialModelLine !== (existing?.initialModelLine ?? null) || + modelHistory.currentModelLine !== (existing?.currentModelLine ?? null) || + modelHistory.modelSinceAt !== (existing?.modelSinceAt ?? null) + ) { + yield* links.setModelHistory(input.discordThreadId, modelHistory); + } + + const channelGithubRepoSlug = + input.skipChannelRepoLookup === true + ? null + : yield* resolveChannelGithubRepoSlug({ + worktreePath: input.worktreePath, + projectId: existing?.projectId, + }); + + const content = renderThreadInfoPin( + buildThreadInfoRenderInput({ + modelSelection: input.modelSelection, + modelHistory, + worktreePath: input.worktreePath, + baseBranchLabel: input.baseBranchLabel, + local: input.local, + webLink: t3WebThreadUrl(input.botConfig.webUiBaseUrl, input.t3ThreadId), + extraLines: input.extraLines, + titleLine: input.titleLine, + jiraIssueKeys, + jiraBrowseBaseUrl: input.botConfig.jiraBrowseBaseUrl, + prUrls, + channelGithubRepoSlug, + }), + ); + + // After setModelHistory the stored info message id is still on `existing`. + const latest = yield* links.getByDiscordThreadId(input.discordThreadId); + const pin = yield* ensureThreadInfoPin({ + channelId: input.discordThreadId, + content, + existingMessageId: latest?.infoDiscordMessageId ?? existing?.infoDiscordMessageId ?? null, + }); + + if ((latest?.infoDiscordMessageId ?? existing?.infoDiscordMessageId) !== pin.messageId) { + yield* links.setInfoDiscordMessageId(input.discordThreadId, pin.messageId); + } + + return { + channelId: pin.channelId, + messageId: pin.messageId, + jiraIssueKeys, + prUrls, + } satisfies ThreadInfoPinMessageRef; + }); + +const BACKFILL_MESSAGE_PAGES = 5; +const BACKFILL_PAGE_SIZE = 100; +const BACKFILL_CONCURRENCY = 2; + +/** + * On bot start: scan linked Discord threads for Jira keys and PR URLs + * (chronological first-seen), rewrite the thread-info message, and ensure it is pinned. + */ +export const backfillThreadInfoPins = (botConfig: DiscordBotConfig) => + Effect.gen(function* () { + const links = yield* ThreadLinkStore; + + const all = yield* links.list(); + const active = all + .filter((link) => link.status === "active") + .toSorted((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt)); + + yield* Effect.logInfo("Thread info pin backfill starting", { + activeLinks: active.length, + jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl ?? "(unset)", + }); + + let updated = 0; + let failed = 0; + let skipped = 0; + + yield* Effect.forEach( + active, + (link) => + Effect.gen(function* () { + const result = yield* backfillOneThreadInfoPin(link, botConfig).pipe(Effect.result); + + if (result._tag === "Failure") { + failed += 1; + yield* Effect.logWarning("Thread info pin backfill failed", { + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + error: String(result.failure), + }); + return; + } + if (result.success === "skipped") { + skipped += 1; + return; + } + updated += 1; + }), + { concurrency: BACKFILL_CONCURRENCY }, + ); + + yield* Effect.logInfo("Thread info pin backfill finished", { + considered: active.length, + updated, + skipped, + failed, + }); + }); + +const backfillOneThreadInfoPin = (link: ThreadLink, botConfig: DiscordBotConfig) => + Effect.gen(function* () { + const rest = yield* DiscordREST; + const t3 = yield* T3Session; + const links = yield* ThreadLinkStore; + + const channelOk = yield* rest.getChannel(link.discordThreadId).pipe( + Effect.as(true as const), + Effect.orElseSucceed(() => false as const), + ); + if (!channelOk) return "skipped" as const; + + const history = yield* fetchChannelMessagesOldestFirst(link.discordThreadId); + + const keysFromHistory: string[] = []; + const prUrlsFromHistory: string[] = []; + let discoveredInfoMessageId: string | null = link.infoDiscordMessageId ?? null; + + for (const message of history) { + const keys = extractJiraIssueKeysFromDiscordMessage(message); + for (const key of keys) keysFromHistory.push(key); + const prUrls = extractPullRequestUrlsFromDiscordMessage(message); + for (const url of prUrls) prUrlsFromHistory.push(url); + if (discoveredInfoMessageId === null && isThreadInfoPinContent(message.content)) { + discoveredInfoMessageId = message.id; + } + } + + const mergedKeys = mergeJiraIssueKeys(link.jiraIssueKeys, keysFromHistory); + yield* links.setJiraIssueKeys(link.discordThreadId, mergedKeys); + const mergedPrUrls = mergePullRequestUrls(link.prUrls, prUrlsFromHistory); + yield* links.setPrUrls(link.discordThreadId, mergedPrUrls); + if (discoveredInfoMessageId !== null && discoveredInfoMessageId !== link.infoDiscordMessageId) { + yield* links.setInfoDiscordMessageId(link.discordThreadId, discoveredInfoMessageId); + } + + const shell = yield* t3.getThreadShell(link.t3ThreadId as ThreadId); + const modelSelection = shell?.modelSelection ?? null; + const worktreePath = shell?.worktreePath ?? null; + + yield* upsertThreadInfoPin({ + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + botConfig, + incomingJiraKeys: [], + incomingPrUrls: [], + modelSelection, + worktreePath, + local: worktreePath === null, + // Keys/URLs already persisted via setJiraIssueKeys/setPrUrls; upsert merges from store. + }); + + return "updated" as const; + }); + +const fetchChannelMessagesOldestFirst = (channelId: string) => + Effect.gen(function* () { + const rest = yield* DiscordREST; + const newestFirst: DiscordMessageSummary[] = []; + let before: string | undefined; + + for (let page = 0; page < BACKFILL_MESSAGE_PAGES; page += 1) { + const batch = (yield* rest + .listMessages(channelId, { + limit: BACKFILL_PAGE_SIZE, + ...(before === undefined ? {} : { before }), + }) + .pipe( + Effect.orElseSucceed((): ReadonlyArray => []), + )) as ReadonlyArray; + + if (batch.length === 0) break; + newestFirst.push(...batch); + before = batch[batch.length - 1]?.id; + if (batch.length < BACKFILL_PAGE_SIZE) break; + } + + // Discord returns newest-first; reverse for chronological first-seen key order. + return newestFirst.toReversed(); + }); diff --git a/apps/discord-bot/src/features/ThreadRestore.test.ts b/apps/discord-bot/src/features/ThreadRestore.test.ts new file mode 100644 index 00000000000..33955ff0cbd --- /dev/null +++ b/apps/discord-bot/src/features/ThreadRestore.test.ts @@ -0,0 +1,280 @@ +// @effect-diagnostics globalDate:off +import type { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { ThreadLink } from "../store/ThreadLinkStore.ts"; +import { MAX_ACTIVE_BRIDGES, pickEvictionVictim, type ActiveBridge } from "./BridgeHub.ts"; +import { + rankAndCapRestoreCandidates, + shellRestoreDecision, + type ShellRestoreDecision, +} from "./ThreadRestore.ts"; + +function link(partial: Partial & Pick): ThreadLink { + return { + discordThreadId: partial.discordThreadId, + t3ThreadId: (partial.t3ThreadId ?? `t3-${partial.discordThreadId}`) as ThreadId, + projectId: (partial.projectId ?? "proj") as ProjectId, + channelId: partial.channelId ?? "chan", + guildId: partial.guildId ?? "guild", + createdAt: partial.createdAt ?? "2026-01-01T00:00:00.000Z", + updatedAt: partial.updatedAt ?? partial.createdAt ?? "2026-01-01T00:00:00.000Z", + lastActivityAt: partial.lastActivityAt ?? "2026-01-01T00:00:00.000Z", + status: partial.status ?? "active", + lastSeenTurnId: partial.lastSeenTurnId ?? null, + lastFinalizedAssistantId: partial.lastFinalizedAssistantId ?? null, + lastThreadSnapshotSequence: partial.lastThreadSnapshotSequence ?? null, + lastDeliveredSequence: partial.lastDeliveredSequence ?? null, + streamDiscordMessageIds: partial.streamDiscordMessageIds, + }; +} + +function shell(overrides: { + latestTurnState?: "running" | "completed" | "interrupted" | null; + sessionStatus?: "running" | "starting" | "idle" | "error" | "interrupted" | null; + hasPendingApprovals?: boolean; + hasPendingUserInput?: boolean; +}): Parameters[0] { + return { + id: "t1" as ThreadId, + projectId: "p1" as ProjectId, + title: "x", + modelSelection: { instanceId: "codex" as never, model: "gpt" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + overrides.latestTurnState === null || overrides.latestTurnState === undefined + ? null + : ({ + turnId: "turn-1", + state: overrides.latestTurnState, + requestedAt: "2026-01-01T00:00:00.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + } as never), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: + overrides.sessionStatus === null || overrides.sessionStatus === undefined + ? null + : ({ + threadId: "t1", + status: overrides.sessionStatus, + providerName: null, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + } as never), + latestUserMessageAt: null, + hasPendingApprovals: overrides.hasPendingApprovals ?? false, + hasPendingUserInput: overrides.hasPendingUserInput ?? false, + hasActionableProposedPlan: false, + } as unknown as Parameters[0]; +} + +describe("shellRestoreDecision", () => { + it("marks missing shell as missing", () => { + expect(shellRestoreDecision(null)).toEqual({ kind: "missing" } satisfies ShellRestoreDecision); + }); + + it("restores running turns", () => { + const decision = shellRestoreDecision(shell({ latestTurnState: "running" })); + expect(decision.kind).toBe("restore"); + if (decision.kind === "restore") { + expect(decision.reasons).toContain("running"); + } + }); + + it("restores session starting/running", () => { + expect(shellRestoreDecision(shell({ sessionStatus: "starting" })).kind).toBe("restore"); + expect(shellRestoreDecision(shell({ sessionStatus: "running" })).kind).toBe("restore"); + }); + + it("restores interrupted sessions only when a turn still looks unfinished", () => { + const decision = shellRestoreDecision( + shell({ sessionStatus: "interrupted", latestTurnState: "running" }), + ); + expect(decision.kind).toBe("restore"); + if (decision.kind === "restore") { + expect(decision.reasons).toContain("session-wake-required"); + } + }); + + it("does not restore zombie interrupted sessions with a completed turn", () => { + expect( + shellRestoreDecision(shell({ sessionStatus: "interrupted", latestTurnState: "completed" })) + .kind, + ).toBe("idle"); + }); + + it("restores pending approvals / user input", () => { + expect(shellRestoreDecision(shell({ hasPendingApprovals: true })).kind).toBe("restore"); + expect(shellRestoreDecision(shell({ hasPendingUserInput: true })).kind).toBe("restore"); + }); + + it("restores when open stream ids need catch-up finalize", () => { + const decision = shellRestoreDecision(shell({ latestTurnState: "completed" }), { + hasOpenStreamIds: true, + }); + expect(decision.kind).toBe("restore"); + if (decision.kind === "restore") { + expect(decision.reasons).toContain("open-stream-ids"); + } + }); + + it("restores when dual-cursor delivery lags orchestration", () => { + const decision = shellRestoreDecision(shell({ latestTurnState: "completed" }), { + deliveryBehind: true, + }); + expect(decision.kind).toBe("restore"); + if (decision.kind === "restore") { + expect(decision.reasons).toContain("delivery-behind"); + } + }); + + it("skips idle completed threads without open stream ids", () => { + expect( + shellRestoreDecision(shell({ latestTurnState: "completed" }), { hasOpenStreamIds: false }) + .kind, + ).toBe("idle"); + }); +}); + +describe("rankAndCapRestoreCandidates", () => { + it("keeps urgent candidates ahead of newer idle links", () => { + const candidates = [ + { + link: link({ discordThreadId: "idle-new", lastActivityAt: "2026-06-01T00:00:00.000Z" }), + urgent: false, + }, + { + link: link({ discordThreadId: "urgent-old", lastActivityAt: "2026-01-01T00:00:00.000Z" }), + urgent: true, + }, + { + link: link({ discordThreadId: "urgent-new", lastActivityAt: "2026-03-01T00:00:00.000Z" }), + urgent: true, + }, + { + link: link({ + discordThreadId: "tomb", + lastActivityAt: "2026-07-01T00:00:00.000Z", + status: "tombstone", + }), + urgent: true, + }, + ]; + const { selected, dropped } = rankAndCapRestoreCandidates(candidates, 2); + expect(selected.map((entry) => entry.link.discordThreadId)).toEqual([ + "urgent-new", + "urgent-old", + ]); + expect(dropped).toBe(1); + }); + + it("defaults cap to MAX_ACTIVE_BRIDGES", () => { + expect(MAX_ACTIVE_BRIDGES).toBe(50); + const many = Array.from({ length: 60 }, (_, index) => ({ + link: link({ + discordThreadId: `d-${index}`, + lastActivityAt: new Date(Date.UTC(2026, 0, 1 + index)).toISOString(), + }), + urgent: index < 5, + })); + const { selected, dropped } = rankAndCapRestoreCandidates(many); + expect(selected).toHaveLength(50); + expect(selected.slice(0, 5).every((entry) => entry.urgent)).toBe(true); + expect(dropped).toBe(10); + }); + + it("orders idle candidates by lastActivityAt once urgent slots are satisfied", () => { + const candidates = [ + { + link: link({ discordThreadId: "idle-old", lastActivityAt: "2026-01-01T00:00:00.000Z" }), + urgent: false, + }, + { + link: link({ discordThreadId: "idle-new", lastActivityAt: "2026-06-01T00:00:00.000Z" }), + urgent: false, + }, + { + link: link({ discordThreadId: "urgent", lastActivityAt: "2026-02-01T00:00:00.000Z" }), + urgent: true, + }, + ]; + const { selected } = rankAndCapRestoreCandidates(candidates, 3); + expect(selected.map((entry) => entry.link.discordThreadId)).toEqual([ + "urgent", + "idle-new", + "idle-old", + ]); + }); +}); + +describe("pickEvictionVictim", () => { + const entry = ( + partial: Partial & Pick, + ): ActiveBridge => ({ + discordChannelId: partial.discordChannelId, + t3ThreadId: partial.t3ThreadId ?? "t3", + lastActivityAt: partial.lastActivityAt ?? "2026-01-01T00:00:00.000Z", + preferred: partial.preferred ?? false, + mode: partial.mode ?? "rehydrate", + }); + + it("prefers oldest non-preferred bridge", () => { + const victim = pickEvictionVictim( + [ + entry({ + discordChannelId: "pref-old", + preferred: true, + lastActivityAt: "2026-01-01T00:00:00.000Z", + }), + entry({ + discordChannelId: "idle-new", + preferred: false, + lastActivityAt: "2026-06-01T00:00:00.000Z", + }), + entry({ + discordChannelId: "idle-old", + preferred: false, + lastActivityAt: "2026-02-01T00:00:00.000Z", + }), + ], + "incoming", + ); + expect(victim?.discordChannelId).toBe("idle-old"); + }); + + it("falls back to oldest preferred when all are preferred", () => { + const victim = pickEvictionVictim( + [ + entry({ + discordChannelId: "a", + preferred: true, + lastActivityAt: "2026-03-01T00:00:00.000Z", + }), + entry({ + discordChannelId: "b", + preferred: true, + lastActivityAt: "2026-01-01T00:00:00.000Z", + }), + ], + "incoming", + ); + expect(victim?.discordChannelId).toBe("b"); + }); + + it("never picks the except channel", () => { + const victim = pickEvictionVictim( + [entry({ discordChannelId: "only", preferred: false })], + "only", + ); + expect(victim).toBeNull(); + }); +}); diff --git a/apps/discord-bot/src/features/ThreadRestore.ts b/apps/discord-bot/src/features/ThreadRestore.ts new file mode 100644 index 00000000000..0c08db60bff --- /dev/null +++ b/apps/discord-bot/src/features/ThreadRestore.ts @@ -0,0 +1,274 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off missingEffectError:off +/** + * Boot / T3-reconnect rehydrate of Discord↔T3 bridges. + * + * Restore set (design decision 1 + catch-up): + * - shell thread running / starting / pending approval / pending user-input + * - OR session interrupted (Wake Required — convert Working tips + ❗ title) + * - OR durable `streamDiscordMessageIds` non-empty (need finalize/cleanup after offline completion) + * - OR dual-cursor lag (`lastDeliveredSequence` behind `lastThreadSnapshotSequence`) + * + * Cap 50 by lastActivityAt desc. Concurrent ensure 4. + */ +import type { OrchestrationThreadShell, ThreadId } from "@t3tools/contracts"; +import { sessionNeedsWakeUp } from "@t3tools/shared/sessionWake"; +import { DiscordREST } from "dfx"; +import * as Effect from "effect/Effect"; + +import { ThreadLinkStore, type ThreadLink } from "../store/ThreadLinkStore.ts"; +import { T3Session } from "../t3/T3Session.ts"; +import { formatAlertCause, postFatalAlert } from "./Alerts.ts"; +import { BridgeHub, MAX_ACTIVE_BRIDGES } from "./BridgeHub.ts"; +import { isDeliveryBehindOrchestration } from "./ResponseBridge.ts"; + +export type RestoreCandidateReason = + | "running" + | "session-active" + | "session-wake-required" + | "pending-approval" + | "pending-user-input" + | "open-stream-ids" + | "delivery-behind" + | "idle-linked"; + +export type ShellRestoreDecision = + | { readonly kind: "missing" } + | { readonly kind: "idle" } + | { readonly kind: "restore"; readonly reasons: ReadonlyArray }; + +/** + * Pure shell-based restore decision (no Discord I/O). + * `hasOpenStreamIds` covers offline-completed turns that still need Discord finalize. + * `deliveryBehind` covers dual-cursor lag (orchestration advanced, Discord never applied). + */ +export function shellRestoreDecision( + shell: OrchestrationThreadShell | null | undefined, + options?: { + readonly hasOpenStreamIds?: boolean; + readonly deliveryBehind?: boolean; + }, +): ShellRestoreDecision { + if (shell === null || shell === undefined) { + // Thread gone from shell — cannot resume or finalize cleanly. + return { kind: "missing" }; + } + + const reasons: RestoreCandidateReason[] = []; + if (shell.latestTurn?.state === "running") reasons.push("running"); + if (shell.session?.status === "running" || shell.session?.status === "starting") { + reasons.push("session-active"); + } + // Only real mid-turn interrupts (not zombie interrupted + completed turn). + if ( + sessionNeedsWakeUp({ + sessionStatus: shell.session?.status ?? null, + activeTurnId: shell.session?.activeTurnId ?? null, + latestTurnState: shell.latestTurn?.state ?? null, + latestTurnCompletedAt: shell.latestTurn?.completedAt ?? null, + }) + ) { + reasons.push("session-wake-required"); + } + if (shell.hasPendingApprovals) reasons.push("pending-approval"); + if (shell.hasPendingUserInput) reasons.push("pending-user-input"); + if (options?.hasOpenStreamIds === true) reasons.push("open-stream-ids"); + if (options?.deliveryBehind === true) reasons.push("delivery-behind"); + + if (reasons.length === 0) return { kind: "idle" }; + return { kind: "restore", reasons }; +} + +/** Sort active links by freshest activity, cap at max. */ +export function rankAndCapRestoreCandidates( + candidates: ReadonlyArray<{ + readonly link: ThreadLink; + readonly urgent: boolean; + }>, + max: number = MAX_ACTIVE_BRIDGES, +): { + readonly selected: ReadonlyArray<{ + readonly link: ThreadLink; + readonly urgent: boolean; + }>; + readonly dropped: number; +} { + const active = candidates.filter((candidate) => candidate.link.status === "active"); + const sorted = [...active].sort((a, b) => { + if (a.urgent !== b.urgent) { + return a.urgent ? -1 : 1; + } + return b.link.lastActivityAt.localeCompare(a.link.lastActivityAt); + }); + const selected = sorted.slice(0, Math.max(0, max)); + return { selected, dropped: Math.max(0, sorted.length - selected.length) }; +} + +export type RehydrateStats = { + readonly considered: number; + readonly selected: number; + readonly restored: number; + readonly failed: number; + readonly tombstoned: number; + readonly idleLinked: number; + readonly cappedOut: number; +}; + +/** + * Select restore candidates from durable links + live T3 shell + Discord channel existence. + */ +export const selectRestoreCandidates = Effect.gen(function* () { + const links = yield* ThreadLinkStore; + const t3 = yield* T3Session; + const rest = yield* DiscordREST; + + const all = yield* links.list(); + const activeLinks = all.filter((link) => link.status === "active"); + + const candidates: Array<{ readonly link: ThreadLink; readonly urgent: boolean }> = []; + let tombstoned = 0; + let idleLinked = 0; + + for (const link of activeLinks) { + const shell = yield* t3.getThreadShell(link.t3ThreadId as ThreadId); + const hasOpenStreamIds = (link.streamDiscordMessageIds?.length ?? 0) > 0; + const deliveryBehind = isDeliveryBehindOrchestration({ + lastDeliveredSequence: link.lastDeliveredSequence, + lastThreadSnapshotSequence: link.lastThreadSnapshotSequence, + }); + const decision = shellRestoreDecision(shell, { hasOpenStreamIds, deliveryBehind }); + + if (decision.kind === "missing") { + yield* links.tombstone(link.discordThreadId); + tombstoned += 1; + yield* Effect.logWarning("Rehydrate tombstoned link (T3 thread missing)", { + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + }); + continue; + } + + // Discord channel must still exist. + const channelOk = yield* rest.getChannel(link.discordThreadId).pipe( + Effect.as(true as const), + Effect.catch((error) => { + const message = String(error); + // dfx / Discord 404 → gone + const missing = + message.includes("10003") || + message.includes("Unknown Channel") || + message.includes("404"); + return Effect.succeed(missing ? (false as const) : (true as const)); + }), + ); + if (!channelOk) { + yield* links.tombstone(link.discordThreadId); + tombstoned += 1; + yield* Effect.logWarning("Rehydrate tombstoned link (Discord channel missing)", { + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + }); + continue; + } + + if (decision.kind === "idle") { + idleLinked += 1; + candidates.push({ link, urgent: false }); + continue; + } + + candidates.push({ link, urgent: true }); + } + + const { selected, dropped } = rankAndCapRestoreCandidates(candidates, MAX_ACTIVE_BRIDGES); + if (dropped > 0) { + yield* Effect.logWarning("Rehydrate capped candidates by urgency and lastActivityAt", { + candidates: candidates.length, + selected: selected.length, + cappedOut: dropped, + cap: MAX_ACTIVE_BRIDGES, + }); + } + + return { + selected: selected.map((candidate) => candidate.link), + stats: { + considered: activeLinks.length, + selected: selected.length, + restored: 0, + failed: 0, + tombstoned, + idleLinked, + cappedOut: dropped, + } satisfies RehydrateStats, + }; +}); + +/** + * Re-establish bridges for active links after boot or T3 reconnect. + * Running / pending / lagging links outrank idle links under the cap. + * Catch-up finalize runs inside each bridge's first snapshot when the turn finished offline. + */ +export const rehydrateBridges = (source: "boot" | "reconnect") => + Effect.gen(function* () { + const hub = yield* BridgeHub; + + yield* Effect.logInfo("Discord bridge rehydrate starting", { source }); + + if (source === "reconnect") { + // subscribeThread fibers die with the old session; clear hub registry explicitly. + yield* hub.dropAll(); + } + + const { selected, stats } = yield* selectRestoreCandidates; + let restored = 0; + let failed = 0; + + yield* Effect.forEach( + selected, + (link) => + Effect.gen(function* () { + yield* hub.ensure({ + discordChannelId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + mode: "rehydrate", + lastActivityAt: link.lastActivityAt, + preferred: true, + }); + restored += 1; + yield* Effect.logInfo("Rehydrate bridge ensured", { + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + openStreamIds: link.streamDiscordMessageIds?.length ?? 0, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + failed += 1; + const pretty = formatAlertCause(cause); + yield* Effect.logError("Rehydrate bridge ensure failed", { + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + cause: pretty, + }); + yield* postFatalAlert( + `rehydrate:${link.discordThreadId}`, + "Bridge rehydrate failed", + `source=\`${source}\` channel=\`${link.discordThreadId}\` thread=\`${link.t3ThreadId}\`\n${pretty}`, + ); + }).pipe(Effect.asVoid), + ), + ), + { concurrency: 4 }, + ); + + const finalStats: RehydrateStats = { + ...stats, + restored, + failed, + }; + yield* Effect.logInfo("Discord bridge rehydrate finished", { + source, + ...finalStats, + }); + return finalStats; + }); diff --git a/apps/discord-bot/src/features/ThreadTalkPolicy.test.ts b/apps/discord-bot/src/features/ThreadTalkPolicy.test.ts new file mode 100644 index 00000000000..8a04ad4cdc1 --- /dev/null +++ b/apps/discord-bot/src/features/ThreadTalkPolicy.test.ts @@ -0,0 +1,64 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { ThreadLink } from "../store/ThreadLinkStore.ts"; +import { + formatUnmentionedDiscordPrompt, + parseThreadTalkCommand, + threadTalkEnabled, +} from "./ThreadTalkPolicy.ts"; + +const link = (threadTalkMode?: "all-messages"): ThreadLink => ({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + lastActivityAt: "2026-07-18T00:00:00.000Z", + status: "active", + lastSeenTurnId: null, + lastFinalizedAssistantId: null, + lastThreadSnapshotSequence: null, + lastDeliveredSequence: null, + ...(threadTalkMode === undefined ? {} : { threadTalkMode }), +}); + +describe("parseThreadTalkCommand", () => { + it("parses exact on, off, and status commands", () => { + expect(parseThreadTalkCommand("thread-talk on")).toEqual({ kind: "set", enabled: true }); + expect(parseThreadTalkCommand(" THREAD-TALK OFF ")).toEqual({ + kind: "set", + enabled: false, + }); + expect(parseThreadTalkCommand("thread-talk status")).toEqual({ kind: "status" }); + }); + + it("does not consume ordinary prompts containing the command words", () => { + expect(parseThreadTalkCommand("please turn thread-talk on")).toBeNull(); + expect(parseThreadTalkCommand("thread-talk on and check this")).toBeNull(); + }); +}); + +describe("threadTalkEnabled", () => { + it("is disabled for absent and legacy links", () => { + expect(threadTalkEnabled(null)).toBe(false); + expect(threadTalkEnabled(link())).toBe(false); + }); + + it("is enabled only for all-messages links", () => { + expect(threadTalkEnabled(link("all-messages"))).toBe(true); + }); +}); + +it("labels unmentioned prompts with Discord author and message context", () => { + expect( + formatUnmentionedDiscordPrompt({ + content: "check the failing build", + authorId: "user-1", + authorName: "Pat", + messageId: "message-1", + }), + ).toBe("Discord message from Pat (user user-1, message message-1):\n\ncheck the failing build"); +}); diff --git a/apps/discord-bot/src/features/ThreadTalkPolicy.ts b/apps/discord-bot/src/features/ThreadTalkPolicy.ts new file mode 100644 index 00000000000..571b828c51b --- /dev/null +++ b/apps/discord-bot/src/features/ThreadTalkPolicy.ts @@ -0,0 +1,30 @@ +import type { ThreadLink } from "../store/ThreadLinkStore.ts"; + +export type ThreadTalkCommand = + | { readonly kind: "set"; readonly enabled: boolean } + | { readonly kind: "status" }; + +export function parseThreadTalkCommand(raw: string): ThreadTalkCommand | null { + const normalized = raw.trim().replace(/\s+/gu, " ").toLocaleLowerCase(); + if (normalized === "thread-talk on") return { kind: "set", enabled: true }; + if (normalized === "thread-talk off") return { kind: "set", enabled: false }; + if (normalized === "thread-talk status") return { kind: "status" }; + return null; +} + +export function threadTalkEnabled(link: ThreadLink | null): boolean { + return link?.threadTalkMode === "all-messages"; +} + +export function formatUnmentionedDiscordPrompt(input: { + readonly content: string; + readonly authorId: string; + readonly authorName: string; + readonly messageId: string; +}): string { + return [ + `Discord message from ${input.authorName} (user ${input.authorId}, message ${input.messageId}):`, + "", + input.content, + ].join("\n"); +} diff --git a/apps/discord-bot/src/identityMap.test.ts b/apps/discord-bot/src/identityMap.test.ts new file mode 100644 index 00000000000..7844916f3f5 --- /dev/null +++ b/apps/discord-bot/src/identityMap.test.ts @@ -0,0 +1,343 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + formatCoAuthoredByBody, + formatCoAuthoredByTrailer, + formatIdentityAttributionBlock, + loadIdentityMapFromFileSync, + makeRefreshingIdentityMapStore, + parseIdentityMapDocument, + parseSimpleIdentityYaml, + resolveGitHubCoAuthorEmail, + resolveParticipantIdentity, +} from "./identityMap.ts"; + +describe("parseIdentityMapDocument", () => { + it("parses people array with nested identities", () => { + const people = parseIdentityMapDocument({ + people: [ + { + name: "Patrick Roza", + discord: { id: "95218063095377920", username: "patroza" }, + github: { login: "patroza", id: "12345" }, + jira: { accountId: "712020:abc", email: "patrick@example.com" }, + }, + ], + }); + expect(people).toEqual([ + { + name: "Patrick Roza", + discord: { id: "95218063095377920", username: "patroza" }, + github: { login: "patroza", id: "12345" }, + jira: { accountId: "712020:abc", email: "patrick@example.com" }, + }, + ]); + }); + + it("parses people map keyed by discord id with flat fields", () => { + const people = parseIdentityMapDocument({ + people: { + "95218063095377920": { + name: "Patrick Roza", + githubLogin: "patroza", + githubId: "12345", + }, + }, + }); + expect(people[0]?.discord?.id).toBe("95218063095377920"); + expect(people[0]?.github?.login).toBe("patroza"); + expect(people[0]?.github?.id).toBe("12345"); + }); + + it("parses top-level map keyed by discord id", () => { + const people = parseIdentityMapDocument({ + "111": { name: "Davide", githubLogin: "davide", githubId: "9" }, + }); + expect(people).toHaveLength(1); + expect(people[0]?.name).toBe("Davide"); + expect(people[0]?.discord?.id).toBe("111"); + }); + + it("rejects entries without name", () => { + expect(() => + parseIdentityMapDocument({ + people: [{ discord: { id: "1" }, github: { login: "x" } }], + }), + ).toThrow(/name/i); + }); +}); + +describe("parseSimpleIdentityYaml", () => { + it("parses people block with nested fields", () => { + const doc = parseSimpleIdentityYaml(` +# comment +people: + "95218063095377920": + name: Patrick Roza + githubLogin: patroza + githubId: "12345" + jiraAccountId: 712020:abc +`); + const people = parseIdentityMapDocument(doc); + expect(people).toHaveLength(1); + expect(people[0]?.name).toBe("Patrick Roza"); + expect(people[0]?.discord?.id).toBe("95218063095377920"); + expect(people[0]?.github?.login).toBe("patroza"); + expect(people[0]?.github?.id).toBe("12345"); + expect(people[0]?.jira?.accountId).toBe("712020:abc"); + }); +}); + +describe("co-author trailers", () => { + it("derives noreply email from github id + login", () => { + expect(resolveGitHubCoAuthorEmail({ login: "patroza", id: "12345" })).toBe( + "12345+patroza@users.noreply.github.com", + ); + }); + + it("prefers explicit email", () => { + expect( + resolveGitHubCoAuthorEmail({ + login: "patroza", + id: "12345", + email: "me@example.com", + }), + ).toBe("me@example.com"); + }); + + it("formats co-author body and full trailer", () => { + expect( + formatCoAuthoredByBody({ + name: "Patrick Roza", + github: { login: "patroza", id: "12345" }, + }), + ).toBe("Patrick Roza <12345+patroza@users.noreply.github.com>"); + expect( + formatCoAuthoredByTrailer({ + name: "Patrick Roza", + github: { login: "patroza", id: "12345" }, + }), + ).toBe("Co-authored-by: Patrick Roza <12345+patroza@users.noreply.github.com>"); + }); + + it("returns null without resolvable email", () => { + expect( + formatCoAuthoredByTrailer({ + name: "X", + github: { login: "x" }, + }), + ).toBeNull(); + }); +}); + +describe("resolveParticipantIdentity + formatIdentityAttributionBlock", () => { + const people = parseIdentityMapDocument({ + people: [ + { + name: "Patrick Roza", + discord: { id: "95218063095377920", username: "patroza" }, + github: { login: "patroza", id: "12345" }, + }, + { + name: "Davide", + discord: { id: "222", username: "davide" }, + github: { login: "davide", id: "99" }, + }, + ], + }); + + it("resolves by discord id", () => { + const resolved = resolveParticipantIdentity({ + role: "requester", + discordId: "95218063095377920", + discordUsername: "patroza", + discordDisplayName: "Patrick Roza", + people, + }); + expect(resolved.person?.name).toBe("Patrick Roza"); + expect(resolved.coAuthoredBy).toContain("patroza"); + }); + + it("marks unmapped users", () => { + const resolved = resolveParticipantIdentity({ + role: "requester", + discordId: "999", + people, + }); + expect(resolved.person).toBeNull(); + expect(resolved.unmappedReason).toContain("not present"); + }); + + it("builds compact cab bodies (no Co-authored-by prefix, no who when mapped)", () => { + const block = formatIdentityAttributionBlock({ + participants: [ + resolveParticipantIdentity({ + role: "thread_starter", + discordId: "222", + discordDisplayName: "Davide", + people, + }), + resolveParticipantIdentity({ + role: "requester", + discordId: "95218063095377920", + discordDisplayName: "Patrick Roza", + people, + }), + ], + }); + expect(block).toBe( + "cab: Davide <99+davide@users.noreply.github.com> | Patrick Roza <12345+patroza@users.noreply.github.com>", + ); + expect(block).not.toContain("Co-authored-by:"); + expect(block).not.toContain("who:"); + expect(block).not.toContain("unmapped:"); + }); + + it("dedupes identical cab when starter is also requester", () => { + const same = resolveParticipantIdentity({ + role: "requester", + discordId: "222", + people, + }); + const starter = resolveParticipantIdentity({ + role: "thread_starter", + discordId: "222", + people, + }); + const block = formatIdentityAttributionBlock({ participants: [starter, same] }); + expect(block).toBe("cab: Davide <99+davide@users.noreply.github.com>"); + expect(block?.match(/Davide/g)).toHaveLength(1); + }); + + it("lists unmapped participants without inventing cab", () => { + const block = formatIdentityAttributionBlock({ + participants: [ + resolveParticipantIdentity({ + role: "requester", + discordId: "999", + discordUsername: "stranger", + people, + }), + ], + }); + expect(block).toContain("cab: (none)"); + expect(block).toContain("unmapped: req 999@stranger unmapped"); + }); +}); + +describe("loadIdentityMapFromFileSync", () => { + it("loads JSON files", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-identity-")); + const path = NodePath.join(dir, "identity-map.json"); + await NodeFSP.writeFile( + path, + JSON.stringify({ + people: [ + { + name: "A", + discord: { id: "1" }, + github: { login: "a", id: "2" }, + }, + ], + }), + "utf8", + ); + const people = loadIdentityMapFromFileSync(path); + expect(people).toHaveLength(1); + expect(people[0]?.github?.login).toBe("a"); + }); + + it("loads YAML files", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-identity-")); + const path = NodePath.join(dir, "identity-map.yaml"); + await NodeFSP.writeFile( + path, + `people: + "1": + name: A + githubLogin: a + githubId: "2" +`, + "utf8", + ); + const people = loadIdentityMapFromFileSync(path); + expect(people[0]?.discord?.id).toBe("1"); + expect(people[0]?.github?.id).toBe("2"); + }); +}); + +describe("makeRefreshingIdentityMapStore", () => { + it("reloads after the TTL expires and keeps the prior map on load failure", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-identity-ttl-")); + const path = NodePath.join(dir, "identity-map.json"); + await NodeFSP.writeFile( + path, + JSON.stringify({ + people: [ + { + name: "A", + discord: { id: "1" }, + github: { login: "a", id: "11" }, + }, + ], + }), + "utf8", + ); + + let now = 1_000_000; + let loads = 0; + const store = makeRefreshingIdentityMapStore({ + filePath: path, + ttlMs: 60_000, + now: () => now, + load: (p) => { + loads += 1; + return loadIdentityMapFromFileSync(p); + }, + }); + + expect(store.list()).toHaveLength(1); + expect(store.resolveByDiscordId("1")?.name).toBe("A"); + expect(loads).toBe(1); + + // Within TTL — no reload + now += 30_000; + expect(store.list()).toHaveLength(1); + expect(loads).toBe(1); + + // After TTL — pick up new file contents + await NodeFSP.writeFile( + path, + JSON.stringify({ + people: [ + { + name: "A", + discord: { id: "1" }, + github: { login: "a", id: "11" }, + }, + { + name: "Davide Di Pumpo", + discord: { id: "150802733316702208" }, + github: { login: "MakhBeth", id: "2373426" }, + }, + ], + }), + "utf8", + ); + now += 60_000; + expect(store.list()).toHaveLength(2); + expect(store.resolveByDiscordId("150802733316702208")?.github?.login).toBe("MakhBeth"); + expect(loads).toBe(2); + + // Corrupt file after TTL — keep last good snapshot + await NodeFSP.writeFile(path, "{ not valid json", "utf8"); + now += 60_000; + expect(store.list()).toHaveLength(2); + expect(store.resolveByDiscordId("150802733316702208")?.name).toBe("Davide Di Pumpo"); + expect(loads).toBe(3); + }); +}); diff --git a/apps/discord-bot/src/identityMap.ts b/apps/discord-bot/src/identityMap.ts new file mode 100644 index 00000000000..e61b4f0acbb --- /dev/null +++ b/apps/discord-bot/src/identityMap.ts @@ -0,0 +1,675 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off tryCatchInEffectGen:off +/** + * Operator-maintained map from Discord (and optional Jira) identities to GitHub + * identities used for commit/PR co-authorship. + * + * Loaded once at bot startup from T3_IDENTITY_MAP_PATH (same delivery path as + * project-aliases: staged secrets share). Absent path → empty map (feature off). + */ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +export interface DiscordIdentityRef { + readonly id: string; + readonly username?: string | undefined; +} + +export interface GitHubIdentityRef { + /** GitHub login (without @). */ + readonly login: string; + /** Numeric GitHub user id as a decimal string (preferred for noreply email). */ + readonly id?: string | undefined; + /** + * Explicit email for Co-authored-by. When omitted, derived as + * `{id}+{login}@users.noreply.github.com` when id is present. + */ + readonly email?: string | undefined; + /** Optional override for the trailer display name (defaults to person.name). */ + readonly name?: string | undefined; +} + +export interface JiraIdentityRef { + readonly accountId?: string | undefined; + readonly email?: string | undefined; + readonly displayName?: string | undefined; +} + +export interface PersonIdentity { + /** Human display name used in Co-authored-by trailers. */ + readonly name: string; + readonly discord?: DiscordIdentityRef | undefined; + readonly github?: GitHubIdentityRef | undefined; + readonly jira?: JiraIdentityRef | undefined; +} + +export class IdentityMapLoadError extends Schema.TaggedErrorClass()( + "IdentityMapLoadError", + { + path: Schema.String, + message: Schema.String, + }, +) {} + +const isIdentityMapLoadError = Schema.is(IdentityMapLoadError); + +function expandHomePath(value: string): string { + if (!value) return value; + if (value === "~") return NodeOS.homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return NodePath.join(NodeOS.homedir(), value.slice(2)); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asDiscordSnowflake(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + // Discord snowflakes are decimal digit strings (typically 17–19 digits). + // Accept any pure digit id so fixtures and short local maps still load. + if (!/^\d{1,32}$/u.test(raw)) return undefined; + return raw; +} + +function normalizeLogin(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + const login = raw.replace(/^@/u, "").trim(); + if (login.length === 0) return undefined; + // GitHub login: alphanumeric / hyphen, max 39. + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(login)) return undefined; + return login; +} + +function parsePerson(raw: unknown, indexLabel: string): PersonIdentity { + if (!isRecord(raw)) { + throw new Error(`Identity map entry ${indexLabel} must be an object.`); + } + + const name = asNonEmptyString(raw.name); + if (name === undefined) { + throw new Error(`Identity map entry ${indexLabel} is missing a non-empty "name".`); + } + + // Nested form: { discord: { id }, github: { login, id, email }, jira: {...} } + // Flat form: { discordId, discordUsername, githubLogin, githubId, githubEmail, jiraAccountId, jiraEmail } + const discordNested = isRecord(raw.discord) ? raw.discord : undefined; + const githubNested = isRecord(raw.github) ? raw.github : undefined; + const jiraNested = isRecord(raw.jira) ? raw.jira : undefined; + + const discordId = + asDiscordSnowflake(discordNested?.id) ?? + asDiscordSnowflake(raw.discordId) ?? + asDiscordSnowflake(raw.discord_id); + const discordUsername = + asNonEmptyString(discordNested?.username) ?? + asNonEmptyString(raw.discordUsername) ?? + asNonEmptyString(raw.discord_username); + + const githubLogin = + normalizeLogin(githubNested?.login) ?? + normalizeLogin(raw.githubLogin) ?? + normalizeLogin(raw.github_login) ?? + normalizeLogin(raw.github); + const githubId = + asNonEmptyString(githubNested?.id)?.replace(/\D/gu, "") || + asNonEmptyString(raw.githubId)?.replace(/\D/gu, "") || + asNonEmptyString(raw.github_id)?.replace(/\D/gu, "") || + undefined; + const githubEmail = + asNonEmptyString(githubNested?.email) ?? + asNonEmptyString(raw.githubEmail) ?? + asNonEmptyString(raw.github_email); + const githubName = + asNonEmptyString(githubNested?.name) ?? + asNonEmptyString(raw.githubName) ?? + asNonEmptyString(raw.github_name); + + const jiraAccountId = + asNonEmptyString(jiraNested?.accountId) ?? + asNonEmptyString(raw.jiraAccountId) ?? + asNonEmptyString(raw.jira_account_id); + const jiraEmail = + asNonEmptyString(jiraNested?.email) ?? + asNonEmptyString(raw.jiraEmail) ?? + asNonEmptyString(raw.jira_email); + const jiraDisplayName = + asNonEmptyString(jiraNested?.displayName) ?? + asNonEmptyString(raw.jiraDisplayName) ?? + asNonEmptyString(raw.jira_display_name); + + if (discordId === undefined && githubLogin === undefined && jiraAccountId === undefined) { + throw new Error( + `Identity map entry ${indexLabel} ("${name}") needs at least one of discord.id, github.login, or jira.accountId.`, + ); + } + + return { + name, + ...(discordId !== undefined + ? { + discord: { + id: discordId, + ...(discordUsername !== undefined ? { username: discordUsername } : {}), + }, + } + : {}), + ...(githubLogin !== undefined + ? { + github: { + login: githubLogin, + ...(githubId !== undefined && githubId.length > 0 ? { id: githubId } : {}), + ...(githubEmail !== undefined ? { email: githubEmail } : {}), + ...(githubName !== undefined ? { name: githubName } : {}), + }, + } + : {}), + ...(jiraAccountId !== undefined || jiraEmail !== undefined + ? { + jira: { + ...(jiraAccountId !== undefined ? { accountId: jiraAccountId } : {}), + ...(jiraEmail !== undefined ? { email: jiraEmail } : {}), + ...(jiraDisplayName !== undefined ? { displayName: jiraDisplayName } : {}), + }, + } + : {}), + }; +} + +/** + * Parse identity map document (JSON object). + * + * Accepted shapes: + * - `{ "people": [ { name, discord, github, jira }, ... ] }` + * - `{ "people": { "": { name, githubLogin, ... }, ... } }` + * - `{ "": { name, githubLogin, ... }, ... }` (top-level map, no people key) + */ +export function parseIdentityMapDocument(document: unknown): ReadonlyArray { + if (document === null || document === undefined) return []; + if (!isRecord(document)) { + throw new Error("Identity map root must be an object."); + } + + const peopleNode = document.people; + if (Array.isArray(peopleNode)) { + return peopleNode.map((entry, index) => parsePerson(entry, `[${index}]`)); + } + + if (isRecord(peopleNode)) { + return Object.entries(peopleNode).map(([key, value]) => { + if (!isRecord(value)) { + throw new Error(`Identity map people["${key}"] must be an object.`); + } + // If the key is a snowflake and the object omitted discordId, inject it. + const withDiscord = + asDiscordSnowflake(key) !== undefined && asDiscordSnowflake(value.discordId) === undefined + ? { ...value, discordId: key } + : value; + return parsePerson(withDiscord, `people["${key}"]`); + }); + } + + // Top-level map keyed by discord id (when no people key). + const reserved = new Set(["version", "schema", "$schema"]); + const entries = Object.entries(document).filter(([key]) => !reserved.has(key)); + if (entries.length === 0) return []; + + return entries.map(([key, value]) => { + if (!isRecord(value)) { + throw new Error(`Identity map["${key}"] must be an object.`); + } + const withDiscord = + asDiscordSnowflake(key) !== undefined && asDiscordSnowflake(value.discordId) === undefined + ? { ...value, discordId: key } + : value; + return parsePerson(withDiscord, `["${key}"]`); + }); +} + +/** + * Minimal YAML → JSON-ish object for the identity map shapes we document. + * Prefer JSON for complex nesting; this covers the flat keyed form used in ops. + * + * Supports: + * ```yaml + * people: + * "123": + * name: Alice + * githubLogin: alice + * githubId: "99" + * ``` + * and top-level keyed people without a `people:` wrapper. + */ +export function parseSimpleIdentityYaml(raw: string): unknown { + const lines = raw.split(/\r?\n/); + const root: Record> = {}; + let inPeople = false; + let currentKey: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + + if (/^people:\s*$/u.test(trimmed)) { + inPeople = true; + continue; + } + + // Nested field under a person key (2+ spaces). + const fieldMatch = /^(\s{2,})([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)$/u.exec(line); + if (fieldMatch && currentKey !== null) { + const field = fieldMatch[2]!; + const value = stripYamlScalar(fieldMatch[3] ?? ""); + root[currentKey] = { ...root[currentKey], [field]: value }; + continue; + } + + // Person key at indent 0 or under people: (2 spaces): `"id":` or `id:` + const keyMatch = /^(\s{0,2})(?:"(\d+)"|'(\d+)'|(\d+)|([A-Za-z0-9_-]+))\s*:\s*$/u.exec(line); + if (keyMatch) { + const indent = keyMatch[1] ?? ""; + if (inPeople && indent.length === 0) { + // top-level key after people block ended — treat as new top-level person + } + const key = keyMatch[2] ?? keyMatch[3] ?? keyMatch[4] ?? keyMatch[5] ?? null; + if (key === null || key === "people") continue; + currentKey = key; + root[currentKey] = { ...root[currentKey] }; + continue; + } + + // Inline single-line `key: value` at root (unusual for this schema) + const inlineMatch = /^([A-Za-z0-9_-]+|"\d+"|'\d+'|\d+)\s*:\s+(.+)$/u.exec(trimmed); + if (inlineMatch && currentKey === null) { + continue; + } + } + + if (Object.keys(root).length === 0) { + return { people: [] }; + } + return { people: root }; +} + +function stripYamlScalar(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + // Strip inline comments for simple scalars. + const hash = trimmed.search(/\s+#/u); + return hash >= 0 ? trimmed.slice(0, hash).trim() : trimmed; +} + +export function loadIdentityMapFromFileSync(filePath: string): ReadonlyArray { + const resolvedPath = NodePath.resolve(expandHomePath(filePath.trim())); + if (!NodeFS.existsSync(resolvedPath)) { + throw new IdentityMapLoadError({ + path: resolvedPath, + message: `Identity map file not found: ${resolvedPath}`, + }); + } + + const raw = NodeFS.readFileSync(resolvedPath, "utf8"); + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + + let document: unknown; + try { + if (resolvedPath.endsWith(".json")) { + document = JSON.parse(trimmed) as unknown; + } else { + // YAML: try JSON-compatible first (JSON is valid YAML subset for our shapes). + try { + document = JSON.parse(trimmed) as unknown; + } catch { + document = parseSimpleIdentityYaml(trimmed); + } + } + } catch (cause) { + throw new IdentityMapLoadError({ + path: resolvedPath, + message: cause instanceof Error ? cause.message : String(cause), + }); + } + + try { + return parseIdentityMapDocument(document); + } catch (cause) { + throw new IdentityMapLoadError({ + path: resolvedPath, + message: cause instanceof Error ? cause.message : String(cause), + }); + } +} + +/** Derive the email used in Co-authored-by trailers. */ +export function resolveGitHubCoAuthorEmail(github: GitHubIdentityRef): string | null { + const explicit = github.email?.trim(); + if (explicit !== undefined && explicit.length > 0) return explicit; + const id = github.id?.trim(); + const login = github.login.trim(); + if (id !== undefined && id.length > 0 && login.length > 0) { + return `${id}+${login}@users.noreply.github.com`; + } + return null; +} + +/** + * Co-author body only: `Name ` (no trailer prefix). + * Prefix with `Co-authored-by: ` when writing git commits (see agent-turn-rules). + */ +export function formatCoAuthoredByBody(person: PersonIdentity): string | null { + const github = person.github; + if (github === undefined) return null; + const email = resolveGitHubCoAuthorEmail(github); + if (email === null) return null; + const name = (github.name ?? person.name).trim() || person.name; + // Git trailers must be a single line; strip newlines from names. + const safeName = name.replace(/[\r\n]+/gu, " ").trim(); + return `${safeName} <${email}>`; +} + +/** Full git trailer `Co-authored-by: Name `, or null when unresolved. */ +export function formatCoAuthoredByTrailer(person: PersonIdentity): string | null { + const body = formatCoAuthoredByBody(person); + return body === null ? null : `Co-authored-by: ${body}`; +} + +export type ResolvedParticipantIdentity = { + readonly role: "requester" | "thread_starter" | "other"; + readonly discordId: string | null; + readonly discordUsername: string | null; + readonly discordDisplayName: string | null; + readonly person: PersonIdentity | null; + readonly coAuthoredBy: string | null; + readonly unmappedReason: string | null; +}; + +export function resolveParticipantIdentity(input: { + readonly role: ResolvedParticipantIdentity["role"]; + readonly discordId?: string | null | undefined; + readonly discordUsername?: string | null | undefined; + readonly discordDisplayName?: string | null | undefined; + readonly people: ReadonlyArray; +}): ResolvedParticipantIdentity { + const discordId = input.discordId?.trim() || null; + const discordUsername = input.discordUsername?.trim() || null; + const discordDisplayName = input.discordDisplayName?.trim() || null; + + let person: PersonIdentity | null = null; + if (discordId !== null) { + person = input.people.find((p) => p.discord?.id === discordId) ?? null; + } + if (person === null && discordUsername !== null) { + const lowered = discordUsername.toLowerCase(); + person = input.people.find((p) => p.discord?.username?.toLowerCase() === lowered) ?? null; + } + + if (person === null) { + return { + role: input.role, + discordId, + discordUsername, + discordDisplayName, + person: null, + coAuthoredBy: null, + unmappedReason: + discordId === null && discordUsername === null + ? "no discord identity on message" + : "not present in identity map", + }; + } + + const coAuthoredBy = formatCoAuthoredByTrailer(person); + return { + role: input.role, + discordId, + discordUsername, + discordDisplayName, + person, + coAuthoredBy, + unmappedReason: + coAuthoredBy === null + ? person.github === undefined + ? "mapped person has no github.login" + : "mapped person has no github email/id for Co-authored-by" + : null, + }; +} + +/** + * Build the agent-facing attribution block for commits/PRs. + * Bot already resolved the identity map — inject cab bodies only (no lookup, no + * repeated `Co-authored-by:` prefix). Unmapped participants listed briefly. + * Returns null when there is nothing useful to inject. + */ +export function formatIdentityAttributionBlock(input: { + readonly participants: ReadonlyArray; +}): string | null { + const participants = input.participants; + if (participants.length === 0) return null; + + const anyMapped = participants.some((p) => p.person !== null); + // Dedupe by trailer body; starter+req same person → one entry. + const cabBodies = uniqueStrings( + participants + .map((p) => { + if (p.coAuthoredBy === null) return null; + return p.coAuthoredBy.replace(/^Co-authored-by:\s*/iu, "").trim(); + }) + .filter((t): t is string => t !== null && t.length > 0), + ); + + // Only surface unmapped / incomplete rows (mapped people are fully covered by cab). + const unmappedParts: string[] = []; + const seenUnmapped = new Set(); + for (const p of participants) { + if (p.coAuthoredBy !== null) continue; + const role = p.role === "requester" ? "req" : p.role === "thread_starter" ? "starter" : "p"; + const id = p.discordId ?? "?"; + const user = p.discordUsername ?? p.person?.discord?.username ?? "?"; + const key = `${id}@${user}`; + if (seenUnmapped.has(key)) continue; + seenUnmapped.add(key); + const why = p.person === null ? "unmapped" : "no-gh"; + unmappedParts.push(`${role} ${key} ${why}`); + } + + const lines: string[] = []; + if (cabBodies.length > 0) { + // Single line; agent prefixes each with `Co-authored-by: ` when committing. + lines.push(`cab: ${cabBodies.join(" | ")}`); + } else if (!anyMapped) { + lines.push("cab: (none)"); + } else { + lines.push("cab: (none — missing gh id/email)"); + } + if (unmappedParts.length > 0) { + lines.push(`unmapped: ${unmappedParts.join(" | ")}`); + } + + return lines.join("\n"); +} + +function uniqueStrings(values: ReadonlyArray): ReadonlyArray { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(value); + } + return out; +} + +export interface IdentityMapStoreService { + readonly list: () => ReadonlyArray; + readonly resolveByDiscordId: (discordId: string) => PersonIdentity | null; + readonly resolveByDiscordUsername: (username: string) => PersonIdentity | null; + readonly resolveParticipant: (input: { + readonly role: ResolvedParticipantIdentity["role"]; + readonly discordId?: string | null | undefined; + readonly discordUsername?: string | null | undefined; + readonly discordDisplayName?: string | null | undefined; + }) => ResolvedParticipantIdentity; +} + +export class IdentityMapStore extends Context.Service()( + "@t3tools/discord-bot/identityMap/IdentityMapStore", +) {} + +/** How long a loaded identity map stays hot before re-reading the file (no bot restart). */ +export const IDENTITY_MAP_CACHE_TTL_MS = 60_000; + +function indexesFromPeople(people: ReadonlyArray): { + readonly people: ReadonlyArray; + readonly byId: ReadonlyMap; + readonly byUsername: ReadonlyMap; +} { + const byId = new Map(); + const byUsername = new Map(); + for (const person of people) { + if (person.discord?.id !== undefined) { + byId.set(person.discord.id, person); + } + if (person.discord?.username !== undefined) { + byUsername.set(person.discord.username.toLowerCase(), person); + } + } + return { people, byId, byUsername }; +} + +export const makeIdentityMapStore = ( + people: ReadonlyArray, +): IdentityMapStoreService => { + const index = indexesFromPeople(people); + + return IdentityMapStore.of({ + list: () => index.people, + resolveByDiscordId: (discordId) => index.byId.get(discordId.trim()) ?? null, + resolveByDiscordUsername: (username) => + index.byUsername.get(username.trim().toLowerCase()) ?? null, + resolveParticipant: (input) => + resolveParticipantIdentity({ + role: input.role, + discordId: input.discordId, + discordUsername: input.discordUsername, + discordDisplayName: input.discordDisplayName, + people: index.people, + }), + }); +}; + +/** + * File-backed identity map with a short TTL cache so operators can edit + * identity-map.yaml without restarting the Discord bot. + * + * - Eager-loads once at construction (throws on first failure). + * - Re-reads the file at most every `ttlMs` (default 60s) on access. + * - On later load failures, keeps the last good snapshot and waits another TTL. + */ +export function makeRefreshingIdentityMapStore(input: { + readonly filePath: string; + readonly ttlMs?: number; + readonly now?: () => number; + readonly load?: (path: string) => ReadonlyArray; + /** Optional hook when a reload succeeds (for tests / diagnostics). */ + readonly onReload?: (people: ReadonlyArray) => void; +}): IdentityMapStoreService { + const ttlMs = input.ttlMs ?? IDENTITY_MAP_CACHE_TTL_MS; + // @effect-diagnostics-next-line globalDate:off + const now = input.now ?? (() => Date.now()); + const load = input.load ?? loadIdentityMapFromFileSync; + const path = input.filePath.trim(); + + let index = indexesFromPeople(load(path)); + let loadedAt = now(); + input.onReload?.(index.people); + + const refreshIfStale = () => { + const t = now(); + if (t - loadedAt < ttlMs) return; + try { + const nextPeople = load(path); + index = indexesFromPeople(nextPeople); + loadedAt = t; + input.onReload?.(index.people); + } catch { + // Keep serving the last good map; delay the next retry by a full TTL. + loadedAt = t; + } + }; + + return IdentityMapStore.of({ + list: () => { + refreshIfStale(); + return index.people; + }, + resolveByDiscordId: (discordId) => { + refreshIfStale(); + return index.byId.get(discordId.trim()) ?? null; + }, + resolveByDiscordUsername: (username) => { + refreshIfStale(); + return index.byUsername.get(username.trim().toLowerCase()) ?? null; + }, + resolveParticipant: (participant) => { + refreshIfStale(); + return resolveParticipantIdentity({ + role: participant.role, + discordId: participant.discordId, + discordUsername: participant.discordUsername, + discordDisplayName: participant.discordDisplayName, + people: index.people, + }); + }, + }); +} + +export const layerFromOptionalPath = (filePath: string | undefined) => + Layer.effect( + IdentityMapStore, + Effect.gen(function* () { + if (filePath === undefined || filePath.trim().length === 0) { + yield* Effect.logInfo( + "T3_IDENTITY_MAP_PATH is unset; Discord→GitHub co-author injection is off until configured.", + ); + return makeIdentityMapStore([]); + } + const resolvedPath = filePath.trim(); + const store = yield* Effect.try({ + try: () => makeRefreshingIdentityMapStore({ filePath: resolvedPath }), + catch: (cause) => { + if (isIdentityMapLoadError(cause)) return cause; + return new IdentityMapLoadError({ + path: resolvedPath, + message: cause instanceof Error ? cause.message : String(cause), + }); + }, + }); + const count = store.list().length; + yield* Effect.logInfo( + `Loaded ${count} identity map entr${count === 1 ? "y" : "ies"} from ${resolvedPath} (reload TTL ${IDENTITY_MAP_CACHE_TTL_MS / 1000}s)`, + ); + return store; + }), + ); diff --git a/apps/discord-bot/src/main.ts b/apps/discord-bot/src/main.ts new file mode 100644 index 00000000000..027d8f71fcd --- /dev/null +++ b/apps/discord-bot/src/main.ts @@ -0,0 +1,195 @@ +// @effect-diagnostics globalDate:off globalFetch:off globalTimers:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off anyUnknownInErrorContext:off missingEffectContext:off missingEffectError:off preferSchemaOverJson:off tryCatchInEffectGen:off missingReturnYieldStar:off layerMergeAllWithDependencies:off unsafeEffectTypeAssertion:off +import { NodeRuntime } from "@effect/platform-node"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import { FetchHttpClient } from "effect/unstable/http"; +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; + +import { DiscordBotConfig } from "./config.ts"; +import { makeDiscordLayer } from "./discord/DiscordLive.ts"; +import { runAlertWatchdog } from "./features/Alerts.ts"; +import { layer as bridgeHubLayer } from "./features/BridgeHub.ts"; +import { DiscordBotRunning, MentionRouterLive } from "./features/MentionRouter.ts"; +import { runBridge } from "./features/ResponseBridge.ts"; +import { runTeamsModule } from "./features/TeamsModule.ts"; +import { runTeamsNativeApp } from "./features/TeamsNativeApp.ts"; +import { backfillThreadInfoPins } from "./features/ThreadInfoPin.ts"; +import { rehydrateBridges } from "./features/ThreadRestore.ts"; +import { layerFromOptionalPath as identityMapStoreLayer, IdentityMapStore } from "./identityMap.ts"; +import { + layerFromOptionalPath as projectAliasStoreLayer, + ProjectAliasStore, +} from "./projectAliases.ts"; +import { layer as teamsSeenStoreLayer } from "./store/TeamsSeenStore.ts"; +import { layer as threadLinkStoreLayer } from "./store/ThreadLinkStore.ts"; +import { layer as threadWarmCacheStoreLayer } from "./store/ThreadWarmCacheStore.ts"; +import { T3Session, layer as t3SessionLayer } from "./t3/T3Session.ts"; + +const BotObservabilityLive = Layer.unwrap( + Effect.gen(function* () { + const tracesUrl = yield* Config.option(Config.nonEmptyString("T3CODE_OTLP_TRACES_URL")); + return Option.match(tracesUrl, { + onNone: () => Layer.empty, + onSome: (url) => + OtlpTracer.layer({ + url, + resource: { + serviceName: "t3-discord-bot", + attributes: { + "service.runtime": "discord-bot", + }, + }, + }).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)), + }); + }), +).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv()))); + +/** + * Build layers so MentionRouter is a *required* dependency of the program + * (via DiscordBotRunning). effectDiscard-only layers can be pruned when + * nothing in the program Effect requires them. + */ +const MainLayer = Layer.unwrap( + Effect.gen(function* () { + const botConfig = yield* DiscordBotConfig; + const discordToken = botConfig.discordToken; + if (discordToken === undefined) { + return yield* Effect.die( + new Error( + "DISCORD_BOT_TOKEN is required by the Discord entrypoint. Use start:teams for Teams-only deployments.", + ), + ); + } + const discord = makeDiscordLayer(discordToken); + const bridgeHub = bridgeHubLayer(runBridge); + const core = Layer.mergeAll( + t3SessionLayer(botConfig), + threadLinkStoreLayer(botConfig.dataDir), + teamsSeenStoreLayer(botConfig.dataDir), + threadWarmCacheStoreLayer(botConfig.dataDir), + projectAliasStoreLayer(botConfig.projectAliasesPath), + identityMapStoreLayer(botConfig.identityMapPath), + bridgeHub, + ).pipe(Layer.provideMerge(discord)); + + const router = MentionRouterLive(botConfig).pipe(Layer.provide(core)); + + // Router first so DiscordBotRunning is provided; core+discord still available. + return router.pipe( + Layer.provideMerge(core), + Layer.provideMerge(discord), + Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromEnv())), + Layer.provideMerge(Logger.layer([Logger.consolePretty()])), + ); + }), +).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv()))); + +const program = Effect.gen(function* () { + const botConfig = yield* DiscordBotConfig; + yield* Effect.logInfo("Starting T3 Discord bot", { + t3HttpBaseUrl: botConfig.t3HttpBaseUrl, + dataDir: botConfig.dataDir, + projectAliasesPath: botConfig.projectAliasesPath ?? "(unset)", + identityMapPath: botConfig.identityMapPath ?? "(unset)", + alertProcessRulesPath: botConfig.alertProcessRulesPath ?? "(unset)", + }); + + // Force acquisition of MentionRouter + Discord gateway (must not be pruned). + // Discord can become READY before guest T3 is listening — that is expected on + // restart. Mentions that land early get a transient "still connecting" reply. + const running = yield* DiscordBotRunning; + yield* Effect.logInfo("Discord gateway active", { botUserId: running.botUserId }); + + const t3 = yield* T3Session; + + // Capture ambient services so T3 auto-reconnect can rehydrate Discord bridges. + // Register before connectUntilReady so a drop immediately after first success + // still rehydrates. provideContext erases R at runtime; cast so Effect.runPromise + // accepts the program. (setOnReconnected is a Promise callback outside the Effect + // runtime, so runPromise is intentional.) + const services = yield* Effect.context(); + t3.setOnReconnected(() => + // @effect-diagnostics-next-line runEffectInsideEffect:off + Effect.runPromise( + rehydrateBridges("reconnect").pipe( + Effect.provideContext(services), + Effect.catchCause((cause) => + Effect.logError("Reconnect rehydrate failed").pipe( + Effect.andThen(Effect.logError(cause)), + ), + ), + Effect.asVoid, + ) as Effect.Effect, + ), + ); + + // Retry forever until shell is ready — do not exit the process when T3 is late. + yield* t3.connectUntilReady(); + const aliasStore = yield* ProjectAliasStore; + const identityStore = yield* IdentityMapStore; + yield* Effect.logInfo( + `Connected to T3; bot project aliases=${aliasStore.list().length}; identity map entries=${identityStore.list().length}`, + ); + + // Restore running/pending bridges + catch-up finalize for open stream tips. + // Without this, mid-turn Discord threads go silent until a human @mentions again. + yield* rehydrateBridges("boot").pipe( + Effect.catchCause((cause) => + Effect.logError("Boot rehydrate failed").pipe(Effect.andThen(Effect.logError(cause))), + ), + ); + + // Backfill pinned thread-info messages (Model / Open in Omegent / Jira links) for active links. + yield* backfillThreadInfoPins(botConfig).pipe( + Effect.catchCause((cause) => + Effect.logError("Thread info pin backfill failed").pipe( + Effect.andThen(Effect.logError(cause)), + ), + ), + ); + + // Guest ops alerts (load / mem / runaway Sentry MCP / long turns) → Discord channel. + // forkDetach: program Effect has no Scope (unlike Layer.effect fibers). + yield* Effect.forkDetach( + runAlertWatchdog(botConfig).pipe( + Effect.catchCause((cause) => + Effect.logError("Alert watchdog stopped").pipe(Effect.andThen(Effect.logError(cause))), + ), + ), + ); + if (botConfig.alertsChannelId) { + yield* Effect.logInfo("Discord ops alerts enabled", { + channelId: botConfig.alertsChannelId, + }); + } + + // Optional Teams Graph intake → Discord/T3 (off unless TEAMS_ENABLED=1). + yield* Effect.forkDetach( + runTeamsModule(botConfig).pipe( + Effect.catchCause((cause) => + Effect.logError("Teams module stopped").pipe(Effect.andThen(Effect.logError(cause))), + ), + ), + ); + + // Optional native Teams SDK endpoint. This can run beside Discord during migration. + yield* runTeamsNativeApp(botConfig).pipe( + Effect.catchCause((cause) => + Effect.logError("Native Teams app stopped").pipe(Effect.andThen(Effect.logError(cause))), + ), + ); + + // Keep process alive; router fibers are scoped to this layer lifetime. + return yield* Effect.never; +}); + +NodeRuntime.runMain( + program.pipe(Effect.provide(Layer.merge(MainLayer, BotObservabilityLive))) as Effect.Effect< + never, + unknown + >, +); diff --git a/apps/discord-bot/src/presentation/asciiTables.test.ts b/apps/discord-bot/src/presentation/asciiTables.test.ts new file mode 100644 index 00000000000..35bfce3772d --- /dev/null +++ b/apps/discord-bot/src/presentation/asciiTables.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + chunkDiscordContentPreservingTables, + extractMarkdownTables, + isSeparatorRow, + renderMysqlTable, + renderRoundedTable, + rewriteMarkdownTablesForDiscord, + splitTableCells, + wrapCellText, +} from "./asciiTables.ts"; + +describe("splitTableCells", () => { + it("splits on unescaped pipes and trims cells", () => { + expect(splitTableCells("| Doc | What it is |")).toEqual(["Doc", "What it is"]); + expect(splitTableCells("Doc | What it is")).toEqual(["Doc", "What it is"]); + }); + + it("keeps escaped pipes inside a cell", () => { + expect(splitTableCells("| a \\| b | c |")).toEqual(["a | b", "c"]); + }); +}); + +describe("isSeparatorRow", () => { + it("accepts GFM separator cells", () => { + expect(isSeparatorRow(["---", ":---", "---:", ":---:"])).toBe(true); + expect(isSeparatorRow(["Doc", "What"])).toBe(false); + }); +}); + +describe("extractMarkdownTables", () => { + it("extracts a short unfenced table", () => { + const text = `| Doc | What it actually is | +|---|---| +| effect-cluster-worker-migration.md | Mixed topology notes. | +| durable-print-roundtrip.md | EasyLife workflow. |`; + + const matches = extractMarkdownTables(text); + expect(matches).toHaveLength(1); + expect(matches[0]?.headers).toEqual(["Doc", "What it actually is"]); + expect(matches[0]?.rows).toEqual([ + ["effect-cluster-worker-migration.md", "Mixed topology notes."], + ["durable-print-roundtrip.md", "EasyLife workflow."], + ]); + expect(matches[0]?.start).toBe(0); + expect(matches[0]?.end).toBe(text.length); + }); + + it("extracts a table wrapped in a code fence", () => { + const text = `\`\`\`bash +| Doc | What | +|---|---| +| a.md | first | +\`\`\``; + const matches = extractMarkdownTables(text); + expect(matches).toHaveLength(1); + expect(matches[0]?.headers).toEqual(["Doc", "What"]); + expect(matches[0]?.rows).toEqual([["a.md", "first"]]); + expect(matches[0]?.raw.startsWith("```")).toBe(true); + }); + + it("finds a table with surrounding prose", () => { + const text = `Intro line. + +| A | B | +|---|---| +| 1 | 2 | + +Outro line.`; + const matches = extractMarkdownTables(text); + expect(matches).toHaveLength(1); + expect(matches[0]?.headers).toEqual(["A", "B"]); + expect(text.slice(0, matches[0]!.start)).toContain("Intro"); + expect(text.slice(matches[0]!.end)).toContain("Outro"); + }); + + it("returns empty when there is no table", () => { + expect(extractMarkdownTables("just a paragraph\nand another")).toEqual([]); + expect(extractMarkdownTables("| not a table without separator |")).toEqual([]); + }); +}); + +describe("wrapCellText", () => { + it("wraps on word boundaries", () => { + expect(wrapCellText("hello world friend", 10)).toEqual(["hello", "world", "friend"]); + expect(wrapCellText("short", 60)).toEqual(["short"]); + }); + + it("hard-breaks overlong tokens", () => { + expect(wrapCellText("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]); + }); +}); + +describe("renderRoundedTable", () => { + it("renders a short aligned rounded table", () => { + const rendered = renderRoundedTable( + ["Col1", "Col2"], + [ + ["Value 1", "Value 2"], + ["x", "y"], + ], + ); + expect(rendered).toBe( + [ + ".---------.---------.", + "| Col1 | Col2 |", + ":---------+---------:", + "| Value 1 | Value 2 |", + ":---------+---------:", + "| x | y |", + "'---------'---------'", + ].join("\n"), + ); + }); + + it("wraps a long-description column within the Discord line width cap", () => { + const long = + "Incomplete — only inbound SFTP settle window (10s). Missing ABAS create/fetch delivery note."; + const rendered = renderRoundedTable( + ["Doc", "What it actually is"], + [["abas-file-handoff.md", long]], + 40, + 72, + ); + const lines = rendered.split("\n"); + // Multi-line body row for the wrapped description. + const bodyLines = lines.filter((line) => line.startsWith("|")); + expect(bodyLines.length).toBeGreaterThan(2); // header + at least 2 wrapped body lines + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(72); + } + expect(rendered).toContain("abas-file-handoff.md"); + expect(rendered).toContain("Incomplete"); + expect(rendered.startsWith(".")).toBe(true); + expect(rendered.endsWith("'")).toBe(true); + }); + + it("right-aligns mostly-numeric columns", () => { + const rendered = renderRoundedTable( + ["Name", "Amount"], + [ + ["a", "10.0"], + ["b", "-2,027.1"], + ], + ); + expect(rendered).toContain("| 10.0 |"); + expect(rendered).toContain("| -2,027.1 |"); + }); +}); + +describe("renderMysqlTable", () => { + it("uses +---+ box borders with a separator after every row", () => { + const rendered = renderMysqlTable( + ["H1", "H2"], + [ + ["a", "b"], + ["c", "d"], + ], + ); + expect(rendered).toBe( + [ + "+----+----+", + "| H1 | H2 |", + "+----+----+", + "| a | b |", + "+----+----+", + "| c | d |", + "+----+----+", + ].join("\n"), + ); + }); +}); + +describe("rewriteMarkdownTablesForDiscord", () => { + it("rewrites a short table to a fenced rounded ASCII table", () => { + const input = `| Doc | What | +|---|---| +| a.md | first |`; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result.attachments).toEqual([]); + expect(result.text.startsWith("```\n")).toBe(true); + expect(result.text.endsWith("\n```")).toBe(true); + expect(result.text).toContain("| Doc "); + expect(result.text).toContain("| a.md"); + expect(result.text).toContain(".---"); + }); + + it("preserves surrounding text", () => { + const input = `Before. + +| A | B | +|---|---| +| 1 | 2 | + +After.`; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result.text.startsWith("Before.")).toBe(true); + expect(result.text.endsWith("After.")).toBe(true); + expect(result.text).toContain("```"); + expect(result.text).not.toContain("|---|"); + }); + + it("is a passthrough when there is no table", () => { + const input = "No tables here, just prose."; + expect(rewriteMarkdownTablesForDiscord(input)).toEqual({ + text: input, + attachments: [], + }); + }); + + it("replaces fenced markdown tables", () => { + const input = `\`\`\`bash +| Doc | What | +|---|---| +| a.md | first | +\`\`\``; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result.text).not.toContain("```bash"); + expect(result.text).toContain("| Doc "); + expect(result.attachments).toEqual([]); + }); + + it("keeps long-text multi-row content in a single table", () => { + const input = `| Doc | What it actually is | +|---|---| +| effect-cluster-worker-migration.md | Mixed: some current topology (shard groups, storage protocols) plus cutover history, removed files, bug notes (void RPC), follow-ups. Title/index still read as migration, not the living ops reference. EasyLife-centric. | +| durable-print-roundtrip.md | EasyLife workflow round-trip (activities, keys, pack SM). Not deploy/runtime/poll/alerts. | +| abas-file-handoff.md | Incomplete — only inbound SFTP settle window (10s). Missing ABAS create/fetch delivery note, packaging import, closeout CSV write, mounts, who runs where. | +| packstations-and-printers.md | Printer ids / CUPS naming / company maps. Not cluster mailbox model. |`; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result.attachments).toEqual([]); + // One table, one fence — do not split into one mini-table per row. + expect(result.text.match(/```/g)?.length).toBe(2); + expect(result.text.startsWith("```\n")).toBe(true); + expect(result.text.endsWith("\n```")).toBe(true); + expect(result.text).not.toContain("\u200B"); + // Only one header block / top border (not one mini-table per row). + const pipeLines = result.text.split("\n").filter((line) => line.startsWith("|")); + expect(pipeLines.filter((line) => line.includes("What it actually is")).length).toBe(1); + expect(result.text.split("\n").filter((line) => line.startsWith(".")).length).toBe(1); + expect(result.text).toContain("EasyLife-centric."); + expect(result.text).toContain("effect-cluster-worker-migration.md"); + expect(result.text).toContain("packstations-and-printers.md"); + for (const line of result.text.split("\n")) { + if ( + line.startsWith("|") || + line.startsWith(".") || + line.startsWith(":") || + line.startsWith("'") + ) { + expect(line.length).toBeLessThanOrEqual(72); + } + } + }); + + it("attaches a single row only when it still exceeds the message limit", () => { + const huge = "word ".repeat(500).trim(); + const input = ["| Col | Description |", "|---|---|", `| only | ${huge} |`].join("\n"); + const result = rewriteMarkdownTablesForDiscord(input, { + messageLimit: 200, + maxColWidth: 40, + maxTableWidth: 72, + }); + expect(result.attachments.length).toBe(1); + expect(result.attachments[0]?.name).toBe("table.txt"); + expect(result.attachments[0]?.body).toContain("word"); + expect(result.text).toContain("table.txt"); + }); + + it("right-aligns a numeric column wider than its header", () => { + const rendered = renderRoundedTable( + ["Name", "Amount"], + [ + ["a", "10.0"], + ["b", "-2,027.1"], + ["c", "1,234,567.89"], + ], + ); + expect(rendered).toContain("| Name | Amount |"); + expect(rendered).toContain("| a | 10.0 |"); + expect(rendered).toContain("| b | -2,027.1 |"); + expect(rendered).toContain("| c | 1,234,567.89 |"); + }); + + it("does not convert a fenced bash block that only looks a bit like a table", () => { + const input = `\`\`\`bash +| not a real table without separator style +|--- +echo "hello | world" +\`\`\``; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result.attachments).toEqual([]); + expect(result.text).toBe(input); + expect(extractMarkdownTables(input)).toEqual([]); + }); + + it("names dual oversized table attachments in document reading order", () => { + const huge = "word ".repeat(400).trim(); + const input = [ + "| ColA | DescA |", + "|---|---|", + `| first-table | ${huge} |`, + "", + "Some prose between.", + "", + "| ColB | DescB |", + "|---|---|", + `| second-table | ${huge} |`, + ].join("\n"); + const result = rewriteMarkdownTablesForDiscord(input, { + messageLimit: 300, + maxColWidth: 40, + maxTableWidth: 72, + }); + expect(result.attachments.map((entry) => entry.name)).toEqual(["table-1.txt", "table-2.txt"]); + expect(result.attachments[0]?.body).toContain("first-table"); + expect(result.attachments[1]?.body).toContain("second-table"); + // Notes in body follow the same reading order. + const firstNote = result.text.indexOf("table-1.txt"); + const secondNote = result.text.indexOf("table-2.txt"); + expect(firstNote).toBeGreaterThanOrEqual(0); + expect(secondNote).toBeGreaterThan(firstNote); + expect(result.text.indexOf("first-table")).toBe(-1); + expect(result.text.indexOf("second-table")).toBe(-1); + }); +}); + +describe("chunkDiscordContentPreservingTables", () => { + it("does not split inside a fenced ASCII table", () => { + const table = [ + "```", + ".----+----.", + "| A | B |", + ":----+----:", + "| 1 | 2 |", + "'----+----'", + "```", + ].join("\n"); + // Force a second chunk: large prose + full fenced table > limit. + const prefix = "x".repeat(1950); + const text = `${prefix}\n\n${table}`; + expect(text.length).toBeGreaterThan(2000); + const chunks = chunkDiscordContentPreservingTables(text, 2000); + expect(chunks.length).toBeGreaterThan(1); + const withTable = chunks.find((chunk) => chunk.includes(".----+----.")); + expect(withTable).toBeDefined(); + expect(withTable).toContain("'----+----'"); + expect(withTable).toContain("```"); + // Fence must stay contiguous in one chunk. + expect(withTable?.indexOf("```")).toBeLessThan(withTable!.lastIndexOf("```")); + }); +}); diff --git a/apps/discord-bot/src/presentation/asciiTables.ts b/apps/discord-bot/src/presentation/asciiTables.ts new file mode 100644 index 00000000000..e0bcdd287e1 --- /dev/null +++ b/apps/discord-bot/src/presentation/asciiTables.ts @@ -0,0 +1,838 @@ +/** + * Convert GFM pipe tables into aligned ASCII tables for Discord. + * Discord does not render markdown tables, so monospace box drawing is required. + */ + +export interface TableMatch { + /** Inclusive start offset of the matched source span. */ + readonly start: number; + /** Exclusive end offset of the matched source span. */ + readonly end: number; + /** Original matched text (may include surrounding code fences). */ + readonly raw: string; + readonly headers: ReadonlyArray; + readonly rows: ReadonlyArray>; +} + +export interface DiscordTableAttachment { + readonly name: string; + readonly body: string; +} + +export interface RewriteMarkdownTablesResult { + readonly text: string; + readonly attachments: ReadonlyArray; +} + +export type AsciiTableStyle = "rounded" | "mysql"; + +/** Per-column wrap width before word-break. Kept modest for Discord code blocks. */ +const DEFAULT_MAX_COL_WIDTH = 40; +/** + * Hard cap on a single rendered table line (borders included). + * Wider lines soft-wrap in Discord clients and destroy alignment. + */ +const DEFAULT_MAX_TABLE_WIDTH = 72; +const DEFAULT_MESSAGE_LIMIT = 2000; + +/** Split a markdown table row into cells on unescaped `|`. */ +export function splitTableCells(line: string): string[] { + let body = line.trim(); + if (body.startsWith("|")) body = body.slice(1); + if (body.endsWith("|")) body = body.slice(0, -1); + + const cells: string[] = []; + let current = ""; + let escaped = false; + for (const char of body) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "|") { + cells.push(unescapeTableCell(current.trim())); + current = ""; + continue; + } + current += char; + } + cells.push(unescapeTableCell(current.trim())); + return cells; +} + +function unescapeTableCell(value: string): string { + return value + .replace(/\\([\\|`*_{}[\]()#+\-.!])/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +/** True when every cell looks like a GFM separator segment (`---`, `:---:`, etc.). */ +export function isSeparatorRow(cells: ReadonlyArray): boolean { + if (cells.length === 0) return false; + return cells.every((cell) => /^:?-{1,}:?$/.test(cell.replace(/\s+/g, ""))); +} + +function isTableRowLine(line: string): boolean { + const trimmed = line.trim(); + if (trimmed.length === 0) return false; + // Require a pipe that actually separates content (not a lone `|`). + if (!trimmed.includes("|")) return false; + // Reject pure fence markers. + if (/^```/.test(trimmed)) return false; + return true; +} + +function normalizeRow(cells: ReadonlyArray, columnCount: number): string[] { + const row = cells.slice(0, columnCount).map((cell) => cell); + while (row.length < columnCount) row.push(""); + return row; +} + +function tryParseTableLines( + lines: ReadonlyArray, + startIndex: number, +): { + readonly endIndex: number; + readonly headers: string[]; + readonly rows: string[][]; +} | null { + if (startIndex + 1 >= lines.length) return null; + const headerLine = lines[startIndex] ?? ""; + const separatorLine = lines[startIndex + 1] ?? ""; + if (!isTableRowLine(headerLine) || !isTableRowLine(separatorLine)) return null; + + const headers = splitTableCells(headerLine); + const separatorCells = splitTableCells(separatorLine); + if (headers.length === 0 || !isSeparatorRow(separatorCells)) return null; + // Require ≥2 columns so bash/prose like `| note` + `|---` is not treated as a table. + if (headers.length < 2 || separatorCells.length < 2) return null; + // Separator column count should roughly match the header (allow off-by-one). + if (Math.abs(headers.length - separatorCells.length) > 1) return null; + + const columnCount = Math.max(headers.length, separatorCells.length); + const normalizedHeaders = normalizeRow(headers, columnCount); + const rows: string[][] = []; + let endIndex = startIndex + 1; + + for (let index = startIndex + 2; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (!isTableRowLine(line)) break; + const cells = splitTableCells(line); + // A second separator row ends the table (defensive). + if (isSeparatorRow(cells)) break; + // Blank-looking pipe rows still count as data. + rows.push(normalizeRow(cells, columnCount)); + endIndex = index; + } + + if (rows.length === 0) return null; + return { endIndex, headers: normalizedHeaders, rows }; +} + +/** + * Find GFM pipe tables in `text`. + * Also matches tables wrapped in fenced code blocks (``` / ```bash / etc.). + */ +export function extractMarkdownTables(text: string): TableMatch[] { + const matches: TableMatch[] = []; + const lineStarts: number[] = [0]; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === "\n") lineStarts.push(index + 1); + } + const lines = text.split(/\r?\n/); + + let lineIndex = 0; + while (lineIndex < lines.length) { + const line = lines[lineIndex] ?? ""; + const fenceOpen = line.match(/^(\s*)```([\w+-]*)\s*$/); + if (fenceOpen) { + const openIndex = lineIndex; + let closeIndex = -1; + for (let probe = lineIndex + 1; probe < lines.length; probe += 1) { + if (/^\s*```\s*$/.test(lines[probe] ?? "")) { + closeIndex = probe; + break; + } + } + if (closeIndex === -1) { + lineIndex += 1; + continue; + } + + const innerStart = openIndex + 1; + // Skip leading blank lines inside the fence. + let tableStart = innerStart; + while (tableStart < closeIndex && (lines[tableStart] ?? "").trim() === "") { + tableStart += 1; + } + const parsed = tryParseTableLines(lines, tableStart); + if (parsed !== null) { + // Trailing blank lines after the table before the closing fence are ok. + let afterTable = parsed.endIndex + 1; + while (afterTable < closeIndex && (lines[afterTable] ?? "").trim() === "") { + afterTable += 1; + } + if (afterTable === closeIndex) { + const start = lineStarts[openIndex] ?? 0; + const exclusiveEnd = lineEndExclusive(text, lineStarts, lines, closeIndex); + matches.push({ + start, + end: exclusiveEnd, + raw: text.slice(start, exclusiveEnd), + headers: parsed.headers, + rows: parsed.rows, + }); + lineIndex = closeIndex + 1; + continue; + } + } + lineIndex = closeIndex + 1; + continue; + } + + const parsed = tryParseTableLines(lines, lineIndex); + if (parsed === null) { + lineIndex += 1; + continue; + } + + const start = lineStarts[lineIndex] ?? 0; + const exclusiveEnd = lineEndExclusive(text, lineStarts, lines, parsed.endIndex); + matches.push({ + start, + end: exclusiveEnd, + raw: text.slice(start, exclusiveEnd), + headers: parsed.headers, + rows: parsed.rows, + }); + lineIndex = parsed.endIndex + 1; + } + + return matches; +} + +/** True when `text` contains at least one GFM pipe table. */ +export function hasMarkdownTables(text: string): boolean { + return extractMarkdownTables(text).length > 0; +} + +/** Exclusive end offset for `lineIndex`, including its trailing newline when present. */ +function lineEndExclusive( + text: string, + lineStarts: ReadonlyArray, + lines: ReadonlyArray, + lineIndex: number, +): number { + const start = lineStarts[lineIndex] ?? text.length; + const line = lines[lineIndex] ?? ""; + let exclusiveEnd = start + line.length; + if (exclusiveEnd < text.length && (text[exclusiveEnd] === "\n" || text[exclusiveEnd] === "\r")) { + exclusiveEnd = + text[exclusiveEnd] === "\r" && text[exclusiveEnd + 1] === "\n" + ? exclusiveEnd + 2 + : exclusiveEnd + 1; + } + return exclusiveEnd; +} + +/** Wrap cell text to maxWidth, preferring spaces then hyphens over hard cuts. */ +export function wrapCellText(text: string, maxWidth: number): string[] { + const width = Math.max(1, maxWidth); + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length === 0) return [""]; + if (normalized.length <= width) return [normalized]; + + const lines: string[] = []; + let remaining = normalized; + while (remaining.length > width) { + let breakAt = remaining.lastIndexOf(" ", width); + if (breakAt <= 0) { + // Soft-break filenames / dotted identifiers on '-' or '_' / '.'. + const hyphen = remaining.lastIndexOf("-", width); + const under = remaining.lastIndexOf("_", width); + const dot = remaining.lastIndexOf(".", width); + breakAt = Math.max(hyphen, under, dot); + if (breakAt <= 0) breakAt = width; + else breakAt += 1; // keep the separator on the left line + } + lines.push(remaining.slice(0, breakAt).trimEnd()); + remaining = remaining.slice(breakAt).trimStart(); + } + if (remaining.length > 0) lines.push(remaining); + return lines.length > 0 ? lines : [""]; +} + +/** Longest token that prefers not to wrap (whole cell if no spaces). */ +function preferredMinCellWidth(cell: string, maxColWidth: number): number { + const normalized = cell.replace(/\s+/g, " ").trim(); + if (normalized.length === 0) return 1; + if (!/\s/.test(normalized)) { + return Math.min(maxColWidth, normalized.length); + } + let longest = 1; + for (const word of normalized.split(" ")) { + longest = Math.max(longest, Math.min(maxColWidth, word.length)); + } + return longest; +} + +function padCell(text: string, width: number, align: "left" | "right" = "left"): string { + const clipped = text.length > width ? text.slice(0, width) : text; + if (align === "right") { + return clipped.padStart(width, " "); + } + return clipped.padEnd(width, " "); +} + +function looksNumeric(value: string): boolean { + if (value.trim() === "") return false; + // Numbers, optional thousands separators, decimals, leading sign. + return /^-?[\d,]+(?:\.\d+)?%?$/.test(value.trim()); +} + +function columnAlignments( + headers: ReadonlyArray, + rows: ReadonlyArray>, +): Array<"left" | "right"> { + return headers.map((_, col) => { + const values = rows.map((row) => row[col] ?? "").filter((value) => value.trim() !== ""); + if (values.length === 0) return "left"; + return values.every(looksNumeric) ? "right" : "left"; + }); +} + +/** Total monospace width of a table line for the given column widths. */ +export function tableLineWidth(widths: ReadonlyArray): number { + if (widths.length === 0) return 0; + // `| ${cell} | ${cell} |` → sum(width + 2) + (n + 1) pipe chars = sum(widths) + 3n + 1 + return widths.reduce((sum, width) => sum + width, 0) + 3 * widths.length + 1; +} + +/** + * Shrink column widths so the full table line stays within maxTableWidth. + * Prefer shrinking prose columns (spaces) before identifier columns. + */ +export function fitColumnWidthsToTableWidth( + widths: ReadonlyArray, + maxTableWidth: number, + minWidths?: ReadonlyArray, +): number[] { + const next = widths.map((width) => Math.max(1, width)); + if (next.length === 0) return next; + const mins = + minWidths?.map((width, index) => Math.max(1, Math.min(next[index] ?? 1, width))) ?? + next.map(() => 1); + // Minimum usable line width with mins (or 1s). + const minLine = tableLineWidth(mins); + const budget = Math.max(minLine, maxTableWidth); + + while (tableLineWidth(next) > budget) { + // Prefer columns that are above their preferred min, then the widest. + let victim = -1; + for (let index = 0; index < next.length; index += 1) { + const width = next[index] ?? 1; + const floor = mins[index] ?? 1; + if (width <= floor) continue; + if ( + victim === -1 || + width > (next[victim] ?? 0) || + (width === (next[victim] ?? 0) && floor < (mins[victim] ?? 1)) + ) { + victim = index; + } + } + if (victim === -1) { + // Forced below preferred mins to meet budget. + let longest = 0; + for (let index = 1; index < next.length; index += 1) { + if ((next[index] ?? 0) > (next[longest] ?? 0)) longest = index; + } + if ((next[longest] ?? 1) <= 1) break; + next[longest] = (next[longest] ?? 1) - 1; + continue; + } + next[victim] = (next[victim] ?? 1) - 1; + } + return next; +} + +function computeColumnWidths( + headers: ReadonlyArray, + rows: ReadonlyArray>, + maxColWidth: number, + maxTableWidth = DEFAULT_MAX_TABLE_WIDTH, +): number[] { + const columnCount = headers.length; + const widths = Array.from({ length: columnCount }, () => 1); + const minWidths = Array.from({ length: columnCount }, () => 1); + + const consider = (cell: string, col: number) => { + minWidths[col] = Math.max(minWidths[col] ?? 1, preferredMinCellWidth(cell, maxColWidth)); + for (const line of wrapCellText(cell, maxColWidth)) { + widths[col] = Math.min(maxColWidth, Math.max(widths[col] ?? 1, line.length)); + } + }; + + for (let col = 0; col < columnCount; col += 1) { + consider(headers[col] ?? "", col); + } + for (const row of rows) { + for (let col = 0; col < columnCount; col += 1) { + consider(row[col] ?? "", col); + } + } + // Ideal width is at least the preferred min (e.g. full filename). + for (let col = 0; col < columnCount; col += 1) { + widths[col] = Math.max(widths[col] ?? 1, minWidths[col] ?? 1); + } + return fitColumnWidthsToTableWidth(widths, maxTableWidth, minWidths); +} + +function mysqlBorder(widths: ReadonlyArray, junction: "+" = "+"): string { + return `${junction}${widths.map((width) => "-".repeat(width + 2)).join(junction)}${junction}`; +} + +/** + * Classic MySQL CLI box table (`+---+`, `|`, separator after header and each row group). + */ +export function renderMysqlTable( + headers: ReadonlyArray, + rows: ReadonlyArray>, + maxColWidth = DEFAULT_MAX_COL_WIDTH, + maxTableWidth = DEFAULT_MAX_TABLE_WIDTH, +): string { + if (headers.length === 0) return ""; + const widths = computeColumnWidths(headers, rows, maxColWidth, maxTableWidth); + const alignments = columnAlignments(headers, rows); + // Wrap against the fitted width per column (not the pre-fit maxColWidth). + const wrapWidths = widths.map((width) => Math.min(maxColWidth, width)); + const top = mysqlBorder(widths, "+"); + const mid = mysqlBorder(widths, "+"); + const out: string[] = [top]; + out.push(...buildRowLinesFitted(headers, widths, alignments, wrapWidths)); + out.push(mid); + for (const row of rows) { + out.push(...buildRowLinesFitted(row, widths, alignments, wrapWidths)); + out.push(mid); + } + // Last mid is the bottom border — already correct with +---+ style. + return out.join("\n"); +} + +function buildRowLinesFitted( + cells: ReadonlyArray, + widths: ReadonlyArray, + alignments: ReadonlyArray<"left" | "right">, + wrapWidths: ReadonlyArray, +): string[] { + const wrapped = cells.map((cell, index) => wrapCellText(cell, wrapWidths[index] ?? 1)); + const height = Math.max(1, ...wrapped.map((lines) => lines.length)); + const lines: string[] = []; + for (let rowLine = 0; rowLine < height; rowLine += 1) { + const parts = widths.map((width, col) => { + const text = wrapped[col]?.[rowLine] ?? ""; + return ` ${padCell(text, width, alignments[col] ?? "left")} `; + }); + lines.push(`|${parts.join("|")}|`); + } + return lines; +} + +function roundedTop(widths: ReadonlyArray): string { + return `.${widths.map((width) => "-".repeat(width + 2)).join(".")}.`; +} + +function roundedBottom(widths: ReadonlyArray): string { + return `'${widths.map((width) => "-".repeat(width + 2)).join("'")}'`; +} + +function roundedSeparator(widths: ReadonlyArray): string { + return `:${widths.map((width) => "-".repeat(width + 2)).join("+")}:`; +} + +/** + * Rounded ASCII table matching the Discord-friendly style: + * top `.---.`, separators `:---+---:` after every row, bottom `'---'`. + */ +export function renderRoundedTable( + headers: ReadonlyArray, + rows: ReadonlyArray>, + maxColWidth = DEFAULT_MAX_COL_WIDTH, + maxTableWidth = DEFAULT_MAX_TABLE_WIDTH, +): string { + if (headers.length === 0) return ""; + const widths = computeColumnWidths(headers, rows, maxColWidth, maxTableWidth); + const alignments = columnAlignments(headers, rows); + const wrapWidths = widths.map((width) => Math.min(maxColWidth, width)); + const out: string[] = [roundedTop(widths)]; + out.push(...buildRowLinesFitted(headers, widths, alignments, wrapWidths)); + out.push(roundedSeparator(widths)); + for (let index = 0; index < rows.length; index += 1) { + out.push(...buildRowLinesFitted(rows[index] ?? [], widths, alignments, wrapWidths)); + if (index < rows.length - 1) { + out.push(roundedSeparator(widths)); + } + } + out.push(roundedBottom(widths)); + return out.join("\n"); +} + +function fenceTable(body: string): string { + return `\`\`\`\n${body}\n\`\`\``; +} + +function renderTableBody( + style: AsciiTableStyle, + headers: ReadonlyArray, + rows: ReadonlyArray>, + maxColWidth: number, + maxTableWidth: number, +): string { + return style === "mysql" + ? renderMysqlTable(headers, rows, maxColWidth, maxTableWidth) + : renderRoundedTable(headers, rows, maxColWidth, maxTableWidth); +} + +/** True when any cell would wrap at maxColWidth (long text → prefer one-row tables). */ +export function tableHasLongCells( + headers: ReadonlyArray, + rows: ReadonlyArray>, + maxColWidth: number, +): boolean { + for (const cell of headers) { + if (cell.length > maxColWidth) return true; + } + for (const row of rows) { + for (const cell of row) { + if (cell.length > maxColWidth) return true; + } + } + return false; +} + +/** + * Render a markdown table as one or more fenced ASCII tables for Discord. + * Prefer a **single** table with all rows (long cells wrap in place). + * Only split into multiple tables when the fenced body exceeds messageLimit; + * never split merely because cells are long. + * A single row that still cannot fit becomes a .txt attachment body. + */ +export function splitTableIntoDiscordBodies( + headers: ReadonlyArray, + rows: ReadonlyArray>, + options?: { + readonly style?: AsciiTableStyle; + readonly maxColWidth?: number; + readonly maxTableWidth?: number; + readonly messageLimit?: number; + }, +): { + readonly fencedChunks: ReadonlyArray; + /** Unfenced bodies that could not fit even as a single-row table. */ + readonly oversizedBodies: ReadonlyArray; +} { + const style = options?.style ?? "rounded"; + const maxColWidth = options?.maxColWidth ?? DEFAULT_MAX_COL_WIDTH; + const maxTableWidth = options?.maxTableWidth ?? DEFAULT_MAX_TABLE_WIDTH; + const messageLimit = options?.messageLimit ?? DEFAULT_MESSAGE_LIMIT; + + if (headers.length === 0 || rows.length === 0) { + return { fencedChunks: [], oversizedBodies: [] }; + } + + // Keep all rows in one table when possible; packRowsIntoGroups only splits + // if the full fenced table exceeds messageLimit. + const groups = packRowsIntoGroups(headers, rows, style, maxColWidth, maxTableWidth, messageLimit); + + const tableBodies: string[] = []; + const oversizedBodies: string[] = []; + + for (const group of groups) { + const body = renderTableBody(style, headers, group, maxColWidth, maxTableWidth); + if (fenceTable(body).length <= messageLimit) { + tableBodies.push(body); + continue; + } + // Group still too big (usually a single huge row). Attach unfenced body. + if (group.length === 1) { + oversizedBodies.push(body); + continue; + } + // Fall back to per-row only when a multi-row pack still overflows (edge case). + for (const row of group) { + const rowBody = renderTableBody(style, headers, [row], maxColWidth, maxTableWidth); + if (fenceTable(rowBody).length <= messageLimit) { + tableBodies.push(rowBody); + } else { + oversizedBodies.push(rowBody); + } + } + } + + // Pack any size-split tables into as few fences as possible. + const fencedChunks = packTableBodiesIntoFences(tableBodies, messageLimit); + return { fencedChunks, oversizedBodies }; +} + +/** + * Join unfenced ASCII tables into fenced code blocks under messageLimit. + * Prefer one fence containing several tables over adjacent fences. + */ +export function packTableBodiesIntoFences( + bodies: ReadonlyArray, + messageLimit: number, +): string[] { + const fencedChunks: string[] = []; + let pack: string[] = []; + + const flush = () => { + if (pack.length === 0) return; + fencedChunks.push(fenceTable(pack.join("\n\n"))); + pack = []; + }; + + for (const body of bodies) { + const solo = fenceTable(body); + if (solo.length > messageLimit) { + // Caller should have filtered these; skip rather than emit an oversize fence. + flush(); + continue; + } + if (pack.length === 0) { + pack = [body]; + continue; + } + const combined = fenceTable([...pack, body].join("\n\n")); + if (combined.length > messageLimit) { + flush(); + pack = [body]; + continue; + } + pack.push(body); + } + flush(); + return fencedChunks; +} + +/** + * Join multiple fenced chunks for Discord without adjacent-fence glitches. + * A zero-width space on its own line forces Discord to close/reopen snippets cleanly. + */ +export function joinFencedTableChunks(chunks: ReadonlyArray): string { + if (chunks.length === 0) return ""; + if (chunks.length === 1) return chunks[0] ?? ""; + // ZWSP between fences: Discord often drops subsequent code blocks when they + // sit back-to-back with only blank lines between them. + return chunks.join("\n\n\u200B\n\n"); +} + +function packRowsIntoGroups( + headers: ReadonlyArray, + rows: ReadonlyArray>, + style: AsciiTableStyle, + maxColWidth: number, + maxTableWidth: number, + messageLimit: number, +): Array>> { + const groups: Array>> = []; + let current: Array> = []; + + for (const row of rows) { + const candidate = [...current, row]; + const fenced = fenceTable( + renderTableBody(style, headers, candidate, maxColWidth, maxTableWidth), + ); + if (current.length > 0 && fenced.length > messageLimit) { + groups.push(current); + current = [row]; + continue; + } + current = candidate; + } + if (current.length > 0) groups.push(current); + return groups; +} + +/** + * Replace markdown pipe tables with fenced ASCII tables. + * Prefer one table; only rows/tables that still exceed the Discord limit become + * .txt attachments. Attachment names follow document reading order. + */ +export function rewriteMarkdownTablesForDiscord( + text: string, + options?: { + readonly style?: AsciiTableStyle; + readonly maxColWidth?: number; + readonly maxTableWidth?: number; + /** Soft limit for a single fenced table; over this attaches as .txt. */ + readonly messageLimit?: number; + }, +): RewriteMarkdownTablesResult { + const style = options?.style ?? "rounded"; + const maxColWidth = options?.maxColWidth ?? DEFAULT_MAX_COL_WIDTH; + const maxTableWidth = options?.maxTableWidth ?? DEFAULT_MAX_TABLE_WIDTH; + const messageLimit = options?.messageLimit ?? DEFAULT_MESSAGE_LIMIT; + const matches = extractMarkdownTables(text); + if (matches.length === 0) { + return { text, attachments: [] }; + } + + // Collect attachments in document order; name by reading order (not reverse-replace order). + const attachmentsByKey = new Map(); + let out = text; + // Replace from the end so earlier offsets stay valid. + for (let index = matches.length - 1; index >= 0; index -= 1) { + const match = matches[index]!; + const { fencedChunks, oversizedBodies } = splitTableIntoDiscordBodies( + match.headers, + match.rows, + { style, maxColWidth, maxTableWidth, messageLimit }, + ); + + const parts: string[] = []; + if (fencedChunks.length > 0) { + parts.push(joinFencedTableChunks(fencedChunks)); + } + for (let bodyIndex = 0; bodyIndex < oversizedBodies.length; bodyIndex += 1) { + const body = oversizedBodies[bodyIndex]!; + const name = oversizedTableAttachmentName({ + matchIndex: index, + bodyIndex, + matchCount: matches.length, + oversizedBodyCount: oversizedBodies.length, + hasFencedChunks: fencedChunks.length > 0, + }); + // Last write wins only if duplicate names; keys are unique per match/body. + attachmentsByKey.set(`${index}:${bodyIndex}:${name}`, { name, body }); + parts.push(`_(Table attached as \`${name}\`)_`); + } + + const replacement = parts.join("\n\n"); + out = `${out.slice(0, match.start)}${replacement}${out.slice(match.end)}`; + } + + // Reading order: earlier match index first, then body index within the match. + const attachments = [...attachmentsByKey.entries()] + .toSorted(([left], [right]) => left.localeCompare(right, undefined, { numeric: true })) + .map(([, attachment]) => attachment); + + return { text: out, attachments }; +} + +/** Stable attachment names in document reading order (table-1 = first table in message). */ +export function oversizedTableAttachmentName(input: { + readonly matchIndex: number; + readonly bodyIndex: number; + readonly matchCount: number; + readonly oversizedBodyCount: number; + readonly hasFencedChunks: boolean; +}): string { + const { matchIndex, bodyIndex, matchCount, oversizedBodyCount, hasFencedChunks } = input; + if (matchCount === 1 && oversizedBodyCount === 1 && !hasFencedChunks) { + return "table.txt"; + } + if (matchCount === 1) { + return `table-${bodyIndex + 1}.txt`; + } + if (oversizedBodyCount === 1) { + return `table-${matchIndex + 1}.txt`; + } + return `table-${matchIndex + 1}-${bodyIndex + 1}.txt`; +} + +/** + * Chunk Discord content without splitting inside fenced code blocks (including ASCII tables) + * and without splitting mid-line of a table row when possible. + */ +export function chunkDiscordContentPreservingTables( + content: string, + limit = DEFAULT_MESSAGE_LIMIT, +): string[] { + const trimmed = content.trimEnd(); + if (trimmed.length === 0) return [""]; + if (trimmed.length <= limit) return [trimmed]; + + const segments = splitPreservingFences(trimmed); + const chunks: string[] = []; + let current = ""; + + const flush = () => { + if (current.trim().length > 0) { + chunks.push(current.trimEnd()); + } + current = ""; + }; + + for (const segment of segments) { + if (segment.length > limit) { + // Oversized segment (should be rare after table attachment). Split on lines. + flush(); + let remaining = segment; + while (remaining.length > limit) { + let splitAt = remaining.lastIndexOf("\n", limit); + if (splitAt < Math.floor(limit * 0.5)) splitAt = limit; + chunks.push(remaining.slice(0, splitAt).trimEnd()); + remaining = remaining.slice(splitAt).replace(/^\n/, ""); + } + if (remaining.trim().length > 0) current = remaining; + continue; + } + + const separator = current.length > 0 ? "\n" : ""; + if (current.length + separator.length + segment.length <= limit) { + current = current.length > 0 ? `${current}\n${segment}` : segment; + continue; + } + flush(); + current = segment; + } + flush(); + return chunks.length > 0 ? chunks : [""]; +} + +/** Split text into fence blocks and non-fence blocks (each non-fence further by blank lines). */ +function splitPreservingFences(text: string): string[] { + const lines = text.split(/\r?\n/); + const segments: string[] = []; + let buffer: string[] = []; + let inFence = false; + + const flushBuffer = () => { + if (buffer.length === 0) return; + const block = buffer.join("\n"); + if (inFence) { + segments.push(block); + } else { + // Split non-fence prose on blank lines for better chunk boundaries. + const paragraphs = block.split(/\n{2,}/); + for (const paragraph of paragraphs) { + if (paragraph.length > 0) segments.push(paragraph); + } + } + buffer = []; + }; + + for (const line of lines) { + if (/^\s*```/.test(line)) { + if (!inFence) { + flushBuffer(); + inFence = true; + buffer.push(line); + } else { + buffer.push(line); + flushBuffer(); + inFence = false; + } + continue; + } + buffer.push(line); + } + flushBuffer(); + return segments; +} diff --git a/apps/discord-bot/src/presentation/attachments.test.ts b/apps/discord-bot/src/presentation/attachments.test.ts new file mode 100644 index 00000000000..58eb4933064 --- /dev/null +++ b/apps/discord-bot/src/presentation/attachments.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendT3DeepLinkToChunks, + attachmentKey, + buildStreamHistoryMarkdownText, + imageAttachmentsOf, + STREAM_HISTORY_MARKDOWN_NAME, + streamHistoryHasAdditionalContent, + unpostedAttachments, + withT3DeepLink, +} from "./attachments.ts"; + +describe("imageAttachmentsOf", () => { + it("filters to image attachments", () => { + const images = imageAttachmentsOf([ + { + type: "image", + id: "a1", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 12, + }, + ]); + expect(images).toHaveLength(1); + expect(images[0]?.id).toBe("a1"); + }); + + it("returns empty for missing attachments", () => { + expect(imageAttachmentsOf(undefined)).toEqual([]); + expect(imageAttachmentsOf(null)).toEqual([]); + expect(imageAttachmentsOf([])).toEqual([]); + }); +}); + +describe("unpostedAttachments", () => { + it("skips already posted ids", () => { + const all = [ + { + type: "image" as const, + id: "a1", + name: "a.png", + mimeType: "image/png", + sizeBytes: 1, + }, + { + type: "image" as const, + id: "a2", + name: "b.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ]; + expect(unpostedAttachments(all, ["a1"]).map((e) => e.id)).toEqual(["a2"]); + expect(attachmentKey(all)).toBe("a1,a2"); + }); +}); + +describe("streamHistoryHasAdditionalContent", () => { + it("is false when history equals the final message (no intermediate tips)", () => { + const body = "👍 Sounds good. Ping this thread anytime."; + expect(streamHistoryHasAdditionalContent(body, body)).toBe(false); + expect(streamHistoryHasAdditionalContent(` ${body}\n`, body)).toBe(false); + }); + + it("is false for empty or placeholder stream bodies", () => { + expect(streamHistoryHasAdditionalContent("", "final answer")).toBe(false); + expect(streamHistoryHasAdditionalContent(" \n", "final answer")).toBe(false); + expect(streamHistoryHasAdditionalContent("…", "final answer")).toBe(false); + }); + + it("is true when history has intermediate progress beyond the final answer", () => { + expect( + streamHistoryHasAdditionalContent( + "Checking PR…\n\n👍 Sounds good. Ping this thread anytime.", + "👍 Sounds good. Ping this thread anytime.", + ), + ).toBe(true); + }); +}); + +describe("buildStreamHistoryMarkdownText", () => { + it("archives non-empty stream text", () => { + const text = buildStreamHistoryMarkdownText("Working…\npartial answer"); + expect(text).not.toBeNull(); + expect(text).toContain("In-progress stream"); + expect(text).toContain("partial answer"); + expect(STREAM_HISTORY_MARKDOWN_NAME).toBe("stream-history.md"); + }); + + it("returns null for blank stream text", () => { + expect(buildStreamHistoryMarkdownText(" \n")).toBeNull(); + }); +}); + +describe("T3 deep link caption helpers", () => { + it("appends a short same-line T3 link on the stats footer", () => { + expect( + withT3DeepLink( + "_`grok-4.5` · effort high · 50s_", + "https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1", + ), + ).toBe( + "_`grok-4.5` · effort high · 50s_ · [T3](https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1)", + ); + expect(withT3DeepLink("Summary", null)).toBe("Summary"); + }); + + it("appends the link onto the last chunk without overflowing", () => { + const url = "https://t3vm.example/?thread=t#message-m"; + expect(appendT3DeepLinkToChunks(["hello"], url, 2000)).toEqual([`hello · [T3](${url})`]); + const almostFull = "x".repeat(1990); + const next = appendT3DeepLinkToChunks([almostFull], url, 2000); + expect(next).toHaveLength(2); + expect(next[1]).toBe(`[T3](${url})`); + }); +}); diff --git a/apps/discord-bot/src/presentation/attachments.ts b/apps/discord-bot/src/presentation/attachments.ts new file mode 100644 index 00000000000..a2c0a650384 --- /dev/null +++ b/apps/discord-bot/src/presentation/attachments.ts @@ -0,0 +1,109 @@ +import type { ChatAttachment, ChatImageAttachment } from "@t3tools/contracts"; + +/** Discord allows up to 10 files per message. */ +export const DISCORD_MAX_FILES_PER_MESSAGE = 10; + +/** + * Filename for the archived in-progress stream (hidden after finalize). + * The final answer itself is posted as normal Discord message content. + */ +export const STREAM_HISTORY_MARKDOWN_NAME = "stream-history.md"; + +export function imageAttachmentsOf( + attachments: ReadonlyArray | null | undefined, +): ReadonlyArray { + if (attachments === undefined || attachments === null) return []; + return attachments.filter((entry): entry is ChatImageAttachment => entry.type === "image"); +} + +export function unpostedAttachments( + attachments: ReadonlyArray, + postedIds: ReadonlyArray, +): ReadonlyArray { + const posted = new Set(postedIds); + return attachments.filter((entry) => !posted.has(entry.id)); +} + +export function attachmentKey(attachments: ReadonlyArray): string { + return attachments.map((entry) => entry.id).join(","); +} + +/** + * True when the archived stream body has intermediate progress beyond the final answer. + * Skip `stream-history.md` when the tip body is empty/placeholder or equals the final post + * (single-bubble turns where message content already is the stream). + */ +export function streamHistoryHasAdditionalContent(historyText: string, finalText: string): boolean { + const history = historyText.trim(); + if (history === "" || history === "…") return false; + return history !== finalText.trim(); +} + +/** + * Archive the in-progress stream as markdown text for a real Discord file attachment. + * Final answer stays in Discord message content (chunked if needed). + */ +export function buildStreamHistoryMarkdownText(streamText: string): string | null { + const body = streamText.trimEnd(); + if (body.trim() === "") return null; + return [ + "# In-progress stream", + "", + "_Live tip updates from this turn (archived when the final answer was posted)._", + "", + body, + "", + ].join("\n"); +} + +/** @deprecated Use buildStreamHistoryMarkdownText + Discord multipart upload */ +export function buildStreamHistoryMarkdownFile(streamText: string): File | null { + const text = buildStreamHistoryMarkdownText(streamText); + if (text === null) return null; + return new File([text], STREAM_HISTORY_MARKDOWN_NAME, { + type: "text/markdown;charset=utf-8", + }); +} + +/** + * Append a compact T3 deep link on a Discord caption/chunk. + * Same-line ` · [T3](url)` — clickable Discord markdown, short label. + * Used on the stats footer of every final answer. + */ +export function withT3DeepLink(caption: string, t3Url: string | null | undefined): string { + const url = t3Url?.trim() ?? ""; + if (url === "") return caption; + const link = `[T3](${url})`; + const body = caption.trimEnd(); + if (body === "") return link; + return `${body} · ${link}`; +} + +/** + * Append a T3 deep link onto the last message chunk (the stats footer), + * respecting the Discord limit. Always used on finals when a URL is available. + * If the link would overflow the last chunk, emit it as its own trailing chunk. + */ +export function appendT3DeepLinkToChunks( + chunks: ReadonlyArray, + t3Url: string | null | undefined, + limit: number, +): string[] { + const url = t3Url?.trim() ?? ""; + if (url === "" || chunks.length === 0) return [...chunks]; + + const out = [...chunks]; + const lastIndex = out.length - 1; + const last = out[lastIndex] ?? ""; + const linked = withT3DeepLink(last, url); + if (linked.length <= limit) { + out[lastIndex] = linked; + return out; + } + + const solo = withT3DeepLink("", url); + if (solo.length <= limit) { + out.push(solo); + } + return out; +} diff --git a/apps/discord-bot/src/presentation/channelInfoPin.test.ts b/apps/discord-bot/src/presentation/channelInfoPin.test.ts new file mode 100644 index 00000000000..af932be3d0a --- /dev/null +++ b/apps/discord-bot/src/presentation/channelInfoPin.test.ts @@ -0,0 +1,156 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + CHANNEL_INFO_PIN_MARKER, + DISCORD_MESSAGE_CONTENT_LIMIT, + renderChannelInfoPin, +} from "./channelInfoPin.ts"; +import { normalizeGitHubRemoteUrl } from "./githubLinks.ts"; + +describe("channel info pin helpers", () => { + it("normalizes GitHub SSH remotes", () => { + expect(normalizeGitHubRemoteUrl("git@github.com:pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + expect(normalizeGitHubRemoteUrl("ssh://git@github.com/pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + }); + + it("normalizes GitHub HTTPS remotes", () => { + expect(normalizeGitHubRemoteUrl("https://github.com/pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + }); + + it("rejects non-GitHub remotes", () => { + expect(normalizeGitHubRemoteUrl("https://gitlab.com/pingdotgg/t3code.git")).toBeNull(); + }); + + it("renders a fallback when the origin GitHub URL cannot be resolved", () => { + const rendered = renderChannelInfoPin({ + githubUrl: null, + workspaceRoot: "/tmp/t3code", + providers: [], + defaultModelSelection: null, + }); + + expect(rendered).toContain("GitHub: (unable to resolve origin GitHub URL)"); + expect(rendered).not.toContain("origin remote is not a GitHub repository"); + }); + + it("renders a stable pinned help message", () => { + const rendered = renderChannelInfoPin({ + githubUrl: "https://github.com/pingdotgg/t3code", + workspaceRoot: "/tmp/t3code", + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + enabled: true, + installed: true, + status: "ready", + availability: "available", + models: [ + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: null }, + { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, + ], + }, + { + instanceId: ProviderInstanceId.make("grok"), + enabled: true, + installed: false, + status: "disabled", + availability: "unavailable", + models: [{ slug: "grok-build", name: "Grok Build", isCustom: false, capabilities: null }], + }, + ], + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + }); + + expect(rendered).toContain(CHANNEL_INFO_PIN_MARKER); + expect(rendered).toContain("https://github.com/pingdotgg/t3code"); + expect(rendered).toContain("/tmp/t3code"); + expect(rendered).toContain("Bot commands (prefer **/omegent**, alias **/agent**):"); + expect(rendered).toContain("/omegent ask prompt:…"); + expect(rendered).toContain("/omegent help"); + expect(rendered).toContain("/omegent stop"); + expect(rendered).toContain("/omegent compact"); + expect(rendered).toContain("/omegent thread-talk action:on|off|status"); + expect(rendered).toContain("/omegent link ref:"); + expect(rendered).toContain("/omegent refresh-indicators"); + expect(rendered).toContain("/omegent assign [github:login]"); + expect(rendered).toContain("@Omegent …"); + expect(rendered).toContain("Same actions (fallback)"); + expect(rendered).toContain("/omegent steernow"); + expect(rendered).toContain("--steer (inject now) --queue (park; default mid-turn)"); + expect(rendered).toContain("delete your message to cancel"); + expect(rendered).toContain("Default provider/model: `codex/gpt-5.4`"); + // Labels pad to the widest provider label ("grok [missing]"). + expect(rendered).toContain("codex gpt-5.4, gpt-5.5"); + expect(rendered).toContain("grok [missing] grok-build"); + expect(rendered.length).toBeLessThanOrEqual(DISCORD_MESSAGE_CONTENT_LIMIT); + // Prefer slash commands before mention fallback in the command block. + const askIdx = rendered.indexOf("/omegent ask prompt:"); + const mentionIdx = rendered.indexOf("@Omegent …"); + expect(askIdx).toBeGreaterThan(-1); + expect(mentionIdx).toBeGreaterThan(askIdx); + }); + + it("keeps pin content within Discord's 2000-character limit for large model lists", () => { + const manyModels = Array.from({ length: 80 }, (_, index) => ({ + slug: `vendor/very-long-model-slug-name-${index}`, + name: `Model ${index}`, + isCustom: false, + capabilities: null, + })); + const rendered = renderChannelInfoPin({ + githubUrl: "https://github.com/pingdotgg/t3code", + workspaceRoot: "/var/lib/t3/src/t3code", + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + enabled: true, + installed: true, + status: "ready", + availability: "available", + models: manyModels, + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + availability: "available", + models: manyModels, + }, + { + instanceId: ProviderInstanceId.make("grok"), + enabled: true, + installed: true, + status: "ready", + availability: "available", + models: manyModels, + }, + { + instanceId: ProviderInstanceId.make("cursor"), + enabled: true, + installed: true, + status: "ready", + availability: "available", + models: manyModels, + }, + ], + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + }); + + expect(rendered.length).toBeLessThanOrEqual(DISCORD_MESSAGE_CONTENT_LIMIT); + expect(rendered).toContain(CHANNEL_INFO_PIN_MARKER); + }); +}); diff --git a/apps/discord-bot/src/presentation/channelInfoPin.ts b/apps/discord-bot/src/presentation/channelInfoPin.ts new file mode 100644 index 00000000000..e50b67fa959 --- /dev/null +++ b/apps/discord-bot/src/presentation/channelInfoPin.ts @@ -0,0 +1,202 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import type { ModelSelection, ServerProvider } from "@t3tools/contracts"; + +export { resolveGitHubUrlForWorkspace } from "./githubLinks.ts"; + +export const CHANNEL_INFO_PIN_MARKER = "Omegent Channel Info"; + +/** + * Markers the bot used before the Omegent rebrand. Pin detection matches these too so + * an existing pre-rebrand pin is found and rewritten in place instead of orphaned + * alongside a fresh one. + */ +export const LEGACY_CHANNEL_INFO_PIN_MARKERS = ["T3 Bot Channel Info"] as const; + +/** Discord hard limit for message content (pins use the same limit). */ +export const DISCORD_MESSAGE_CONTENT_LIMIT = 2000; + +function formatProviderLabel(provider: Pick): string { + return provider.instanceId; +} + +function providerStatusSuffix( + provider: Pick, +): string { + if (!provider.installed) return " [missing]"; + if (!provider.enabled) return " [off]"; + if (provider.availability === "unavailable") return " [down]"; + if (provider.status === "disabled") return " [off]"; + return ""; +} + +function shortenModelSlug(slug: string): string { + const trimmed = slug.trim(); + if (trimmed.length === 0) return trimmed; + const slashIndex = trimmed.lastIndexOf("/"); + return slashIndex >= 0 ? trimmed.slice(slashIndex + 1) : trimmed; +} + +function wrapItems( + prefix: string, + items: ReadonlyArray, + maxWidth: number, +): ReadonlyArray { + if (items.length === 0) return [`${prefix}(none)`]; + + const lines: string[] = []; + let current = prefix; + + for (const item of items) { + const separator = current === prefix ? "" : ", "; + const next = `${current}${separator}${item}`; + if (current !== prefix && next.length > maxWidth) { + lines.push(current); + current = `${" ".repeat(prefix.length)}${item}`; + continue; + } + current = next; + } + + lines.push(current); + return lines; +} + +function renderProviderLines( + providers: ReadonlyArray< + Pick< + ServerProvider, + "instanceId" | "models" | "enabled" | "installed" | "availability" | "status" + > + >, + options?: { readonly maxModelsPerProvider?: number }, +): ReadonlyArray { + if (providers.length === 0) return ["(no configured providers expose models)"]; + + const maxModels = options?.maxModelsPerProvider; + const labels = providers.map( + (provider) => `${formatProviderLabel(provider)}${providerStatusSuffix(provider)}`, + ); + const labelWidth = labels.reduce((max, label) => Math.max(max, label.length), 0); + + return providers.flatMap((provider, index) => { + const label = labels[index]!.padEnd(labelWidth, " "); + const allModels = provider.models.map((model) => shortenModelSlug(model.slug)); + const models = + maxModels === undefined || allModels.length <= maxModels + ? allModels + : [...allModels.slice(0, maxModels), `+${allModels.length - maxModels} more`]; + return wrapItems(`${label} `, models, 110); + }); +} + +function buildChannelInfoPinBody(input: { + readonly githubUrl: string | null; + readonly workspaceRoot: string; + readonly providers: ReadonlyArray< + Pick< + ServerProvider, + "instanceId" | "models" | "enabled" | "installed" | "availability" | "status" + > + >; + readonly defaultModelSelection: ModelSelection | null; + readonly maxModelsPerProvider?: number; +}): string { + const repoDirectory = NodePath.resolve(input.workspaceRoot); + const githubLine = input.githubUrl ?? "(unable to resolve origin GitHub URL)"; + const providerLines = renderProviderLines( + input.providers, + input.maxModelsPerProvider === undefined + ? {} + : { maxModelsPerProvider: input.maxModelsPerProvider }, + ); + const defaultLine = + input.defaultModelSelection === null + ? "(unable to resolve)" + : `${input.defaultModelSelection.instanceId}/${input.defaultModelSelection.model}`; + + // Keep the command block compact — every line counts against Discord's 2000 limit. + // Prefer /omegent slash commands (/agent is an alias); @Omegent mentions are a fallback. + return [ + `**${CHANNEL_INFO_PIN_MARKER}**`, + "", + `GitHub: ${githubLine}`, + "", + "Repository directory:", + "```text", + repoDirectory, + "```", + "Bot commands (prefer **/omegent**, alias **/agent**):", + "```text", + "/omegent ask prompt:… Start or continue work", + " options: model provider base local plan steer queue", + "/omegent steer prompt:… Mid-turn: inject now", + "/omegent queue prompt:… Mid-turn: park (same as default)", + "/omegent steernow Inject the whole parked queue", + "/omegent help This pin", + "/omegent stop Stop active turn", + "/omegent compact Compact context (posts token stats)", + "/omegent thread-talk action:on|off|status", + "/omegent link ref:", + "/omegent refresh-indicators", + "/omegent assign [github:login] Assign linked PR(s) (default: you)", + "@Omegent … Same actions (fallback)", + " flags: --plan --local --base --provider --model ", + " --steer (inject now) --queue (park; default mid-turn)", + " queued: 📥 badge · delete your message to cancel · /steernow to flush", + "```", + `Default provider/model: \`${defaultLine}\``, + "Supported provider/model configurations:", + "```text", + ...providerLines, + "```", + ].join("\n"); +} + +/** + * Render the channel-info pin body, always ≤ Discord's message content limit. + * Provider model lists are truncated first; hard-truncation is a last resort. + */ +export function renderChannelInfoPin(input: { + readonly githubUrl: string | null; + readonly workspaceRoot: string; + readonly providers: ReadonlyArray< + Pick< + ServerProvider, + "instanceId" | "models" | "enabled" | "installed" | "availability" | "status" + > + >; + readonly defaultModelSelection: ModelSelection | null; + readonly maxLength?: number; +}): string { + const maxLength = input.maxLength ?? DISCORD_MESSAGE_CONTENT_LIMIT; + const modelCaps = [undefined, 12, 6, 3, 1] as const; + + for (const maxModelsPerProvider of modelCaps) { + const rendered = buildChannelInfoPinBody({ + githubUrl: input.githubUrl, + workspaceRoot: input.workspaceRoot, + providers: input.providers, + defaultModelSelection: input.defaultModelSelection, + ...(maxModelsPerProvider === undefined ? {} : { maxModelsPerProvider }), + }); + if (rendered.length <= maxLength) return rendered; + } + + // Last resort: keep the header/commands and drop the provider dump. + const header = buildChannelInfoPinBody({ + githubUrl: input.githubUrl, + workspaceRoot: input.workspaceRoot, + providers: [], + defaultModelSelection: input.defaultModelSelection, + }); + const withoutProviders = header.replace( + "Supported provider/model configurations:\n```text\n(no configured providers expose models)\n```", + "Supported provider/model configurations: _(truncated — too many models for a Discord pin)_", + ); + + if (withoutProviders.length <= maxLength) return withoutProviders; + + const ellipsis = "…"; + return `${withoutProviders.slice(0, Math.max(0, maxLength - ellipsis.length))}${ellipsis}`; +} diff --git a/apps/discord-bot/src/presentation/compactContext.test.ts b/apps/discord-bot/src/presentation/compactContext.test.ts new file mode 100644 index 00000000000..abec5e1677a --- /dev/null +++ b/apps/discord-bot/src/presentation/compactContext.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + extractLatestContextWindowStats, + formatCompactionStatsReply, + hasNewContextCompaction, +} from "./compactContext.ts"; + +describe("extractLatestContextWindowStats", () => { + it("returns the newest resolvable context-window activity", () => { + const stats = extractLatestContextWindowStats([ + { + id: "a1", + kind: "context-window.updated", + payload: { usedTokens: 1000, maxTokens: 200_000 }, + }, + { id: "a2", kind: "context-compaction", payload: { state: "compacted" } }, + { + id: "a3", + kind: "context-window.updated", + payload: { usedTokens: 12_400, maxTokens: 200_000 }, + }, + ]); + expect(stats).toEqual({ + usedTokens: 12_400, + maxTokens: 200_000, + activityId: "a3", + }); + }); + + it("skips malformed rows", () => { + expect( + extractLatestContextWindowStats([ + { kind: "context-window.updated", payload: { usedTokens: -1 } }, + { kind: "context-window.updated", payload: { usedTokens: "nope" } }, + ]), + ).toBeNull(); + }); +}); + +describe("hasNewContextCompaction", () => { + it("detects compact after a marker activity", () => { + const activities = [ + { id: "w1", kind: "context-window.updated", payload: { usedTokens: 10 } }, + { id: "c1", kind: "context-compaction", payload: {} }, + ]; + expect(hasNewContextCompaction(activities, "w1")).toBe(true); + expect(hasNewContextCompaction(activities, "c1")).toBe(false); + }); +}); + +describe("formatCompactionStatsReply", () => { + it("formats a successful before → after with savings", () => { + expect( + formatCompactionStatsReply({ + before: { usedTokens: 45_200, maxTokens: 200_000, activityId: "b" }, + after: { usedTokens: 12_100, maxTokens: 200_000, activityId: "a" }, + compacted: true, + }), + ).toBe("Context compacted: **45k/200k** → **12k/200k** tokens (saved 33k, 73%)."); + }); + + it("reports errors", () => { + expect( + formatCompactionStatsReply({ + before: null, + after: null, + compacted: false, + error: "Provider grok does not support manual context compact.", + }), + ).toContain("Context compact failed:"); + }); + + it("reports pending when not compacted yet", () => { + expect( + formatCompactionStatsReply({ + before: { usedTokens: 1500, maxTokens: null, activityId: null }, + after: { usedTokens: 1500, maxTokens: null, activityId: null }, + compacted: false, + }), + ).toContain("Current window:"); + }); +}); diff --git a/apps/discord-bot/src/presentation/compactContext.ts b/apps/discord-bot/src/presentation/compactContext.ts new file mode 100644 index 00000000000..303e81d4fdd --- /dev/null +++ b/apps/discord-bot/src/presentation/compactContext.ts @@ -0,0 +1,126 @@ +/** + * Context-window / compaction stats for `/omegent compact`. + * + * Pure helpers: extract latest context-window activity, format a public Discord + * reply with before → after token counts. + */ +import { formatCompactTokenCount } from "@t3tools/shared/turnResponseStats"; + +export type ContextWindowStats = { + readonly usedTokens: number; + readonly maxTokens: number | null; + readonly activityId: string | null; +}; + +export type CompactActivityLike = { + readonly id?: string | null; + readonly kind: string; + readonly payload: unknown; +}; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function nonNegativeInt(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null; + return Math.round(value); +} + +/** Latest resolvable context-window.updated activity (walks newest → oldest). */ +export function extractLatestContextWindowStats( + activities: ReadonlyArray, +): ContextWindowStats | null { + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity || activity.kind !== "context-window.updated") continue; + const payload = asRecord(activity.payload); + if (payload === null) continue; + const usedTokens = nonNegativeInt(payload.usedTokens); + if (usedTokens === null) continue; + const maxTokens = nonNegativeInt(payload.maxTokens); + return { + usedTokens, + maxTokens, + activityId: typeof activity.id === "string" ? activity.id : null, + }; + } + return null; +} + +/** True when a newer context-compaction activity appeared after `sinceActivityId`. */ +export function hasNewContextCompaction( + activities: ReadonlyArray, + sinceActivityId: string | null, +): boolean { + let seenSince = sinceActivityId === null; + for (const activity of activities) { + if (!seenSince) { + if (activity.id === sinceActivityId) seenSince = true; + continue; + } + if (activity.kind === "context-compaction") return true; + } + // If we never found the marker (windowed out), any compact activity counts. + if (!seenSince) { + return activities.some((activity) => activity.kind === "context-compaction"); + } + return false; +} + +function formatTokens(value: number, maxTokens: number | null): string { + const used = formatCompactTokenCount(value) ?? `${value}`; + if (maxTokens === null) return used; + const max = formatCompactTokenCount(maxTokens) ?? `${maxTokens}`; + return `${used}/${max}`; +} + +/** + * Public Discord reply for a completed (or partial) compact attempt. + */ +export function formatCompactionStatsReply(input: { + readonly before: ContextWindowStats | null; + readonly after: ContextWindowStats | null; + readonly compacted: boolean; + readonly error?: string | null; +}): string { + if (input.error !== undefined && input.error !== null && input.error.length > 0) { + return `Context compact failed: ${input.error}`; + } + + if (!input.compacted && input.before === null && input.after === null) { + return "Context compact requested, but no context-window stats are available for this thread yet."; + } + + if (!input.compacted) { + const current = input.after ?? input.before; + const currentLabel = + current === null ? "unknown" : formatTokens(current.usedTokens, current.maxTokens); + return `Context compact requested. Current window: **${currentLabel}** tokens (provider may still be compacting, or does not support manual compact).`; + } + + const before = input.before; + const after = input.after ?? input.before; + if (before === null || after === null) { + return "Context compacted."; + } + + const beforeLabel = formatTokens(before.usedTokens, before.maxTokens); + const afterLabel = formatTokens(after.usedTokens, after.maxTokens); + const saved = before.usedTokens - after.usedTokens; + if (saved > 0) { + const savedLabel = formatCompactTokenCount(saved) ?? `${saved}`; + const pct = Math.round((saved / Math.max(before.usedTokens, 1)) * 100); + return `Context compacted: **${beforeLabel}** → **${afterLabel}** tokens (saved ${savedLabel}, ${pct}%).`; + } + if (saved < 0) { + return `Context compacted: **${beforeLabel}** → **${afterLabel}** tokens.`; + } + return `Context compacted: **${afterLabel}** tokens.`; +} + +/** How long the Discord command waits for compaction / token drop before reporting. */ +export const COMPACT_WAIT_TIMEOUT_MS = 90_000; +export const COMPACT_POLL_INTERVAL_MS = 1_500; diff --git a/apps/discord-bot/src/presentation/discordFiles.ts b/apps/discord-bot/src/presentation/discordFiles.ts new file mode 100644 index 00000000000..dd3486e4dc6 --- /dev/null +++ b/apps/discord-bot/src/presentation/discordFiles.ts @@ -0,0 +1,124 @@ +/** + * Discord file uploads must use multipart createMessage: + * - payload_json: { content, attachments: [{ id: 0, filename }] } + * - files[0], files[1], ... + * + * Attachments cannot be added later via message edit. + */ +// @effect-diagnostics globalFetch:off globalFetchInEffect:off unknownInEffectCatch:off anyUnknownInErrorContext:off preferSchemaOverJson:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off missingEffectError:off + +export interface DiscordUploadFile { + readonly name: string; + readonly mimeType: string; + readonly data: Uint8Array; +} + +export class DiscordUploadError extends Error { + readonly status: number | undefined; + readonly body: string | undefined; + + constructor(message: string, status?: number, body?: string) { + super(message); + this.name = "DiscordUploadError"; + this.status = status; + this.body = body; + } +} + +/** + * Create a channel message with binary attachments in a single multipart POST. + * Pure async helper — pass bot token + baseUrl from DiscordConfig at the call site. + */ +export async function createMessageWithAttachments(input: { + readonly baseUrl: string; + readonly botToken: string; + readonly channelId: string; + readonly content: string; + readonly files: ReadonlyArray; +}): Promise<{ readonly id: string }> { + const content = input.content; + const files = input.files; + + const form = new FormData(); + const payload: { + content: string; + attachments?: ReadonlyArray<{ id: number; filename: string }>; + } = { + content, + }; + + if (files.length > 0) { + payload.attachments = files.map((file, index) => ({ + id: index, + filename: file.name, + })); + } + + // Discord rejects completely empty messages; allow empty content when files exist. + if (payload.content.trim() === "" && files.length === 0) { + payload.content = "\u200b"; + } + + // payload_json must be a field name Discord recognizes — not an attachment. + form.append("payload_json", JSON.stringify(payload)); + + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; + // Copy into a plain ArrayBuffer-backed Uint8Array for BlobPart typing. + const copy = new Uint8Array(file.data.byteLength); + copy.set(file.data); + // Prefer File (filename is first-class). Blob+filename can show up as "blob" + // in some Discord clients when the multipart disposition is incomplete. + const safeName = + file.name.trim() !== "" + ? file.name.trim() + : file.mimeType.startsWith("image/") + ? "image.png" + : "attachment.bin"; + form.append( + `files[${index}]`, + new File([copy], safeName, { + type: file.mimeType || "application/octet-stream", + }), + ); + } + + // Strip trailing slash so we don't double up. + const base = input.baseUrl.replace(/\/+$/, ""); + const url = `${base}/channels/${input.channelId}/messages`; + + // Prefer global fetch (Node undici over HTTP/1.1) — Effect's layerUndici + HTTP/2 + // multipart has been observed to fail with NGHTTP2_PROTOCOL_ERROR on ~1MB images. + const response = await globalThis.fetch(url, { + method: "POST", + headers: { + Authorization: `Bot ${input.botToken}`, + "User-Agent": "DiscordBot (t3-discord-bot, 0.0.0)", + }, + body: form, + }); + + if (!response.ok) { + const errBody = await response.text().catch(() => ""); + throw new DiscordUploadError( + `Discord createMessage with files failed (${response.status}): ${errBody}`, + response.status, + errBody, + ); + } + + const json = (await response.json()) as { readonly id: string }; + return { id: json.id }; +} + +export function textFile( + name: string, + text: string, + mimeType = "text/markdown;charset=utf-8", +): DiscordUploadFile { + return { + name, + mimeType, + data: new TextEncoder().encode(text), + }; +} diff --git a/apps/discord-bot/src/presentation/discordInboundFiles.test.ts b/apps/discord-bot/src/presentation/discordInboundFiles.test.ts new file mode 100644 index 00000000000..cea3729f3f2 --- /dev/null +++ b/apps/discord-bot/src/presentation/discordInboundFiles.test.ts @@ -0,0 +1,161 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + appendDiscordAttachmentPromptBlock, + ATTACHMENT_ONLY_PROMPT, + downloadDiscordAttachmentsToWorkspace, + formatDiscordAttachmentPromptBlock, +} from "./discordInboundFiles.ts"; + +describe("discordInboundFiles", () => { + const originalFetch = globalThis.fetch; + let tempDir: string; + + beforeEach(() => { + tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "discord-files-")); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("downloads arbitrary Discord attachments into a temp staging directory", async () => { + globalThis.fetch = vi.fn( + async () => + new Response("hello", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ) as typeof fetch; + + const result = await downloadDiscordAttachmentsToWorkspace({ + attachments: [ + { + filename: "../incident report.html", + content_type: "text/html; charset=utf-8", + url: "https://cdn.discordapp.com/report.html", + }, + ], + discordThreadId: "thread-123", + messageId: "message-456", + }); + + expect(result.skipped).toEqual([]); + expect(result.saved).toHaveLength(1); + expect(result.saved[0]?.name).toBe("incident report.html"); + expect(result.saved[0]?.absolutePath).toContain( + `${NodePath.sep}t3-discord-attachments${NodePath.sep}thread-123${NodePath.sep}message-456${NodePath.sep}`, + ); + expect(NodeFS.readFileSync(result.saved[0]!.absolutePath, "utf8")).toBe("hello"); + }); + + it("deduplicates filenames within the same message", async () => { + globalThis.fetch = vi.fn(async () => new Response("a", { status: 200 })) as typeof fetch; + + const result = await downloadDiscordAttachmentsToWorkspace({ + attachments: [ + { filename: "report.html", url: "https://cdn.discordapp.com/1" }, + { filename: "report.html", url: "https://cdn.discordapp.com/2" }, + ], + discordThreadId: "thread", + messageId: "message", + }); + + expect(result.saved.map((attachment) => attachment.name)).toEqual([ + "report.html", + "report-2.html", + ]); + }); + + it("falls back to the alternate Discord attachment URL when the first source fails", async () => { + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url === "https://cdn.discordapp.com/report.html") { + return new Response("unsupported", { status: 415 }); + } + if (url === "https://media.discordapp.net/report.html") { + return new Response("Developer handover", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const result = await downloadDiscordAttachmentsToWorkspace({ + attachments: [ + { + filename: "report.html", + content_type: "text/html; charset=utf-8", + url: "https://cdn.discordapp.com/report.html", + proxy_url: "https://media.discordapp.net/report.html", + }, + ], + discordThreadId: "thread", + messageId: "message", + }); + + expect(result.skipped).toEqual([]); + expect(result.saved).toHaveLength(1); + expect(NodeFS.readFileSync(result.saved[0]!.absolutePath, "utf8")).toBe( + "Developer handover", + ); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it("records all attempted Discord attachment sources when every download fails", async () => { + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + return new Response(url.includes("cdn.discordapp.com") ? "unsupported" : "missing", { + status: url.includes("cdn.discordapp.com") ? 415 : 404, + }); + }) as typeof fetch; + + const result = await downloadDiscordAttachmentsToWorkspace({ + attachments: [ + { + filename: "report.html", + url: "https://cdn.discordapp.com/report.html", + proxy_url: "https://media.discordapp.net/report.html", + }, + ], + discordThreadId: "thread", + messageId: "message", + }); + + expect(result.saved).toEqual([]); + expect(result.skipped).toEqual([ + { + filename: "report.html", + reason: "url:http 415; proxy_url:http 404", + }, + ]); + }); + + it("formats markdown links for prompt injection", () => { + const prompt = appendDiscordAttachmentPromptBlock({ + prompt: ATTACHMENT_ONLY_PROMPT, + attachments: [ + { + name: "report.html", + absolutePath: "/tmp/t3-discord-attachments/thread/message/report.html", + mimeType: "text/html", + sizeBytes: 42, + }, + ], + }); + + expect(formatDiscordAttachmentPromptBlock([])).toBe(""); + expect(prompt).toContain("## Discord attachments"); + expect(prompt).toContain( + "[report.html](/tmp/t3-discord-attachments/thread/message/report.html)", + ); + }); +}); diff --git a/apps/discord-bot/src/presentation/discordInboundFiles.ts b/apps/discord-bot/src/presentation/discordInboundFiles.ts new file mode 100644 index 00000000000..395e05ac1bb --- /dev/null +++ b/apps/discord-bot/src/presentation/discordInboundFiles.ts @@ -0,0 +1,219 @@ +// @effect-diagnostics globalFetch:off nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import type { DiscordInboundAttachment } from "./discordInboundImages.ts"; + +const DISCORD_ATTACHMENT_STAGE_DIR = "t3-discord-attachments"; +const MAX_DISCORD_ATTACHMENT_BYTES = 50 * 1024 * 1024; +const MAX_FILENAME_LENGTH = 120; + +export interface SavedDiscordAttachment { + readonly name: string; + readonly absolutePath: string; + readonly mimeType: string | null; + readonly sizeBytes: number; +} + +interface DiscordAttachmentSourceAttempt { + readonly kind: "url" | "proxy_url"; + readonly url: string; +} + +function sanitizePathSegment(value: string, fallback: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) return fallback; + const withoutControlChars = [...trimmed] + .filter((char) => (char.codePointAt(0) ?? 0x20) >= 0x20) + .join(""); + const sanitized = withoutControlChars + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, " ") + .replace(/^[.\-\s]+/g, "") + .trim(); + return sanitized.length > 0 ? sanitized : fallback; +} + +function splitExtension(filename: string): { readonly stem: string; readonly extension: string } { + const extension = NodePath.extname(filename); + if (extension.length === 0) { + return { stem: filename, extension: "" }; + } + return { stem: filename.slice(0, -extension.length), extension }; +} + +function sanitizeAttachmentFilename(filename: string, index: number): string { + const original = filename.trim() !== "" ? filename : `attachment-${index + 1}`; + const { stem, extension } = splitExtension(original); + const safeStem = sanitizePathSegment(stem, `attachment-${index + 1}`); + const safeExtension = extension.replace(/[^a-z0-9.]+/gi, "").toLowerCase(); + const maxStemLength = Math.max(1, MAX_FILENAME_LENGTH - safeExtension.length); + const trimmedStem = safeStem.slice(0, maxStemLength).trim() || `attachment-${index + 1}`; + return `${trimmedStem}${safeExtension}`; +} + +function ensureUniqueFilename(filename: string, seen: Set, index: number): string { + if (!seen.has(filename)) { + seen.add(filename); + return filename; + } + + const { stem, extension } = splitExtension(filename); + for (let attempt = 2; attempt < 10_000; attempt += 1) { + const suffix = `-${attempt}`; + const maxStemLength = Math.max(1, MAX_FILENAME_LENGTH - extension.length - suffix.length); + const candidate = + `${stem.slice(0, maxStemLength)}${suffix}${extension}` || `attachment-${index + 1}`; + if (!seen.has(candidate)) { + seen.add(candidate); + return candidate; + } + } + + const fallback = `attachment-${index + 1}${extension}`; + seen.add(fallback); + return fallback; +} + +export function formatDiscordAttachmentPromptBlock( + attachments: ReadonlyArray, +): string { + if (attachments.length === 0) return ""; + const lines = [ + "## Discord attachments", + "These files were attached to the Discord message that mentioned you. Open them from the local filesystem if needed.", + ...attachments.map( + (attachment) => + `- [${attachment.name}](${attachment.absolutePath})${ + attachment.mimeType + ? ` (${attachment.mimeType}, ${attachment.sizeBytes} bytes)` + : ` (${attachment.sizeBytes} bytes)` + }`, + ), + ]; + return lines.join("\n"); +} + +export function appendDiscordAttachmentPromptBlock(input: { + readonly prompt: string; + readonly attachments: ReadonlyArray; +}): string { + const block = formatDiscordAttachmentPromptBlock(input.attachments); + if (block.length === 0) return input.prompt; + const prompt = input.prompt.trim(); + return prompt.length === 0 ? block : `${prompt}\n\n${block}`; +} + +function getAttachmentSourceAttempts( + attachment: DiscordInboundAttachment, +): ReadonlyArray { + const attempts: DiscordAttachmentSourceAttempt[] = []; + for (const candidate of [ + { kind: "url" as const, url: attachment.url }, + { kind: "proxy_url" as const, url: attachment.proxy_url }, + ]) { + const sourceUrl = candidate.url; + if (!sourceUrl) continue; + if (attempts.some((attempt) => attempt.url === sourceUrl)) continue; + attempts.push({ kind: candidate.kind, url: sourceUrl }); + } + return attempts; +} + +export async function downloadDiscordAttachmentsToWorkspace(input: { + readonly attachments: ReadonlyArray; + readonly discordThreadId: string; + readonly messageId: string; +}): Promise<{ + readonly saved: ReadonlyArray; + readonly skipped: ReadonlyArray<{ readonly filename: string; readonly reason: string }>; +}> { + if (input.attachments.length === 0) { + return { saved: [], skipped: [] }; + } + + const baseDir = NodePath.join( + NodeOS.tmpdir(), + DISCORD_ATTACHMENT_STAGE_DIR, + sanitizePathSegment(input.discordThreadId, "thread"), + sanitizePathSegment(input.messageId, "message"), + ); + await NodeFSP.mkdir(baseDir, { recursive: true }); + + const seenNames = new Set(); + const saved: SavedDiscordAttachment[] = []; + const skipped: Array<{ filename: string; reason: string }> = []; + + for (const [index, attachment] of input.attachments.entries()) { + const filename = sanitizeAttachmentFilename(attachment.filename ?? "", index); + const uniqueFilename = ensureUniqueFilename(filename, seenNames, index); + const sourceAttempts = getAttachmentSourceAttempts(attachment); + if (sourceAttempts.length === 0) { + skipped.push({ filename: uniqueFilename, reason: "missing url" }); + continue; + } + if (typeof attachment.size === "number" && attachment.size > MAX_DISCORD_ATTACHMENT_BYTES) { + skipped.push({ + filename: uniqueFilename, + reason: `too large (${attachment.size} > ${MAX_DISCORD_ATTACHMENT_BYTES})`, + }); + continue; + } + + try { + const failures: string[] = []; + let savedAttachment: SavedDiscordAttachment | null = null; + + for (const source of sourceAttempts) { + const response = await fetch(source.url); + if (!response.ok) { + failures.push(`${source.kind}:http ${response.status}`); + continue; + } + + const buffer = new Uint8Array(await response.arrayBuffer()); + if (buffer.byteLength === 0) { + failures.push(`${source.kind}:empty body`); + continue; + } + if (buffer.byteLength > MAX_DISCORD_ATTACHMENT_BYTES) { + failures.push( + `${source.kind}:too large (${buffer.byteLength} > ${MAX_DISCORD_ATTACHMENT_BYTES})`, + ); + continue; + } + + const absolutePath = NodePath.join(baseDir, uniqueFilename); + await NodeFSP.writeFile(absolutePath, buffer); + savedAttachment = { + name: uniqueFilename, + absolutePath, + mimeType: attachment.content_type?.split(";")[0]?.trim() ?? null, + sizeBytes: buffer.byteLength, + }; + break; + } + + if (savedAttachment) { + saved.push(savedAttachment); + continue; + } + + skipped.push({ + filename: uniqueFilename, + reason: failures.join("; ") || "download failed", + }); + } catch (cause) { + skipped.push({ + filename: uniqueFilename, + reason: cause instanceof Error ? cause.message : String(cause), + }); + } + } + + return { saved, skipped }; +} + +export const ATTACHMENT_ONLY_PROMPT = + "[User attached one or more files without additional text. Inspect the linked file(s) and respond using the conversation context.]"; diff --git a/apps/discord-bot/src/presentation/discordInboundImages.test.ts b/apps/discord-bot/src/presentation/discordInboundImages.test.ts new file mode 100644 index 00000000000..2331f6890aa --- /dev/null +++ b/apps/discord-bot/src/presentation/discordInboundImages.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + filterDiscordImageAttachments, + guessMimeFromFilename, + isDiscordImageAttachment, +} from "./discordInboundImages.ts"; + +describe("isDiscordImageAttachment", () => { + it("accepts image content types", () => { + expect( + isDiscordImageAttachment({ + filename: "shot.png", + content_type: "image/png", + url: "https://cdn.discordapp.com/a.png", + }), + ).toBe(true); + }); + + it("rejects svg", () => { + expect( + isDiscordImageAttachment({ + filename: "x.svg", + content_type: "image/svg+xml", + url: "https://cdn.discordapp.com/x.svg", + }), + ).toBe(false); + }); + + it("accepts image extensions without content_type", () => { + expect( + isDiscordImageAttachment({ + filename: "ui.jpg", + url: "https://cdn.discordapp.com/ui.jpg", + }), + ).toBe(true); + }); + + it("accepts dimensioned attachments", () => { + expect( + isDiscordImageAttachment({ + filename: "paste", + width: 800, + height: 600, + url: "https://cdn.discordapp.com/paste", + }), + ).toBe(true); + }); + + it("rejects non-images", () => { + expect( + isDiscordImageAttachment({ + filename: "notes.txt", + content_type: "text/plain", + url: "https://cdn.discordapp.com/notes.txt", + }), + ).toBe(false); + }); +}); + +describe("filterDiscordImageAttachments", () => { + it("keeps only images", () => { + const out = filterDiscordImageAttachments([ + { filename: "a.png", content_type: "image/png", url: "u1" }, + { filename: "b.pdf", content_type: "application/pdf", url: "u2" }, + { filename: "c.webp", url: "u3" }, + ]); + expect(out.map((a) => a.filename)).toEqual(["a.png", "c.webp"]); + }); +}); + +describe("guessMimeFromFilename", () => { + it("maps common extensions", () => { + expect(guessMimeFromFilename("x.PNG")).toBe("image/png"); + expect(guessMimeFromFilename("x.jpeg")).toBe("image/jpeg"); + expect(guessMimeFromFilename("x")).toBe(null); + }); +}); diff --git a/apps/discord-bot/src/presentation/discordInboundImages.ts b/apps/discord-bot/src/presentation/discordInboundImages.ts new file mode 100644 index 00000000000..a56e7ad2374 --- /dev/null +++ b/apps/discord-bot/src/presentation/discordInboundImages.ts @@ -0,0 +1,149 @@ +// @effect-diagnostics globalFetch:off +/** + * Convert Discord message attachments into T3 UploadChatAttachment images + * (data URLs) so providers receive the same shape as the web composer. + */ +import type { UploadChatAttachment } from "@t3tools/contracts"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; + +/** Minimal Discord attachment shape (gateway or REST). */ +export interface DiscordInboundAttachment { + readonly id?: string; + readonly filename?: string; + readonly url?: string; + readonly proxy_url?: string; + readonly size?: number; + readonly content_type?: string | null; + readonly width?: number; + readonly height?: number; +} + +const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i; + +export function guessMimeFromFilename(filename: string): string | null { + const lower = filename.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".bmp")) return "image/bmp"; + return null; +} + +export function isDiscordImageAttachment(att: DiscordInboundAttachment): boolean { + const ct = att.content_type?.toLowerCase() ?? ""; + if (ct.startsWith("image/") && !ct.includes("svg")) return true; + const name = att.filename ?? ""; + if (IMAGE_EXT.test(name)) return true; + // Discord sometimes omits content_type but sets dimensions for images. + if ( + typeof att.width === "number" && + att.width > 0 && + typeof att.height === "number" && + att.height > 0 && + name !== "" + ) { + return true; + } + return false; +} + +export function filterDiscordImageAttachments( + attachments: ReadonlyArray | null | undefined, +): ReadonlyArray { + if (attachments === undefined || attachments === null) return []; + return attachments.filter(isDiscordImageAttachment).slice(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS); +} + +function uint8ToBase64(bytes: Uint8Array): string { + // Chunk to avoid call-stack / argument limits on large images. + const chunk = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +/** + * Download Discord CDN images and build T3 upload attachments. + * Skips oversize / failed downloads (logged by caller via returned skipped list). + */ +export async function downloadDiscordImagesAsUploadAttachments( + attachments: ReadonlyArray, +): Promise<{ + readonly uploads: ReadonlyArray; + readonly skipped: ReadonlyArray<{ readonly filename: string; readonly reason: string }>; +}> { + const images = filterDiscordImageAttachments(attachments); + const uploads: UploadChatAttachment[] = []; + const skipped: Array<{ filename: string; reason: string }> = []; + + for (const att of images) { + const filename = (att.filename ?? "image.png").slice(0, 255) || "image.png"; + const sourceUrl = att.proxy_url || att.url; + if (sourceUrl === undefined || sourceUrl === "") { + skipped.push({ filename, reason: "missing url" }); + continue; + } + if (typeof att.size === "number" && att.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + skipped.push({ + filename, + reason: `too large (${att.size} > ${PROVIDER_SEND_TURN_MAX_IMAGE_BYTES})`, + }); + continue; + } + + try { + const response = await fetch(sourceUrl); + if (!response.ok) { + skipped.push({ filename, reason: `http ${response.status}` }); + continue; + } + const buffer = new Uint8Array(await response.arrayBuffer()); + if (buffer.byteLength === 0) { + skipped.push({ filename, reason: "empty body" }); + continue; + } + if (buffer.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + skipped.push({ + filename, + reason: `too large (${buffer.byteLength} > ${PROVIDER_SEND_TURN_MAX_IMAGE_BYTES})`, + }); + continue; + } + + const headerType = response.headers.get("content-type")?.split(";")[0]?.trim() ?? ""; + const mimeType = + (att.content_type && att.content_type.startsWith("image/") + ? att.content_type.split(";")[0]!.trim() + : null) ?? + (headerType.startsWith("image/") ? headerType : null) ?? + guessMimeFromFilename(filename) ?? + "image/png"; + + const dataUrl = `data:${mimeType};base64,${uint8ToBase64(buffer)}`; + uploads.push({ + type: "image", + name: filename, + mimeType, + sizeBytes: buffer.byteLength, + dataUrl, + }); + } catch (cause) { + skipped.push({ + filename, + reason: cause instanceof Error ? cause.message : String(cause), + }); + } + } + + return { uploads, skipped }; +} + +/** Prompt when the user only attached images (mirrors web empty-text image turns). */ +export const IMAGE_ONLY_PROMPT = + "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; diff --git a/apps/discord-bot/src/presentation/discordPrAttribution.test.ts b/apps/discord-bot/src/presentation/discordPrAttribution.test.ts new file mode 100644 index 00000000000..7ab46d468a9 --- /dev/null +++ b/apps/discord-bot/src/presentation/discordPrAttribution.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendDiscordPrAttributionFooter, + buildDiscordThreadJumpUrl, + buildOmegentThreadMessageUrl, + buildT3WebThreadUrl, + DISCORD_PR_ATTRIBUTION_MARKER, + ensureDiscordPrAttributionFooters, + formatDiscordPrAttributionFooter, + newlyObservedPullRequestUrls, + pickT3ThreadUrlForGithubRepo, + prBodyHasDiscordAttribution, + starterDisplayName, + starterUserId, + toT3PublicShortThreadUrl, + withT3ThreadLink, +} from "./discordPrAttribution.ts"; + +describe("formatDiscordPrAttributionFooter", () => { + it("formats starter profile URL + full thread jump link", () => { + expect( + formatDiscordPrAttributionFooter({ + starterDisplayName: "joshuadima", + starterUserId: "593167616273809448", + threadTitle: "Open Random PR Test", + threadJumpUrl: + "https://discord.com/channels/1083767712431480922/1531376362399465595/1531376362399465595", + }), + ).toBe( + "opened by [joshuadima](https://discord.com/users/593167616273809448) in chat thread **Discord** · [Open Random PR Test](https://discord.com/channels/1083767712431480922/1531376362399465595/1531376362399465595)", + ); + }); + + it("omits broken/truncated thread links", () => { + expect( + formatDiscordPrAttributionFooter({ + starterDisplayName: "joshuadima", + starterUserId: "593167616273809448", + threadTitle: "Thread", + threadJumpUrl: "https://discord.com/channels", + }), + ).toBe( + "opened by [joshuadima](https://discord.com/users/593167616273809448) in chat thread **Discord**", + ); + }); + + it("escapes markdown brackets in names/titles", () => { + const footer = formatDiscordPrAttributionFooter({ + starterDisplayName: "a[b]c", + starterUserId: "1", + threadTitle: "Title [x]", + threadJumpUrl: "https://discord.com/channels/1/2/3", + }); + expect(footer).toContain("[a\\[b\\]c](https://discord.com/users/1)"); + expect(footer).toContain("[Title \\[x\\]](https://discord.com/channels/1/2/3)"); + }); + + it("appends T3 thread link when provided", () => { + expect( + formatDiscordPrAttributionFooter({ + starterDisplayName: "patroza", + starterUserId: "1", + threadTitle: "Thread", + threadJumpUrl: "https://discord.com/channels/1/2/3", + t3ThreadUrl: "https://t3vm.tail.example.ts.net/?thread=abc", + }), + ).toContain(" · [T3](https://t3vm.tail.example.ts.net/?thread=abc)"); + }); +}); + +describe("T3 thread URL helpers", () => { + it("builds full and short t3 thread URLs", () => { + expect(buildT3WebThreadUrl("https://t3vm.tail86038f.ts.net/", "tid-1")).toBe( + "https://t3vm.tail86038f.ts.net/?thread=tid-1", + ); + expect(toT3PublicShortThreadUrl("https://t3vm.tail86038f.ts.net/?thread=tid-1")).toBe( + "https://t3vm/?thread=tid-1", + ); + expect( + pickT3ThreadUrlForGithubRepo({ + fullUrl: "https://t3vm.tail86038f.ts.net/?thread=tid-1", + repoIsPrivate: true, + }), + ).toBe("https://t3vm.tail86038f.ts.net/?thread=tid-1"); + expect( + pickT3ThreadUrlForGithubRepo({ + fullUrl: "https://t3vm.tail86038f.ts.net/?thread=tid-1", + repoIsPrivate: false, + }), + ).toBe("https://t3vm/?thread=tid-1"); + expect( + pickT3ThreadUrlForGithubRepo({ + fullUrl: "https://t3vm.tail86038f.ts.net/?thread=tid-1", + repoIsPrivate: null, + }), + ).toBe("https://t3vm/?thread=tid-1"); + }); + + it("withT3ThreadLink is idempotent", () => { + const base = + "opened by [x](https://discord.com/users/1) in chat thread **Discord** · [t](https://discord.com/channels/1/2/3)"; + const once = withT3ThreadLink(base, "https://t3vm/?thread=1"); + expect(once).toBe(`${base} · [T3](https://t3vm/?thread=1)`); + expect(withT3ThreadLink(once, "https://t3vm/?thread=1")).toBe(once); + }); + + it("builds message deep links from the configured web UI base", () => { + expect( + buildOmegentThreadMessageUrl({ + webUiBaseUrl: "https://t3vm.tail86038f.ts.net/", + threadId: "tid-1", + messageId: "msg-1", + }), + ).toBe("https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1"); + expect( + buildOmegentThreadMessageUrl({ + webUiBaseUrl: undefined, + threadId: "tid-1", + messageId: "msg-1", + }), + ).toBeNull(); + }); +}); + +describe("buildDiscordThreadJumpUrl", () => { + it("prefers message id when provided", () => { + expect( + buildDiscordThreadJumpUrl({ + guildId: "g", + discordThreadId: "t", + messageId: "m", + }), + ).toBe("https://discord.com/channels/g/t/m"); + }); + + it("falls back to thread id as message id", () => { + expect( + buildDiscordThreadJumpUrl({ + guildId: "g", + discordThreadId: "t", + messageId: null, + }), + ).toBe("https://discord.com/channels/g/t/t"); + }); +}); + +describe("starter helpers", () => { + it("prefers displayName over username", () => { + expect( + starterDisplayName({ + id: "m1", + author: { id: "u1", username: "user", displayName: "Display" }, + }), + ).toBe("Display"); + expect(starterUserId({ id: "m1", author: { id: "u1" } })).toBe("u1"); + }); + + it("handles missing starter", () => { + expect(starterDisplayName(null)).toBe("unknown"); + expect(starterUserId(null)).toBeNull(); + }); +}); + +describe("prBodyHasDiscordAttribution / appendDiscordPrAttributionFooter", () => { + const footer = formatDiscordPrAttributionFooter({ + starterDisplayName: "joshuadima", + starterUserId: "593167616273809448", + threadTitle: "Thread", + threadJumpUrl: "https://discord.com/channels/1/2/3", + }); + + it("detects existing footer marker", () => { + expect(prBodyHasDiscordAttribution(`hello\n${DISCORD_PR_ATTRIBUTION_MARKER}\n`)).toBe(true); + expect(prBodyHasDiscordAttribution("## Summary\n- stuff")).toBe(false); + }); + + it("appends footer with separator when missing", () => { + expect(appendDiscordPrAttributionFooter("## Summary\n- a", footer)).toBe( + `## Summary\n- a\n\n---\n\n${footer}\n`, + ); + }); + + it("returns null when already present (idempotent)", () => { + const body = `## Summary\n\n---\n\n${footer}\n`; + expect(appendDiscordPrAttributionFooter(body, footer)).toBeNull(); + }); + + it("works on empty body", () => { + expect(appendDiscordPrAttributionFooter("", footer)).toBe(`${footer}\n`); + }); +}); + +describe("newlyObservedPullRequestUrls", () => { + it("returns only new canonical PR urls", () => { + expect( + newlyObservedPullRequestUrls( + ["https://github.com/owner/repo/pull/1"], + [ + "https://github.com/owner/repo/pull/1/files", + "https://github.com/owner/repo/pull/2", + "https://example.com/not-a-pr", + ], + ), + ).toEqual(["https://github.com/owner/repo/pull/2"]); + }); +}); + +describe("ensureDiscordPrAttributionFooters", () => { + it("patches missing footers and skips ones already present", async () => { + const bodies = new Map([ + ["repos/o/r/pulls/1", "## Summary\n- a\n"], + [ + "repos/o/r/pulls/2", + `## Summary\n\n---\n\nopened by [x](1) ${DISCORD_PR_ATTRIBUTION_MARKER} [t](https://discord.com/channels/1/2/3)\n`, + ], + ]); + const repoPrivate = new Map([["repos/o/r", "true"]]); + const patched: string[] = []; + const writtenBodies: string[] = []; + + const results = await ensureDiscordPrAttributionFooters({ + prUrls: [ + "https://github.com/o/r/pull/1", + "https://github.com/o/r/pull/2", + "https://github.com/o/r/pull/3", + ], + footer: formatDiscordPrAttributionFooter({ + starterDisplayName: "joshuadima", + starterUserId: "593167616273809448", + threadTitle: "Open Random PR Test", + threadJumpUrl: "https://discord.com/channels/1/2/3", + }), + t3FullThreadUrl: "https://t3vm.tail.example.ts.net/?thread=t1", + execFile: async (_file, args) => { + const path = String(args[1] ?? ""); + if (args.includes("--jq")) { + const jq = String(args[args.indexOf("--jq") + 1] ?? ""); + if (jq.includes(".private")) { + return { stdout: repoPrivate.get(path) ?? "false", stderr: "" }; + } + if (!bodies.has(path)) { + throw new Error(`not found: ${path}`); + } + return { stdout: bodies.get(path) ?? "", stderr: "" }; + } + if (args.includes("PATCH")) { + patched.push(path); + // body written via temp file; just record path + writtenBodies.push(path); + return { stdout: "", stderr: "" }; + } + throw new Error(`unexpected gh args: ${args.join(" ")}`); + }, + }); + + expect(results).toEqual([ + { url: "https://github.com/o/r/pull/1", status: "updated" }, + { url: "https://github.com/o/r/pull/2", status: "already_present" }, + { + url: "https://github.com/o/r/pull/3", + status: "error", + detail: "not found: repos/o/r/pulls/3", + }, + ]); + expect(patched).toEqual(["repos/o/r/pulls/1"]); + }); +}); diff --git a/apps/discord-bot/src/presentation/discordPrAttribution.ts b/apps/discord-bot/src/presentation/discordPrAttribution.ts new file mode 100644 index 00000000000..efbe628a784 --- /dev/null +++ b/apps/discord-bot/src/presentation/discordPrAttribution.ts @@ -0,0 +1,394 @@ +/** + * Hardcoded Discord PR attribution footer. + * + * When a PR URL is first linked to a Discord thread, the bot appends a footer + * using the **thread starter** (not the current requester) and the Discord + * thread title. No agent prompt is required. + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +import { gitCommandEnv } from "./githubLinks.ts"; +import { normalizePullRequestUrl } from "./prLinks.ts"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +type ExecFileResult = { + readonly stdout: string; + readonly stderr: string; +}; + +type ExecFileLike = ( + file: string, + args: ReadonlyArray, + options?: NodeChildProcess.ExecFileOptions, +) => Promise; + +/** Marker used to detect an existing Discord attribution footer (idempotent). */ +export const DISCORD_PR_ATTRIBUTION_MARKER = "in chat thread **Discord** ·"; + +/** Marker for optional T3 thread link on the same footer line. */ +export const T3_PR_THREAD_LINK_MARKER = " · [T3]("; + +export type DiscordPrAttributionInput = { + /** Thread starter display name (fallback: username). */ + readonly starterDisplayName: string; + /** Thread starter Discord snowflake user id. */ + readonly starterUserId: string; + /** Discord thread title (channel name). */ + readonly threadTitle: string; + /** Jump URL into the Discord thread (prefer starter message). */ + readonly threadJumpUrl: string; + /** + * Optional T3 web thread URL. Full host for private GitHub repos; short + * `https://t3vm/?thread=…` for public repos (see pickT3ThreadUrlForGithubRepo). + */ + readonly t3ThreadUrl?: string | null | undefined; +}; + +export type DiscordThreadStarterLike = { + readonly id: string; + readonly author?: + | { + readonly id?: string | undefined; + readonly username?: string | undefined; + readonly displayName?: string | undefined; + } + | undefined + | null; +}; + +/** + * Public Discord user profile URL (opens profile in app/browser when signed in). + * Prefer this over bare snowflake ids, which are not navigable links. + */ +export function buildDiscordUserProfileUrl(userId: string): string { + return `https://discord.com/users/${userId.trim()}`; +} + +/** True when the URL is a full discord.com/channels/... jump (not a truncated placeholder). */ +export function isValidDiscordChannelJumpUrl(url: string): boolean { + const trimmed = url.trim(); + if (trimmed.length === 0) return false; + // Require guild + channel segments; optional message id. + return /^https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/[^/\s]+\/[^/\s]+(?:\/[^/\s]+)?$/u.test( + trimmed, + ); +} + +/** + * Full T3 web UI deep link for a thread (`{base}/?thread={id}`). + * Returns null when base or thread id is missing. + */ +export function buildT3WebThreadUrl( + webUiBaseUrl: string | undefined | null, + threadId: string | undefined | null, +): string | null { + const base = webUiBaseUrl?.trim(); + const id = threadId?.trim(); + if (base === undefined || base.length === 0 || id === undefined || id.length === 0) { + return null; + } + return `${base.replace(/\/+$/u, "")}/?thread=${id}`; +} + +/** + * Same web UI base as the pin's "Open in Omegent" (`T3_WEB_UI_BASE_URL`), + * plus `#message-{messageId}` for client scroll-into-view. + * Does not invent a short `t3vm` host — that rewrite is only for public PR bodies. + */ +export function buildOmegentThreadMessageUrl(input: { + readonly webUiBaseUrl?: string | null | undefined; + readonly threadId: string | undefined | null; + readonly messageId: string | undefined | null; +}): string | null { + const messageId = input.messageId?.trim() ?? ""; + if (messageId === "") return null; + + const threadUrl = buildT3WebThreadUrl(input.webUiBaseUrl, input.threadId); + if (threadUrl === null) return null; + + try { + const url = new URL(threadUrl); + url.hash = `message-${messageId}`; + return url.toString(); + } catch { + const withoutHash = threadUrl.replace(/#.*$/u, ""); + return `${withoutHash}#message-${messageId}`; + } +} + +/** + * Public-safe short form: same URL with hostname forced to `t3vm` (no port). + * Example: `https://t3vm.tail….ts.net/?thread=x` → `https://t3vm/?thread=x` + */ +export function toT3PublicShortThreadUrl(fullUrl: string): string { + const trimmed = fullUrl.trim(); + try { + const url = new URL(trimmed); + url.hostname = "t3vm"; + url.port = ""; + return url.toString(); + } catch { + return trimmed.replace(/^(https?:\/\/)[^/?#]+/u, "$1t3vm"); + } +} + +/** + * Private GitHub repo → full t3vm host URL. Public/unknown → short `t3vm` host only + * (avoids leaking tailnet hostnames on public PR bodies). + */ +export function pickT3ThreadUrlForGithubRepo(input: { + readonly fullUrl: string | null | undefined; + readonly repoIsPrivate: boolean | null; +}): string | null { + const full = input.fullUrl?.trim(); + if (full === undefined || full.length === 0) return null; + if (input.repoIsPrivate === true) return full; + return toT3PublicShortThreadUrl(full); +} + +/** Append ` · [T3](url)` when missing. */ +export function withT3ThreadLink( + discordFooter: string, + t3ThreadUrl: string | null | undefined, +): string { + const base = discordFooter.trim(); + const t3 = t3ThreadUrl?.trim(); + if (base.length === 0) return t3 !== undefined && t3.length > 0 ? `[T3](${t3})` : ""; + if (t3 === undefined || t3.length === 0) return base; + if (base.includes(T3_PR_THREAD_LINK_MARKER) || base.includes(`](${t3})`)) return base; + return `${base}${T3_PR_THREAD_LINK_MARKER}${t3})`; +} + +/** + * Build the single-line attribution footer from thread starter + title. + * User link uses Discord profile URL; thread link must be a full channel jump URL. + * Optional T3 thread link is appended as ` · [T3](url)`. + */ +export function formatDiscordPrAttributionFooter(input: DiscordPrAttributionInput): string { + const displayName = input.starterDisplayName.trim() || "unknown"; + const userId = input.starterUserId.trim(); + const title = sanitizeMarkdownLinkLabel(input.threadTitle.trim() || "Discord thread"); + const profileUrl = buildDiscordUserProfileUrl(userId); + const jumpUrl = input.threadJumpUrl.trim(); + + const openedBy = `opened by [${escapeMarkdownLinkLabel(displayName)}](${profileUrl}) in chat thread **Discord**`; + const withDiscord = isValidDiscordChannelJumpUrl(jumpUrl) + ? `${openedBy} · [${escapeMarkdownLinkLabel(title)}](${jumpUrl})` + : openedBy; + return withT3ThreadLink(withDiscord, input.t3ThreadUrl); +} + +export function buildDiscordThreadJumpUrl(input: { + readonly guildId: string; + readonly discordThreadId: string; + /** Prefer the starter message id; falls back to the thread id. */ + readonly messageId?: string | null | undefined; +}): string { + const guildId = input.guildId.trim(); + const discordThreadId = input.discordThreadId.trim(); + if (guildId.length === 0 || discordThreadId.length === 0) { + return ""; + } + const messageId = + input.messageId !== null && input.messageId !== undefined && input.messageId.trim() !== "" + ? input.messageId.trim() + : discordThreadId; + return `https://discord.com/channels/${guildId}/${discordThreadId}/${messageId}`; +} + +export function starterDisplayName(starter: DiscordThreadStarterLike | null | undefined): string { + if (starter === null || starter === undefined) return "unknown"; + const display = starter.author?.displayName?.trim(); + if (display !== undefined && display.length > 0) return display; + const username = starter.author?.username?.trim(); + if (username !== undefined && username.length > 0) return username; + return "unknown"; +} + +export function starterUserId(starter: DiscordThreadStarterLike | null | undefined): string | null { + const id = starter?.author?.id?.trim(); + return id !== undefined && id.length > 0 ? id : null; +} + +/** True when the PR body already has a Discord attribution footer. */ +export function prBodyHasDiscordAttribution(body: string | null | undefined): boolean { + if (body === null || body === undefined || body.length === 0) return false; + return ( + body.includes(DISCORD_PR_ATTRIBUTION_MARKER) || + /opened by \[.+?\]\(.+?\) in chat thread/u.test(body) + ); +} + +/** + * Append the footer when missing. Returns null when body already has attribution + * (caller should skip the GitHub update). + */ +export function appendDiscordPrAttributionFooter( + body: string | null | undefined, + footer: string, +): string | null { + const trimmedFooter = footer.trim(); + if (trimmedFooter.length === 0) return null; + if (prBodyHasDiscordAttribution(body)) return null; + + const base = (body ?? "").replace(/\s+$/u, ""); + if (base.length === 0) return `${trimmedFooter}\n`; + return `${base}\n\n---\n\n${trimmedFooter}\n`; +} + +/** + * Newly observed PR URLs that are not already in the durable first-seen list. + */ +export function newlyObservedPullRequestUrls( + existing: ReadonlyArray | null | undefined, + incoming: ReadonlyArray | null | undefined, +): ReadonlyArray { + const seen = new Set(); + for (const raw of existing ?? []) { + const normalized = normalizePullRequestUrl(raw); + if (normalized !== null) seen.add(normalized.url); + } + + const result: string[] = []; + for (const raw of incoming ?? []) { + const normalized = normalizePullRequestUrl(raw); + if (normalized === null || seen.has(normalized.url)) continue; + seen.add(normalized.url); + result.push(normalized.url); + } + return result; +} + +function escapeMarkdownLinkLabel(value: string): string { + return value.replace(/\[/gu, "\\[").replace(/\]/gu, "\\]"); +} + +function sanitizeMarkdownLinkLabel(value: string): string { + // Collapse newlines / excessive whitespace so the footer stays one line. + return value.replace(/\s+/gu, " ").trim(); +} + +export type EnsureDiscordPrAttributionResult = { + readonly url: string; + readonly status: "updated" | "already_present" | "skipped" | "error"; + readonly detail?: string | undefined; +}; + +/** + * For each PR URL, append the Discord attribution footer when missing. + * When `t3FullThreadUrl` is set, appends ` · [T3](…)` using the full host for + * private GitHub repos and the short `t3vm` host for public/unknown repos. + * Best-effort: failures are returned per-URL and never throw. + */ +export async function ensureDiscordPrAttributionFooters(input: { + readonly prUrls: ReadonlyArray; + /** Discord attribution line (may already include T3; otherwise T3 is chosen per-repo). */ + readonly footer: string; + /** Full T3 web URL (`{T3_WEB_UI_BASE_URL}/?thread=…`). Shortened for public repos. */ + readonly t3FullThreadUrl?: string | null | undefined; + readonly execFile?: ExecFileLike; +}): Promise> { + const execImpl = input.execFile ?? execFile; + const results: EnsureDiscordPrAttributionResult[] = []; + const footerHasT3 = input.footer.includes(T3_PR_THREAD_LINK_MARKER); + + for (const raw of input.prUrls) { + const normalized = normalizePullRequestUrl(raw); + if (normalized === null) { + results.push({ url: raw, status: "skipped", detail: "not a github pull request url" }); + continue; + } + + try { + const currentBody = await readPullRequestBody(normalized, execImpl); + let footer = input.footer; + if (!footerHasT3 && input.t3FullThreadUrl !== undefined && input.t3FullThreadUrl !== null) { + const repoIsPrivate = await readGithubRepoIsPrivate(normalized, execImpl); + const t3Url = pickT3ThreadUrlForGithubRepo({ + fullUrl: input.t3FullThreadUrl, + repoIsPrivate, + }); + footer = withT3ThreadLink(input.footer, t3Url); + } + const nextBody = appendDiscordPrAttributionFooter(currentBody, footer); + if (nextBody === null) { + results.push({ url: normalized.url, status: "already_present" }); + continue; + } + + await writePullRequestBody(normalized, nextBody, execImpl); + results.push({ url: normalized.url, status: "updated" }); + } catch (error) { + results.push({ + url: normalized.url, + status: "error", + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + return results; +} + +async function readGithubRepoIsPrivate( + pr: { readonly owner: string; readonly repo: string }, + execImpl: ExecFileLike, +): Promise { + try { + const { stdout } = await execImpl( + "gh", + ["api", `repos/${pr.owner}/${pr.repo}`, "--jq", ".private"], + { env: gitCommandEnv(), maxBuffer: 64 * 1024 }, + ); + const value = stdout.trim().toLowerCase(); + if (value === "true") return true; + if (value === "false") return false; + return null; + } catch { + return null; + } +} + +async function readPullRequestBody( + pr: { readonly owner: string; readonly repo: string; readonly number: number }, + execImpl: ExecFileLike, +): Promise { + const { stdout } = await execImpl( + "gh", + ["api", `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, "--jq", '.body // ""'], + { env: gitCommandEnv(), maxBuffer: 4 * 1024 * 1024 }, + ); + return stdout; +} + +async function writePullRequestBody( + pr: { readonly owner: string; readonly repo: string; readonly number: number }, + body: string, + execImpl: ExecFileLike, +): Promise { + const tempDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-discord-pr-body-")); + const bodyFile = NodePath.join(tempDir, "body.json"); + try { + await NodeFSP.writeFile(bodyFile, JSON.stringify({ body }), "utf8"); + await execImpl( + "gh", + [ + "api", + `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, + "-X", + "PATCH", + "--input", + bodyFile, + ], + { env: gitCommandEnv(), maxBuffer: 4 * 1024 * 1024 }, + ); + } finally { + await NodeFSP.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/apps/discord-bot/src/presentation/githubLinks.test.ts b/apps/discord-bot/src/presentation/githubLinks.test.ts new file mode 100644 index 00000000000..2b726ac7691 --- /dev/null +++ b/apps/discord-bot/src/presentation/githubLinks.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + createGitHubLinkResolutionCache, + normalizeGitHubRemoteUrl, + resolveGitHubBlobUrlForLocalPath, + resolveGitHubBlobUrlForPathReference, + resolveGitHubUrlForWorkspace, +} from "./githubLinks.ts"; + +describe("normalizeGitHubRemoteUrl", () => { + it("normalizes GitHub SSH remotes", () => { + expect(normalizeGitHubRemoteUrl("git@github.com:pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + expect(normalizeGitHubRemoteUrl("ssh://git@github.com/pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + }); + + it("normalizes GitHub deploy-key style SCP remotes", () => { + expect(normalizeGitHubRemoteUrl("org-12345678@github.com:pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + }); + + it("normalizes GitHub HTTPS remotes", () => { + expect(normalizeGitHubRemoteUrl("https://github.com/pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + expect(normalizeGitHubRemoteUrl("https://www.github.com/pingdotgg/t3code.git")).toBe( + "https://github.com/pingdotgg/t3code", + ); + }); + + it("rejects non-GitHub remotes", () => { + expect(normalizeGitHubRemoteUrl("https://gitlab.com/pingdotgg/t3code.git")).toBeNull(); + }); +}); + +describe("resolveGitHubBlobUrlForLocalPath", () => { + it("uses the remote branch when origin already has it", async () => { + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + switch (key) { + case "rev-parse --show-toplevel": + return { stdout: "/repo\n", stderr: "" }; + case "remote get-url origin": + return { stdout: "git@github.com:pingdotgg/t3code.git\n", stderr: "" }; + case "rev-parse HEAD": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "symbolic-ref --quiet --short HEAD": + return { stdout: "feature/code-links\n", stderr: "" }; + case "rev-parse --verify --quiet refs/remotes/origin/feature/code-links": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "ls-files --error-unmatch -- apps/server/src/index.ts": + return { stdout: "apps/server/src/index.ts\n", stderr: "" }; + default: + throw new Error(`Unexpected git call: ${key}`); + } + }; + + await expect( + resolveGitHubBlobUrlForLocalPath("/repo/apps/server/src/index.ts:42", { execFile }), + ).resolves.toBe( + "https://github.com/pingdotgg/t3code/blob/feature/code-links/apps/server/src/index.ts#L42", + ); + }); + + it("falls back to the current commit sha for detached or local-only worktree refs", async () => { + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + switch (key) { + case "rev-parse --show-toplevel": + return { stdout: "/repo\n", stderr: "" }; + case "remote get-url origin": + return { stdout: "https://github.com/pingdotgg/t3code.git\n", stderr: "" }; + case "rev-parse HEAD": + return { stdout: "abc123def456\n", stderr: "" }; + case "symbolic-ref --quiet --short HEAD": + return { stdout: "t3code/1dd39f28\n", stderr: "" }; + case "rev-parse --verify --quiet refs/remotes/origin/t3code/1dd39f28": + throw new Error("missing remote branch"); + case "ls-files --error-unmatch -- packages/contracts/src/settings.ts": + return { stdout: "packages/contracts/src/settings.ts\n", stderr: "" }; + default: + throw new Error(`Unexpected git call: ${key}`); + } + }; + + await expect( + resolveGitHubBlobUrlForLocalPath("/repo/packages/contracts/src/settings.ts:316", { + execFile, + }), + ).resolves.toBe( + "https://github.com/pingdotgg/t3code/blob/abc123def456/packages/contracts/src/settings.ts#L316", + ); + }); + + it("returns null for files outside a GitHub-backed repo", async () => { + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + if (key === "rev-parse --show-toplevel") { + return { stdout: "/repo\n", stderr: "" }; + } + if (key === "remote get-url origin") { + return { stdout: "https://gitlab.com/pingdotgg/t3code.git\n", stderr: "" }; + } + if (key === "rev-parse HEAD") { + return { stdout: "abc123def456\n", stderr: "" }; + } + throw new Error(`Unexpected git call: ${key}`); + }; + + await expect(resolveGitHubBlobUrlForLocalPath("/repo/file.ts:1", { execFile })).resolves.toBe( + null, + ); + }); + + it("reuses cached repo context across multiple files in one repo", async () => { + let remoteCallCount = 0; + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + switch (key) { + case "rev-parse --show-toplevel": + return { stdout: "/repo\n", stderr: "" }; + case "remote get-url origin": + remoteCallCount += 1; + return { stdout: "git@github.com:pingdotgg/t3code.git\n", stderr: "" }; + case "rev-parse HEAD": + return { stdout: "abc123def456\n", stderr: "" }; + case "symbolic-ref --quiet --short HEAD": + return { stdout: "main\n", stderr: "" }; + case "rev-parse --verify --quiet refs/remotes/origin/main": + return { stdout: "abc123def456\n", stderr: "" }; + case "ls-files --error-unmatch -- a.ts": + return { stdout: "a.ts\n", stderr: "" }; + case "ls-files --error-unmatch -- b.ts": + return { stdout: "b.ts\n", stderr: "" }; + default: + throw new Error(`Unexpected git call: ${key}`); + } + }; + const repoContextCache = new Map(); + + await resolveGitHubBlobUrlForLocalPath("/repo/a.ts:1", { execFile, repoContextCache }); + await resolveGitHubBlobUrlForLocalPath("/repo/b.ts:2", { execFile, repoContextCache }); + + expect(remoteCallCount).toBe(1); + }); + + it("returns null for untracked files inside the repo", async () => { + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + switch (key) { + case "rev-parse --show-toplevel": + return { stdout: "/repo\n", stderr: "" }; + case "remote get-url origin": + return { stdout: "git@github.com:pingdotgg/t3code.git\n", stderr: "" }; + case "rev-parse HEAD": + return { stdout: "abc123def456\n", stderr: "" }; + case "symbolic-ref --quiet --short HEAD": + return { stdout: "main\n", stderr: "" }; + case "rev-parse --verify --quiet refs/remotes/origin/main": + return { stdout: "abc123def456\n", stderr: "" }; + case "ls-files --error-unmatch -- tmp/generated-report.html": + throw new Error("not tracked"); + default: + throw new Error(`Unexpected git call: ${key}`); + } + }; + + await expect( + resolveGitHubBlobUrlForLocalPath("/repo/tmp/generated-report.html:1", { execFile }), + ).resolves.toBe(null); + }); +}); + +describe("resolveGitHubBlobUrlForPathReference", () => { + it("resolves repo-relative line ranges against the provided cwd", async () => { + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + switch (key) { + case "rev-parse --show-toplevel": + return { stdout: "/repo\n", stderr: "" }; + case "remote get-url origin": + return { stdout: "git@github.com:pingdotgg/t3code.git\n", stderr: "" }; + case "rev-parse HEAD": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "symbolic-ref --quiet --short HEAD": + return { stdout: "main\n", stderr: "" }; + case "rev-parse --verify --quiet refs/remotes/origin/main": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "ls-files --error-unmatch -- api/src/EasyLife/Standard/RealPacking.Controllers.ts": + return { + stdout: "api/src/EasyLife/Standard/RealPacking.Controllers.ts\n", + stderr: "", + }; + default: + throw new Error(`Unexpected git call: ${key}`); + } + }; + + await expect( + resolveGitHubBlobUrlForPathReference( + "api/src/EasyLife/Standard/RealPacking.Controllers.ts:186-212", + { + cwd: "/repo", + execFile, + }, + ), + ).resolves.toBe( + "https://github.com/pingdotgg/t3code/blob/main/api/src/EasyLife/Standard/RealPacking.Controllers.ts#L186", + ); + }); +}); + +describe("GitHub link resolution cache", () => { + it("reuses git results across independent and concurrent resolutions", async () => { + const calls = new Map(); + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + calls.set(key, (calls.get(key) ?? 0) + 1); + switch (key) { + case "rev-parse --show-toplevel": + return { stdout: "/repo\n", stderr: "" }; + case "remote get-url origin": + return { stdout: "git@github.com:pingdotgg/t3code.git\n", stderr: "" }; + case "rev-parse HEAD": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "symbolic-ref --quiet --short HEAD": + return { stdout: "main\n", stderr: "" }; + case "rev-parse --verify --quiet refs/remotes/origin/main": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "ls-files --error-unmatch -- apps/server/src/index.ts": + return { stdout: "apps/server/src/index.ts\n", stderr: "" }; + default: + throw new Error(`Unexpected git call: ${key}`); + } + }; + const cache = createGitHubLinkResolutionCache(); + const options = { cache, execFile }; + + const [first, second] = await Promise.all([ + resolveGitHubBlobUrlForLocalPath("/repo/apps/server/src/index.ts:10", options), + resolveGitHubBlobUrlForLocalPath("/repo/apps/server/src/index.ts:20", options), + ]); + const third = await resolveGitHubBlobUrlForLocalPath( + "/repo/apps/server/src/index.ts:30", + options, + ); + const workspaceUrl = await resolveGitHubUrlForWorkspace("/repo", options); + + expect(first).toContain("#L10"); + expect(second).toContain("#L20"); + expect(third).toContain("#L30"); + expect(workspaceUrl).toBe("https://github.com/pingdotgg/t3code"); + expect([...calls.values()].every((count) => count === 1)).toBe(true); + }); + + it("refreshes tracked state after its short TTL", async () => { + let now = 0; + let tracked = true; + let trackedCalls = 0; + const execFile = async (_file: string, args: ReadonlyArray) => { + const key = args.slice(2).join(" "); + switch (key) { + case "rev-parse --show-toplevel": + return { stdout: "/repo\n", stderr: "" }; + case "remote get-url origin": + return { stdout: "git@github.com:pingdotgg/t3code.git\n", stderr: "" }; + case "rev-parse HEAD": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "symbolic-ref --quiet --short HEAD": + return { stdout: "main\n", stderr: "" }; + case "rev-parse --verify --quiet refs/remotes/origin/main": + return { stdout: "deadbeefcafebabe\n", stderr: "" }; + case "ls-files --error-unmatch -- generated/report.html": + trackedCalls += 1; + if (!tracked) throw new Error("not tracked"); + return { stdout: "generated/report.html\n", stderr: "" }; + default: + throw new Error(`Unexpected git call: ${key}`); + } + }; + const cache = createGitHubLinkResolutionCache({ now: () => now, trackedPathTtlMs: 10 }); + const options = { cache, execFile }; + + await expect( + resolveGitHubBlobUrlForLocalPath("/repo/generated/report.html", options), + ).resolves.toContain("/generated/report.html"); + tracked = false; + await expect( + resolveGitHubBlobUrlForLocalPath("/repo/generated/report.html", options), + ).resolves.toContain("/generated/report.html"); + + now = 11; + await expect( + resolveGitHubBlobUrlForLocalPath("/repo/generated/report.html", options), + ).resolves.toBeNull(); + expect(trackedCalls).toBe(2); + }); +}); diff --git a/apps/discord-bot/src/presentation/githubLinks.ts b/apps/discord-bot/src/presentation/githubLinks.ts new file mode 100644 index 00000000000..26982a53114 --- /dev/null +++ b/apps/discord-bot/src/presentation/githubLinks.ts @@ -0,0 +1,408 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +type ExecFileResult = { + readonly stdout: string; + readonly stderr: string; +}; + +type ExecFileLike = ( + file: string, + args: ReadonlyArray, + options?: NodeChildProcess.ExecFileOptions, +) => Promise; + +interface ParsedPathPosition { + readonly path: string; + readonly line?: number | undefined; +} + +interface GitHubRepositoryContext { + readonly repoRoot: string; + readonly githubUrl: string; + readonly ref: string; +} + +interface CacheEntry { + readonly expiresAt: number; + readonly value: Promise; +} + +export interface GitHubLinkResolutionCache { + readonly repositoryRoots: Map>; + readonly repositoryUrls: Map>; + readonly repositoryContexts: Map>; + readonly trackedPaths: Map>; + readonly now: () => number; + readonly maxEntries: number; + readonly repositoryRootTtlMs: number; + readonly repositoryContextTtlMs: number; + readonly trackedPathTtlMs: number; +} + +const DEFAULT_CACHE_OPTIONS = { + maxEntries: 2_048, + repositoryRootTtlMs: 60_000, + repositoryContextTtlMs: 15_000, + trackedPathTtlMs: 5_000, +} as const; + +export function createGitHubLinkResolutionCache( + options: Partial< + Pick< + GitHubLinkResolutionCache, + "maxEntries" | "repositoryRootTtlMs" | "repositoryContextTtlMs" | "trackedPathTtlMs" | "now" + > + > = {}, +): GitHubLinkResolutionCache { + return { + repositoryRoots: new Map(), + repositoryUrls: new Map(), + repositoryContexts: new Map(), + trackedPaths: new Map(), + now: options.now ?? Date.now, + maxEntries: options.maxEntries ?? DEFAULT_CACHE_OPTIONS.maxEntries, + repositoryRootTtlMs: options.repositoryRootTtlMs ?? DEFAULT_CACHE_OPTIONS.repositoryRootTtlMs, + repositoryContextTtlMs: + options.repositoryContextTtlMs ?? DEFAULT_CACHE_OPTIONS.repositoryContextTtlMs, + trackedPathTtlMs: options.trackedPathTtlMs ?? DEFAULT_CACHE_OPTIONS.trackedPathTtlMs, + }; +} + +const sharedResolutionCache = createGitHubLinkResolutionCache(); + +function cached(input: { + readonly entries: Map>; + readonly key: string; + readonly ttlMs: number; + readonly cache: GitHubLinkResolutionCache; + readonly load: () => Promise; +}): Promise { + const now = input.cache.now(); + const existing = input.entries.get(input.key); + if (existing && existing.expiresAt > now) { + return existing.value; + } + if (existing) input.entries.delete(input.key); + + while (input.entries.size >= input.cache.maxEntries) { + const oldest = input.entries.keys().next().value; + if (oldest === undefined) break; + input.entries.delete(oldest); + } + + const value = input.load(); + input.entries.set(input.key, { expiresAt: now + input.ttlMs, value }); + return value; +} + +/** + * Ensure git is discoverable even when the process PATH is a minimal systemd + * default that omits environment.systemPackages (common in the microVM units). + */ +export function gitCommandEnv(env: NodeJS.ProcessEnv = globalThis.process.env): NodeJS.ProcessEnv { + const prefixes = ["/run/current-system/sw/bin", "/usr/bin", "/bin"]; + const current = env.PATH ?? ""; + const merged = [...prefixes, ...current.split(":").filter((part) => part.length > 0)]; + return { + ...env, + PATH: [...new Set(merged)].join(":"), + }; +} + +function splitPathAndPosition(value: string): ParsedPathPosition { + let path = value; + let line: number | undefined; + + const columnMatch = path.match(/:(\d+)$/u); + if (!columnMatch?.[1]) { + return { path }; + } + + const trailing = Number.parseInt(columnMatch[1], 10); + path = path.slice(0, -columnMatch[0].length); + + const lineMatch = path.match(/:(\d+)$/u); + if (lineMatch?.[1]) { + line = Number.parseInt(lineMatch[1], 10); + path = path.slice(0, -lineMatch[0].length); + } else { + line = trailing; + } + + return Number.isFinite(line) ? { path, line } : { path }; +} + +function parsePathReference(value: string): ParsedPathPosition { + const trimmed = value.trim(); + const rangeMatch = + /^(?.+?\.[A-Za-z0-9_-]{1,16}):(?\d+)(?:-\d+)?(?:,\d+(?:-\d+)?)*$/u.exec(trimmed); + if (rangeMatch?.groups?.path && rangeMatch.groups.line) { + const line = Number.parseInt(rangeMatch.groups.line, 10); + return Number.isFinite(line) ? { path: rangeMatch.groups.path, line } : { path: trimmed }; + } + return splitPathAndPosition(trimmed); +} + +async function runGit( + cwd: string, + args: ReadonlyArray, + execImpl: ExecFileLike, +): Promise { + try { + const { stdout } = await execImpl("git", ["-C", cwd, ...args], { + cwd, + env: gitCommandEnv(), + }); + const trimmed = stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +async function resolveRepositoryContext( + repoRoot: string, + execImpl: ExecFileLike, + cache: GitHubLinkResolutionCache | null, +): Promise { + const githubUrl = await resolveRepositoryGitHubUrl(repoRoot, execImpl, cache); + if (!githubUrl) { + return null; + } + + const sha = await runGit(repoRoot, ["rev-parse", "HEAD"], execImpl); + if (!sha) { + return null; + } + + const branch = await runGit(repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"], execImpl); + if (!branch) { + return { repoRoot, githubUrl, ref: sha }; + } + + const remoteBranch = await runGit( + repoRoot, + ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`], + execImpl, + ); + + return { + repoRoot, + githubUrl, + ref: remoteBranch ? branch : sha, + }; +} + +async function resolveRepositoryGitHubUrl( + repoRoot: string, + execImpl: ExecFileLike, + cache: GitHubLinkResolutionCache | null, +): Promise { + const load = async () => { + const remoteUrl = await runGit(repoRoot, ["remote", "get-url", "origin"], execImpl); + return remoteUrl ? normalizeGitHubRemoteUrl(remoteUrl) : null; + }; + return cache + ? cached({ + entries: cache.repositoryUrls, + key: repoRoot, + ttlMs: cache.repositoryRootTtlMs, + cache, + load, + }) + : load(); +} + +async function isTrackedRepositoryPath( + repoRoot: string, + relativePath: string, + execImpl: ExecFileLike, +): Promise { + try { + await execImpl("git", ["-C", repoRoot, "ls-files", "--error-unmatch", "--", relativePath], { + cwd: repoRoot, + env: gitCommandEnv(), + }); + return true; + } catch { + return false; + } +} + +function resolutionCache(options: { + readonly execFile?: ExecFileLike | undefined; + readonly cache?: GitHubLinkResolutionCache | undefined; +}): GitHubLinkResolutionCache | null { + if (options.cache) return options.cache; + // Test/custom executors must never share results with production git calls. + return options.execFile ? null : sharedResolutionCache; +} + +function encodeGitHubPath(value: string): string { + return value + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function buildGitHubBlobUrl(input: { + readonly githubUrl: string; + readonly ref: string; + readonly relativePath: string; + readonly line?: number | undefined; +}): string { + const refPath = encodeGitHubPath(input.ref); + const relativePath = encodeGitHubPath(input.relativePath.replaceAll("\\", "/")); + const hash = input.line !== undefined ? `#L${input.line}` : ""; + return `${input.githubUrl}/blob/${refPath}/${relativePath}${hash}`; +} + +export function normalizeGitHubRemoteUrl(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) return null; + + // SCP-like, including deploy-key / machine users (e.g. org-123@github.com:owner/repo). + const scpLike = /^(?:[\w.-]+@)?github\.com:([^/\s]+)\/(.+?)(?:\.git)?$/u.exec(trimmed); + if (scpLike) { + return `https://github.com/${scpLike[1]}/${scpLike[2]}`; + } + + // ssh:// with optional user and optional port (ssh.github.com is GitHub's HTTPS-port SSH endpoint). + const sshLike = + /^ssh:\/\/(?:[\w.-]+@)?(?:github\.com|ssh\.github\.com)(?::\d+)?\/([^/\s]+)\/(.+?)(?:\.git)?$/u.exec( + trimmed, + ); + if (sshLike) { + return `https://github.com/${sshLike[1]}/${sshLike[2]}`; + } + + try { + const url = new URL(trimmed); + const host = url.hostname.toLowerCase(); + if (host !== "github.com" && host !== "www.github.com" && host !== "ssh.github.com") { + return null; + } + const path = url.pathname.replace(/\.git$/u, "").replace(/\/+$/u, ""); + if (path === "" || !path.includes("/", 1)) return null; + return `https://github.com${path}`; + } catch { + return null; + } +} + +export async function resolveGitHubBlobUrlForLocalPath( + filePath: string, + options?: { + readonly execFile?: ExecFileLike | undefined; + readonly repoContextCache?: Map | undefined; + readonly cache?: GitHubLinkResolutionCache | undefined; + }, +): Promise { + const execImpl = options?.execFile ?? execFile; + const parsed = parsePathReference(filePath); + if (!NodePath.isAbsolute(parsed.path)) { + return null; + } + + const fileDirectory = NodePath.dirname(parsed.path); + const cache = resolutionCache(options ?? {}); + const repoRoot = cache + ? await cached({ + entries: cache.repositoryRoots, + key: fileDirectory, + ttlMs: cache.repositoryRootTtlMs, + cache, + load: () => runGit(fileDirectory, ["rev-parse", "--show-toplevel"], execImpl), + }) + : await runGit(fileDirectory, ["rev-parse", "--show-toplevel"], execImpl); + if (!repoRoot) { + return null; + } + + const requestCache = options?.repoContextCache; + const cachedContext = requestCache?.get(repoRoot); + const context = + cachedContext !== undefined + ? cachedContext + : cache + ? await cached({ + entries: cache.repositoryContexts, + key: repoRoot, + ttlMs: cache.repositoryContextTtlMs, + cache, + load: () => resolveRepositoryContext(repoRoot, execImpl, cache), + }) + : await resolveRepositoryContext(repoRoot, execImpl, null); + if (cachedContext === undefined) { + requestCache?.set(repoRoot, context); + } + if (!context) { + return null; + } + + const relativePath = NodePath.relative(context.repoRoot, parsed.path); + if (relativePath === "" || relativePath.startsWith("..") || NodePath.isAbsolute(relativePath)) { + return null; + } + const trackedCacheKey = `${context.repoRoot}\0${relativePath}`; + const tracked = cache + ? await cached({ + entries: cache.trackedPaths, + key: trackedCacheKey, + ttlMs: cache.trackedPathTtlMs, + cache, + load: () => isTrackedRepositoryPath(context.repoRoot, relativePath, execImpl), + }) + : await isTrackedRepositoryPath(context.repoRoot, relativePath, execImpl); + if (!tracked) { + return null; + } + + return buildGitHubBlobUrl({ + githubUrl: context.githubUrl, + ref: context.ref, + relativePath, + line: parsed.line, + }); +} + +export async function resolveGitHubBlobUrlForPathReference( + pathReference: string, + options?: { + readonly cwd?: string | undefined; + readonly execFile?: ExecFileLike | undefined; + readonly repoContextCache?: Map | undefined; + readonly cache?: GitHubLinkResolutionCache | undefined; + }, +): Promise { + const parsed = parsePathReference(pathReference); + const absolutePath = NodePath.isAbsolute(parsed.path) + ? parsed.path + : options?.cwd + ? NodePath.resolve(options.cwd, parsed.path) + : null; + if (absolutePath === null) { + return null; + } + + const suffix = parsed.line !== undefined ? `:${parsed.line}` : ""; + return resolveGitHubBlobUrlForLocalPath(`${absolutePath}${suffix}`, options); +} + +export async function resolveGitHubUrlForWorkspace( + workspaceRoot: string, + options?: { + readonly execFile?: ExecFileLike | undefined; + readonly cache?: GitHubLinkResolutionCache | undefined; + }, +): Promise { + const execImpl = options?.execFile ?? execFile; + const cache = resolutionCache(options ?? {}); + return resolveRepositoryGitHubUrl(NodePath.resolve(workspaceRoot), execImpl, cache); +} diff --git a/apps/discord-bot/src/presentation/jiraLinks.test.ts b/apps/discord-bot/src/presentation/jiraLinks.test.ts new file mode 100644 index 00000000000..3762bd4d58f --- /dev/null +++ b/apps/discord-bot/src/presentation/jiraLinks.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + extractJiraIssueKeys, + extractJiraIssueKeysFromDiscordMessage, + formatJiraLinksForDiscord, + jiraBrowseUrl, + mergeJiraIssueKeys, + normalizeJiraIssueKey, + resolveJiraBrowseBaseUrl, +} from "./jiraLinks.ts"; + +describe("normalizeJiraIssueKey", () => { + it("uppercases valid keys", () => { + expect(normalizeJiraIssueKey("proj-367")).toBe("PROJ-367"); + expect(normalizeJiraIssueKey("ACV2-642")).toBe("ACV2-642"); + }); + + it("rejects invalid shapes", () => { + expect(normalizeJiraIssueKey("EXAMPLE-PROJECT-API-JW")).toBeNull(); + expect(normalizeJiraIssueKey("not-a-key")).toBeNull(); + expect(normalizeJiraIssueKey("A-1")).toBeNull(); + }); +}); + +describe("resolveJiraBrowseBaseUrl", () => { + it("keeps classic atlassian site roots", () => { + expect(resolveJiraBrowseBaseUrl("https://example.atlassian.net/")).toBe( + "https://example.atlassian.net", + ); + expect(resolveJiraBrowseBaseUrl("https://example.atlassian.net/browse/PROJ-1")).toBe( + "https://example.atlassian.net", + ); + }); + + it("rejects API gateway URLs that are not browse sites", () => { + expect( + resolveJiraBrowseBaseUrl( + "https://api.atlassian.com/ex/jira/a6978c4b-b79e-4b1f-83ea-c537c2b19316", + ), + ).toBeUndefined(); + }); +}); + +describe("extractJiraIssueKeys", () => { + it("extracts bare keys in first-seen order without duplicates", () => { + expect( + extractJiraIssueKeys("Please look at PROJ-367 then PROJ-400 and PROJ-367 again"), + ).toEqual(["PROJ-367", "PROJ-400"]); + }); + + it("extracts keys from browse URLs and selectedIssue params", () => { + const text = [ + "https://example.atlassian.net/browse/PROJ-100", + "also https://example.atlassian.net/jira/software/c/projects/SA/boards/1?selectedIssue=PROJ-200", + "and PROJ-100 again", + ].join(" "); + expect(extractJiraIssueKeys(text)).toEqual(["PROJ-100", "PROJ-200"]); + }); + + it("reads embeds as well as content", () => { + expect( + extractJiraIssueKeysFromDiscordMessage({ + content: "ping", + embeds: [ + { + url: "https://example.atlassian.net/browse/PROJ-50", + title: "PROJ-50 Fix packing", + }, + ], + }), + ).toEqual(["PROJ-50"]); + }); +}); + +describe("mergeJiraIssueKeys", () => { + it("appends only new keys in order", () => { + expect(mergeJiraIssueKeys(["PROJ-1", "PROJ-2"], ["PROJ-2", "PROJ-3", "proj-1"])).toEqual([ + "PROJ-1", + "PROJ-2", + "PROJ-3", + ]); + }); +}); + +describe("formatJiraLinksForDiscord", () => { + it("renders markdown links when browse base is set", () => { + expect(formatJiraLinksForDiscord(["PROJ-367"], "https://example.atlassian.net")).toBe( + ["**Jira**", "• [PROJ-367](https://example.atlassian.net/browse/PROJ-367)"].join("\n"), + ); + }); + + it("falls back to bare keys without a browse base", () => { + expect(formatJiraLinksForDiscord(["PROJ-367"], undefined)).toBe( + ["**Jira**", "• `PROJ-367`"].join("\n"), + ); + }); + + it("returns null for empty key lists", () => { + expect(formatJiraLinksForDiscord([], "https://example.atlassian.net")).toBeNull(); + }); +}); + +describe("jiraBrowseUrl", () => { + it("builds browse URLs", () => { + expect(jiraBrowseUrl("https://example.atlassian.net", "proj-367")).toBe( + "https://example.atlassian.net/browse/PROJ-367", + ); + }); +}); diff --git a/apps/discord-bot/src/presentation/jiraLinks.ts b/apps/discord-bot/src/presentation/jiraLinks.ts new file mode 100644 index 00000000000..628bd080595 --- /dev/null +++ b/apps/discord-bot/src/presentation/jiraLinks.ts @@ -0,0 +1,156 @@ +/** + * Extract and format Jira issue keys / browse links from Discord message text. + * + * Keys are stored in first-seen order. Duplicates are ignored (case-insensitive match + * with canonical uppercase key form). + */ + +/** Classic Jira issue key: PROJ-123 (project 2–10 alnum chars, numeric id). */ +const JIRA_ISSUE_KEY_PATTERN = /\b([A-Z][A-Z0-9]{1,9}-\d{1,7})\b/g; + +/** Browse-style Atlassian URLs that embed an issue key. */ +const JIRA_BROWSE_URL_PATTERN = + /https?:\/\/[^\s<>"']+\.atlassian\.net\/(?:browse|jira\/browse)\/([A-Z][A-Z0-9]{1,9}-\d{1,7})(?:[^\s<>"']*)?/gi; + +/** Board / issue navigator deep links: ?selectedIssue=PROJ-123 */ +const JIRA_SELECTED_ISSUE_PATTERN = /[?&]selectedIssue=([A-Z][A-Z0-9]{1,9}-\d{1,7})\b/gi; + +const FALSE_POSITIVE_KEYS = new Set([ + // Common non-Jira tokens that match the key shape + "UTF-8", + "ISO-8601", + "HTTP-1", + "HTTP-2", + "TLS-1", +]); + +export function normalizeJiraIssueKey(raw: string): string | null { + const key = raw.trim().toUpperCase(); + if (!/^[A-Z][A-Z0-9]{1,9}-\d{1,7}$/u.test(key)) return null; + if (FALSE_POSITIVE_KEYS.has(key)) return null; + return key; +} + +/** + * Strip trailing slash and normalize a browse base URL. + * Accepts either `https://org.atlassian.net` or a full API URL that embeds the site. + */ +export function resolveJiraBrowseBaseUrl(raw: string | undefined | null): string | undefined { + if (raw === undefined || raw === null) return undefined; + const trimmed = raw.trim().replace(/\/+$/u, ""); + if (trimmed.length === 0) return undefined; + + // Classic site URL + const siteMatch = trimmed.match(/^(https?:\/\/[a-z0-9.-]+\.atlassian\.net)(?:\/.*)?$/iu); + if (siteMatch?.[1] !== undefined) return siteMatch[1]; + + // Leave non-atlassian bases as-is when they look like a site root (no /ex/jira/) + if (/^https?:\/\//iu.test(trimmed) && !trimmed.includes("/ex/jira/")) { + return trimmed; + } + return undefined; +} + +export function jiraBrowseUrl(baseUrl: string | undefined, key: string): string | null { + const normalized = normalizeJiraIssueKey(key); + if (normalized === null) return null; + const base = resolveJiraBrowseBaseUrl(baseUrl); + if (base === undefined) return null; + return `${base}/browse/${normalized}`; +} + +/** + * Extract issue keys from free text (message content, embed fields, etc.) + * in left-to-right first-seen order without duplicates. + */ +export function extractJiraIssueKeys(text: string | null | undefined): ReadonlyArray { + if (text === null || text === undefined || text.length === 0) return []; + + const found: string[] = []; + const seen = new Set(); + + const push = (raw: string) => { + const key = normalizeJiraIssueKey(raw); + if (key === null || seen.has(key)) return; + seen.add(key); + found.push(key); + }; + + // Prefer URL-sourced keys first so they appear in URL order when mixed with bare keys + // in the same string — still overall left-to-right via a single scan of positions. + type Hit = { readonly index: number; readonly key: string }; + const hits: Hit[] = []; + + for (const pattern of [ + JIRA_BROWSE_URL_PATTERN, + JIRA_SELECTED_ISSUE_PATTERN, + JIRA_ISSUE_KEY_PATTERN, + ]) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const key = normalizeJiraIssueKey(match[1] ?? ""); + if (key === null) continue; + hits.push({ index: match.index, key }); + } + } + + hits.sort((a, b) => a.index - b.index || a.key.localeCompare(b.key)); + for (const hit of hits) { + push(hit.key); + } + return found; +} + +export function extractJiraIssueKeysFromDiscordMessage(input: { + readonly content?: string | null | undefined; + readonly embeds?: + | ReadonlyArray<{ + readonly url?: string | null | undefined; + readonly title?: string | null | undefined; + readonly description?: string | null | undefined; + readonly footer?: { readonly text?: string | null | undefined } | null | undefined; + }> + | null + | undefined; +}): ReadonlyArray { + const parts: string[] = []; + if (input.content) parts.push(input.content); + for (const embed of input.embeds ?? []) { + if (embed.url) parts.push(embed.url); + if (embed.title) parts.push(embed.title); + if (embed.description) parts.push(embed.description); + if (embed.footer?.text) parts.push(embed.footer.text); + } + return extractJiraIssueKeys(parts.join("\n")); +} + +/** Append newly seen keys preserving first-seen order; never duplicates. */ +export function mergeJiraIssueKeys( + existing: ReadonlyArray | null | undefined, + incoming: ReadonlyArray | null | undefined, +): ReadonlyArray { + const result: string[] = []; + const seen = new Set(); + for (const raw of [...(existing ?? []), ...(incoming ?? [])]) { + const key = normalizeJiraIssueKey(raw); + if (key === null || seen.has(key)) continue; + seen.add(key); + result.push(key); + } + return result; +} + +export function formatJiraLinksForDiscord( + keys: ReadonlyArray, + browseBaseUrl: string | undefined, +): string | null { + const ordered = mergeJiraIssueKeys([], keys); + if (ordered.length === 0) return null; + + const lines = ordered.map((key) => { + const url = jiraBrowseUrl(browseBaseUrl, key); + return url === null ? `• \`${key}\`` : `• [${key}](${url})`; + }); + return ["**Jira**", ...lines].join("\n"); +} diff --git a/apps/discord-bot/src/presentation/markdownFiles.test.ts b/apps/discord-bot/src/presentation/markdownFiles.test.ts new file mode 100644 index 00000000000..7b6700cd2a5 --- /dev/null +++ b/apps/discord-bot/src/presentation/markdownFiles.test.ts @@ -0,0 +1,118 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + extractMarkdownLocalFileLinks, + fileNameForLocalFileRef, + guessFileMimeType, + isLocalFileSrc, + replaceMarkdownLocalFileLinks, + resolveLocalFilePathOnDisk, + stripMarkdownLocalFileLinks, +} from "./markdownFiles.ts"; + +const SAMPLE_CSV_PATH = "/tmp/carrier_usage/label_ops_proxy.csv"; + +describe("extractMarkdownLocalFileLinks", () => { + it("parses local csv markdown links", () => { + const text = `And the extracted table here: +[label_ops_proxy.csv](${SAMPLE_CSV_PATH})`; + const files = extractMarkdownLocalFileLinks(text); + expect(files).toHaveLength(1); + expect(files[0]?.src).toBe(SAMPLE_CSV_PATH); + expect(files[0]?.label).toBe("label_ops_proxy.csv"); + }); + + it("parses local file links with line numbers", () => { + const text = `[githubLinks.ts](/tmp/project/githubLinks.ts:96)`; + const files = extractMarkdownLocalFileLinks(text); + expect(files).toHaveLength(1); + expect(files[0]?.src).toBe("/tmp/project/githubLinks.ts"); + expect(files[0]?.target).toBe("/tmp/project/githubLinks.ts:96"); + }); + + it("ignores http links and local image links", () => { + const text = ` +[report.csv](https://example.com/report.csv) +[graph.png](/tmp/carrier_usage/graph.png) +`; + expect(extractMarkdownLocalFileLinks(text)).toEqual([]); + }); +}); + +describe("stripMarkdownLocalFileLinks", () => { + it("removes local file links but keeps surrounding prose", () => { + const text = `I saved the file here: + +[label_ops_proxy.csv](<${SAMPLE_CSV_PATH}>) + +Use it if you need exact counts.`; + const stripped = stripMarkdownLocalFileLinks(text); + expect(stripped).not.toContain("label_ops_proxy.csv]("); + expect(stripped).toContain("I saved the file here:"); + expect(stripped).toContain("Use it if you need exact counts."); + }); + + it("can replace local file links with readable attached markers", () => { + const text = `I saved the file here: + +[label_ops_proxy.csv](<${SAMPLE_CSV_PATH}>)`; + const replaced = replaceMarkdownLocalFileLinks(text, (ref) => `${ref.label} (attached below)`); + expect(replaced).toContain("I saved the file here:"); + expect(replaced).toContain("label_ops_proxy.csv (attached below)"); + expect(replaced).not.toContain("]("); + }); +}); + +describe("local file helpers", () => { + it("detects attachment: and relative local files", () => { + expect(isLocalFileSrc(`attachment:${SAMPLE_CSV_PATH}`)).toBe(true); + expect(isLocalFileSrc("./artifacts/table.csv")).toBe(true); + expect(isLocalFileSrc("https://example.com/table.csv")).toBe(false); + }); + + it("uses the visible label for the Discord attachment name", () => { + const ref = extractMarkdownLocalFileLinks(`[carrier data](${SAMPLE_CSV_PATH})`)[0]; + expect(ref).toBeDefined(); + expect(fileNameForLocalFileRef(ref!)).toBe("carrier_data.csv"); + }); + + it("guesses csv mime types", () => { + expect(guessFileMimeType(SAMPLE_CSV_PATH)).toBe("text/plain;charset=utf-8"); + expect(guessFileMimeType("/tmp/data.json")).toBe("text/plain;charset=utf-8"); + }); + + it("uses native media types for audio and video previews", () => { + expect(guessFileMimeType("/tmp/clip.mp4")).toBe("video/mp4"); + expect(guessFileMimeType("/tmp/voice.mp3")).toBe("audio/mpeg"); + }); +}); + +describe("resolveLocalFilePathOnDisk", () => { + it("resolves worktree-relative plan paths that agents emit", () => { + const tempRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-discord-md-file-")); + try { + const plansDir = NodePath.join(tempRoot, ".plans"); + const relativeFile = ".plans/abas-markisen-agent-collisions.md"; + const absoluteFile = NodePath.join(tempRoot, relativeFile); + NodeFS.mkdirSync(plansDir, { recursive: true }); + NodeFS.writeFileSync(absoluteFile, "# note\n", "utf8"); + + expect(resolveLocalFilePathOnDisk(relativeFile, tempRoot)).toBe( + NodePath.normalize(absoluteFile), + ); + expect(resolveLocalFilePathOnDisk(`./${relativeFile}`, tempRoot)).toBe( + NodePath.normalize(absoluteFile), + ); + // Absolute path still works when present. + expect(resolveLocalFilePathOnDisk(absoluteFile, null)).toBe(NodePath.normalize(absoluteFile)); + // Without worktree, bot cwd cannot see the project-relative path. + expect(resolveLocalFilePathOnDisk(relativeFile, null)).toBeNull(); + } finally { + NodeFS.rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/discord-bot/src/presentation/markdownFiles.ts b/apps/discord-bot/src/presentation/markdownFiles.ts new file mode 100644 index 00000000000..430b31f8410 --- /dev/null +++ b/apps/discord-bot/src/presentation/markdownFiles.ts @@ -0,0 +1,225 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + assertFilesystemPath, + guessImageMimeType, + normalizeLocalImagePath, +} from "./markdownImages.ts"; + +export interface MarkdownLocalFileRef { + readonly label: string; + /** Normalized filesystem path without any :line or :line:column suffix. */ + readonly src: string; + /** Normalized link target including any :line or :line:column suffix. */ + readonly target: string; + /** Original destination before normalization (for debugging). */ + readonly rawSrc: string; + /** Full match including the markdown link syntax. */ + readonly match: string; +} + +/** [label](url) / [label]() */ +const MARKDOWN_LINK = /(?]+?)>?\s*\)/g; + +function splitLocalFileTarget(value: string): { + readonly path: string; + readonly line?: string | undefined; + readonly column?: string | undefined; +} { + let path = value; + let column: string | undefined; + let line: string | undefined; + + const columnMatch = path.match(/:(\d+)$/u); + if (!columnMatch?.[1]) { + return { path }; + } + + column = columnMatch[1]; + path = path.slice(0, -columnMatch[0].length); + + const lineMatch = path.match(/:(\d+)$/u); + if (lineMatch?.[1]) { + line = lineMatch[1]; + path = path.slice(0, -lineMatch[0].length); + } else { + line = column; + column = undefined; + } + + return { path, line, column }; +} + +function hasFileLikeName(path: string): boolean { + const base = path.split(/[/\\]/).at(-1) ?? ""; + return /\.[A-Za-z0-9_-]{1,16}$/u.test(base); +} + +export function isLocalFileSrc(src: string): boolean { + const raw = src.trim(); + if (raw === "") return false; + if (/^https?:\/\//i.test(raw)) return false; + if (/^data:/i.test(raw)) return false; + + const normalized = normalizeLocalImagePath(raw); + if (normalized === "") return false; + if (/^https?:\/\//i.test(normalized) || /^data:/i.test(normalized)) return false; + if (/\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(normalized)) return false; + + if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalized)) return true; + if (normalized.startsWith("./") || normalized.startsWith("../")) return true; + return hasFileLikeName(normalized); +} + +function looksLikeLocalFileTarget(rawSrc: string): boolean { + if (!isLocalFileSrc(rawSrc)) return false; + return hasFileLikeName(splitLocalFileTarget(normalizeLocalImagePath(rawSrc)).path); +} + +export function extractMarkdownLocalFileLinks(text: string): ReadonlyArray { + const results: MarkdownLocalFileRef[] = []; + const seen = new Set(); + + for (const match of text.matchAll(MARKDOWN_LINK)) { + const full = match[0]; + const label = (match[1] ?? "").trim(); + const rawSrc = (match[2] ?? "").trim(); + if (full === undefined || rawSrc === "") continue; + if (!looksLikeLocalFileTarget(rawSrc)) continue; + + const target = normalizeLocalImagePath(rawSrc); + const src = splitLocalFileTarget(target).path; + const key = `${target}::${full}`; + if (seen.has(key)) continue; + seen.add(key); + results.push({ + label, + src, + target, + rawSrc, + match: full, + }); + } + + return results; +} + +export function stripMarkdownLocalFileLinks(text: string): string { + const refs = extractMarkdownLocalFileLinks(text); + let out = text; + const ordered = [...refs].sort((a, b) => b.match.length - a.match.length); + for (const ref of ordered) { + out = out.split(ref.match).join(""); + } + return out.replace(/\n{3,}/g, "\n\n").trimEnd(); +} + +export function replaceMarkdownLocalFileLinks( + text: string, + replacer: (ref: MarkdownLocalFileRef) => string, +): string { + const refs = extractMarkdownLocalFileLinks(text); + let out = text; + const ordered = [...refs].sort((a, b) => b.match.length - a.match.length); + for (const ref of ordered) { + out = out.split(ref.match).join(replacer(ref)); + } + return out.replace(/\n{3,}/g, "\n\n").trimEnd(); +} + +export function fileNameForLocalFileRef(ref: MarkdownLocalFileRef): string { + const path = assertFilesystemPath(ref.src); + const base = path.split(/[/\\]/).at(-1) ?? "attachment.bin"; + const ext = /\.[A-Za-z0-9_-]{1,16}$/u.exec(base)?.[0] ?? ""; + + const label = ref.label + .trim() + .replace(/[^\w.-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .slice(0, 128); + if (label.length > 0) { + if (ext !== "" && !label.toLowerCase().endsWith(ext.toLowerCase())) { + return `${label}${ext}`; + } + return label; + } + return base; +} + +/** + * Resolve a local markdown-linked file to an absolute path on disk. + * + * Agents usually write worktree-relative targets (`.plans/note.md`, `./out.csv`). + * The Discord bot process cwd is the bot package — resolve against `worktreePath` + * first so relative embeds actually upload. + */ +export function resolveLocalFilePathOnDisk( + path: string, + worktreePath?: string | null, +): string | null { + const normalized = assertFilesystemPath(path); + if (normalized === "" || /^https?:\/\//i.test(normalized) || /^data:/i.test(normalized)) { + return null; + } + + if (NodePath.isAbsolute(normalized)) { + return NodeFS.existsSync(normalized) ? NodePath.normalize(normalized) : null; + } + + const rel = normalized.replace(/^\.\//u, ""); + const roots: string[] = []; + const worktree = worktreePath?.trim() ?? ""; + if (worktree !== "") { + roots.push(worktree); + } + // Fallbacks when worktree is unknown (local / no-worktree threads). + roots.push(process.cwd()); + + for (const root of roots) { + const candidate = NodePath.join(root, rel); + if (NodeFS.existsSync(candidate) && NodeFS.statSync(candidate).isFile()) { + return NodePath.normalize(candidate); + } + } + + return null; +} + +export function guessFileMimeType(filePath: string): string { + const lower = filePath.toLowerCase(); + if (/\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(lower)) { + return guessImageMimeType(lower); + } + if (lower.endsWith(".mp4")) return "video/mp4"; + if (lower.endsWith(".mov")) return "video/quicktime"; + if (lower.endsWith(".webm")) return "video/webm"; + if (lower.endsWith(".m4v")) return "video/x-m4v"; + if (lower.endsWith(".mp3")) return "audio/mpeg"; + if (lower.endsWith(".m4a")) return "audio/mp4"; + if (lower.endsWith(".wav")) return "audio/wav"; + if (lower.endsWith(".ogg")) return "audio/ogg"; + if (lower.endsWith(".flac")) return "audio/flac"; + // Discord documents preview support for plain text files rather than + // structured formats like CSV/JSON specifically, so prefer text/plain for + // text-ish artifacts to preserve the best chance of inline preview. + if ( + lower.endsWith(".csv") || + lower.endsWith(".txt") || + lower.endsWith(".log") || + lower.endsWith(".md") || + lower.endsWith(".json") || + lower.endsWith(".yaml") || + lower.endsWith(".yml") + ) { + return "text/plain;charset=utf-8"; + } + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".zip")) return "application/zip"; + if (lower.endsWith(".gz")) return "application/gzip"; + return "application/octet-stream"; +} + +export { assertFilesystemPath }; diff --git a/apps/discord-bot/src/presentation/markdownImages.test.ts b/apps/discord-bot/src/presentation/markdownImages.test.ts new file mode 100644 index 00000000000..a9b5f54c675 --- /dev/null +++ b/apps/discord-bot/src/presentation/markdownImages.test.ts @@ -0,0 +1,131 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { describe, expect, it } from "vite-plus/test"; + +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + assertFilesystemPath, + extractMarkdownImages, + fileNameForImageRef, + isLocalImageSrc, + normalizeLocalImagePath, + resolveImagePathOnDisk, + stripMarkdownImages, +} from "./markdownImages.ts"; + +const SAMPLE_PATH = + "/var/lib/t3/.codex/generated_images/019f6511-7b93-7663-9908-41e3f45b5bac/call_5K1KcXmRdTGulQT91c2PtIl6.png"; + +describe("extractMarkdownImages", () => { + it("parses markdown images with angle brackets", () => { + const text = `![EasyLife]()`; + const images = extractMarkdownImages(text); + expect(images).toHaveLength(1); + expect(images[0]?.src).toContain("generated_images"); + expect(images[0]?.src.startsWith("attachment:")).toBe(false); + }); + + it("parses HTML img tags like Discord screenshot", () => { + const text = `EasyLife weekday activity estimate graph + +I can't force this client to inline a local filesystem image.`; + const images = extractMarkdownImages(text); + expect(images).toHaveLength(1); + expect(images[0]?.src).toBe(SAMPLE_PATH); + expect(images[0]?.alt).toContain("EasyLife"); + const stripped = stripMarkdownImages(text); + expect(stripped).not.toContain(" { + const text = `The image file is here: + +[call_5K1KcXmRdTGulQT91c2PtIl6.png](<${SAMPLE_PATH}>) + +If you want, I can regenerate it.`; + const images = extractMarkdownImages(text); + expect(images).toHaveLength(1); + expect(images[0]?.src).toBe(SAMPLE_PATH); + const stripped = stripMarkdownImages(text); + expect(stripped).not.toContain("call_5K1Kc"); + expect(stripped).toContain("If you want"); + }); + + it("extracts both html img and md link from the same message", () => { + const text = `EasyLife graph + +I can't force this client. + +[call_5K1KcXmRdTGulQT91c2PtIl6.png](<${SAMPLE_PATH}>) + +Regenerate?`; + const images = extractMarkdownImages(text); + // Same path twice is fine — load once by src key; extract may return both embeds + expect(images.length).toBeGreaterThanOrEqual(1); + expect(images.every((i) => i.src === SAMPLE_PATH)).toBe(true); + const stripped = stripMarkdownImages(text); + expect(stripped).not.toContain(" { + expect(normalizeLocalImagePath(`attachment:${SAMPLE_PATH}`)).toBe(SAMPLE_PATH); + expect(assertFilesystemPath(`attachment:${SAMPLE_PATH}`)).toBe(SAMPLE_PATH); + expect(isLocalImageSrc(`attachment:${SAMPLE_PATH}`)).toBe(true); + }); + + it("extracts attachment: markdown images", () => { + const text = `![EasyLife](attachment:${SAMPLE_PATH})`; + const images = extractMarkdownImages(text); + expect(images).toHaveLength(1); + expect(images[0]?.src).toBe(SAMPLE_PATH); + // Prefer human alt for Discord attachment name (image preview still uses .png). + expect(fileNameForImageRef(images[0]!)).toBe("EasyLife.png"); + }); + + it("falls back to path basename when alt is empty", () => { + const text = `![](${SAMPLE_PATH})`; + const images = extractMarkdownImages(text); + expect(images).toHaveLength(1); + expect(fileNameForImageRef(images[0]!)).toBe("call_5K1KcXmRdTGulQT91c2PtIl6.png"); + }); + + it("treats Grok session-relative images/1.jpg as a local image", () => { + const text = `Here’s a soft blush peony:\n\n![Beautiful flower](images/1.jpg)\n`; + const images = extractMarkdownImages(text); + expect(images).toHaveLength(1); + expect(images[0]?.src).toBe("images/1.jpg"); + expect(fileNameForImageRef(images[0]!)).toBe("Beautiful_flower.jpg"); + }); +}); + +describe("resolveImagePathOnDisk", () => { + it("resolves Grok session-relative images under a sessions tree", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-mdimg-")); + const sessionImages = NodePath.join(root, "encoded-cwd", "session-id", "images"); + NodeFS.mkdirSync(sessionImages, { recursive: true }); + const absolute = NodePath.join(sessionImages, "1.jpg"); + NodeFS.writeFileSync(absolute, Buffer.from([0xff, 0xd8, 0xff, 0xd9])); // minimal jpeg-ish + + // Point search at our temp root by temporarily resolving via absolute path existence first. + expect(resolveImagePathOnDisk(absolute)).toBe(absolute); + + // Relative form: only finds files under HOME/.grok/sessions in prod. For unit coverage, + // verify absolute + cwd cases; relative search is integration-tested on the guest. + const cwdRel = NodePath.join(root, "images"); + NodeFS.mkdirSync(cwdRel, { recursive: true }); + const cwdFile = NodePath.join(cwdRel, "2.png"); + NodeFS.writeFileSync(cwdFile, Buffer.from([1, 2, 3])); + const prev = process.cwd(); + try { + process.chdir(root); + expect(resolveImagePathOnDisk("images/2.png")).toBe(NodeFS.realpathSync(cwdFile)); + } finally { + process.chdir(prev); + } + }); +}); diff --git a/apps/discord-bot/src/presentation/markdownImages.ts b/apps/discord-bot/src/presentation/markdownImages.ts new file mode 100644 index 00000000000..4484837ce96 --- /dev/null +++ b/apps/discord-bot/src/presentation/markdownImages.ts @@ -0,0 +1,339 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Agents embed local filesystem images in several wire formats: + * ![alt](/var/lib/t3/.codex/generated_images/.../file.png) + * ![alt](attachment:/var/lib/...) + * ... + * [file.png]() + * ![alt](images/1.jpg) — Grok ImageGen session-relative path + * + * Discord cannot render host paths — they must become real multipart file attachments. + */ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export interface MarkdownImageRef { + readonly alt: string; + /** Normalized filesystem path (never has attachment: / file:// prefixes). */ + readonly src: string; + /** Original destination before normalization (for debugging). */ + readonly rawSrc: string; + /** Full match including the embed syntax */ + readonly match: string; +} + +/** ![alt](url) and ![alt]() */ +const MARKDOWN_IMAGE = /!\[([^\]]*)\]\(\s*]+?)>?\s*\)/g; + +/** [label](url) / [label]() — only treated as images when the target looks local+image */ +const MARKDOWN_LINK = /(?]+?)>?\s*\)/g; + +/** HTML ... (attribute order flexible enough for common agent output) */ +const HTML_IMG = + /]*\bsrc\s*=\s*["']([^"']+)["'][^>]*\/?>|]*\bsrc\s*=\s*([^\s>]+)[^>]*\/?>/gi; + +/** ACP / Codex media scheme prefixes that are not real filesystem paths. */ +const MEDIA_SCHEME_PREFIX = /^(?:attachment:\/?\/?|file:\/\/)/i; + +const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i; + +/** + * Normalize agent/Codex image targets to a bare filesystem path (or leave http(s)). + * Always strips angle brackets and attachment:/file:// schemes — repeatedly if nested. + */ +export function normalizeLocalImagePath(src: string): string { + let value = src.trim(); + if (value.startsWith("<") && value.endsWith(">")) { + value = value.slice(1, -1).trim(); + } + + // Strip media schemes until gone. + // IMPORTANT: only strip the scheme token — keep the leading `/` of absolute paths. + // attachment:/var/lib/... → /var/lib/... + for (let i = 0; i < 4; i += 1) { + if (/^https?:\/\//i.test(value) || /^data:/i.test(value)) { + return value; + } + if (/^attachment:/i.test(value)) { + value = value.replace(/^attachment:/i, "").trim(); + if (value.startsWith("//")) { + value = `/${value.replace(/^\/+/, "")}`; + } + continue; + } + if (/^file:/i.test(value)) { + try { + value = decodeURIComponent(new URL(value).pathname); + if (/^\/[A-Za-z]:[\\/]/.test(value)) { + value = value.slice(1); + } + } catch { + value = value + .replace(/^file:\/\//i, "") + .replace(/^file:/i, "") + .trim(); + } + continue; + } + break; + } + + return value; +} + +/** + * True when the image target is a local path rather than an http(s) URL. + */ +export function isLocalImageSrc(src: string): boolean { + const raw = src.trim(); + if (raw === "") return false; + if (/^https?:\/\//i.test(raw)) return false; + if (/^data:/i.test(raw)) return false; + if (MEDIA_SCHEME_PREFIX.test(raw) || /^attachment:/i.test(raw)) return true; + if (/^file:/i.test(raw)) return true; + const value = normalizeLocalImagePath(raw); + if (value === "") return false; + if (/^https?:\/\//i.test(value)) return false; + if (value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value)) return true; + if (value.startsWith("./") || value.startsWith("../")) return true; + if (IMAGE_EXT.test(value)) return true; + if (value.includes("generated_images")) return true; + return false; +} + +function looksLikeLocalImageTarget(rawSrc: string): boolean { + if (!isLocalImageSrc(rawSrc)) return false; + const path = normalizeLocalImagePath(rawSrc); + return IMAGE_EXT.test(path) || path.includes("generated_images"); +} + +function pushUnique( + results: MarkdownImageRef[], + seen: Set, + input: { alt: string; rawSrc: string; match: string }, +): void { + const rawSrc = input.rawSrc.trim(); + if (rawSrc === "") return; + if (!looksLikeLocalImageTarget(rawSrc) && !isLocalImageSrc(rawSrc)) return; + // Require image-like path for link/html to avoid stripping normal file links. + if (!looksLikeLocalImageTarget(rawSrc)) return; + + const src = normalizeLocalImagePath(rawSrc); + const key = `${src}::${input.match}`; + if (seen.has(key)) return; + seen.add(key); + results.push({ + alt: input.alt, + src, + rawSrc, + match: input.match, + }); +} + +/** + * Extract every local image embed the agent might have put in the message: + * markdown images, HTML , and markdown links to image files. + */ +export function extractMarkdownImages(text: string): ReadonlyArray { + const results: MarkdownImageRef[] = []; + const seen = new Set(); + + for (const match of text.matchAll(MARKDOWN_IMAGE)) { + const full = match[0]; + if (full === undefined) continue; + pushUnique(results, seen, { + alt: match[1] ?? "", + rawSrc: (match[2] ?? "").trim(), + match: full, + }); + } + + for (const match of text.matchAll(HTML_IMG)) { + const full = match[0]; + if (full === undefined) continue; + const rawSrc = (match[1] ?? match[2] ?? "").trim(); + const altMatch = /\balt\s*=\s*["']([^"']*)["']/i.exec(full); + pushUnique(results, seen, { + alt: altMatch?.[1] ?? "", + rawSrc, + match: full, + }); + } + + for (const match of text.matchAll(MARKDOWN_LINK)) { + const full = match[0]; + if (full === undefined) continue; + // Skip if already captured as markdown image (negative lookbehind is imperfect in JS). + if (full.startsWith("![")) continue; + pushUnique(results, seen, { + alt: match[1] ?? "", + rawSrc: (match[2] ?? "").trim(), + match: full, + }); + } + + return results; +} + +/** Remove all recognized local image embeds (md image, html img, md link-to-image). */ +export function stripMarkdownImages( + text: string, + predicate: (ref: MarkdownImageRef) => boolean = () => true, +): string { + const refs = extractMarkdownImages(text).filter(predicate); + let out = text; + // Remove longest matches first so nested/overlapping cases stay stable. + const ordered = [...refs].sort((a, b) => b.match.length - a.match.length); + for (const ref of ordered) { + out = out.split(ref.match).join(""); + } + return out.replace(/\n{3,}/g, "\n\n").trimEnd(); +} + +export function guessImageMimeType(filePath: string): string { + const lower = filePath.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".bmp")) return "image/bmp"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + return "application/octet-stream"; +} + +/** + * Discord attachment filename — extension drives image preview vs generic file chip. + * Prefer a human alt when present; fall back to the path basename. + */ +export function fileNameForImageRef(ref: MarkdownImageRef): string { + const path = normalizeLocalImagePath(ref.src); + const base = path.split(/[/\\]/).at(-1) ?? "image.png"; + const extMatch = IMAGE_EXT.exec(base); + const ext = extMatch?.[0]?.toLowerCase() ?? ".png"; + + const alt = ref.alt + .trim() + .replace(/[^\w.-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .slice(0, 64); + if (alt.length > 0 && !/^call_[A-Za-z0-9]+$/i.test(alt)) { + return IMAGE_EXT.test(alt) ? alt : `${alt}${ext}`; + } + if (IMAGE_EXT.test(base)) return base; + return `image${ext}`; +} + +/** Guard: never return a path that still starts with a media scheme. */ +export function assertFilesystemPath(path: string): string { + const normalized = normalizeLocalImagePath(path); + if (/^attachment:/i.test(normalized) || /^file:/i.test(normalized)) { + throw new Error(`Refusing to open media-scheme path: ${JSON.stringify(path)}`); + } + return normalized; +} + +function safeStatMtimeMs(path: string): number { + try { + return NodeFS.statSync(path).mtimeMs; + } catch { + return 0; + } +} + +/** + * Bounded walk for files under `root` whose path ends with `relativeSuffix` + * (e.g. `images/1.jpg`) or whose basename matches when under an `images/` dir. + * Returns newest match first. Depth-capped so large trees stay cheap. + */ +function findRecentImageFiles( + root: string, + relativeSuffix: string, + fileBase: string, + maxDepth = 6, + maxHits = 8, +): string[] { + if (!NodeFS.existsSync(root)) return []; + const suffix = relativeSuffix + .replace(/^\.?\//, "") + .split(/[/\\]/) + .join(NodePath.sep); + const hits: Array<{ path: string; mtime: number }> = []; + + const walk = (dir: string, depth: number) => { + if (hits.length >= maxHits || depth > maxDepth) return; + let entries: ReadonlyArray<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>; + try { + entries = NodeFS.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const ent of entries) { + if (hits.length >= maxHits) return; + const full = NodePath.join(dir, ent.name); + if (ent.isDirectory()) { + walk(full, depth + 1); + continue; + } + if (!ent.isFile()) continue; + if (!IMAGE_EXT.test(ent.name)) continue; + const relFromRoot = full.slice(root.length).replace(/^[/\\]/, ""); + const underImages = `${NodePath.sep}images${NodePath.sep}${fileBase}`; + const match = + relFromRoot === suffix || + relFromRoot.endsWith(`${NodePath.sep}${suffix}`) || + (fileBase !== "" && (relFromRoot.endsWith(underImages) || ent.name === fileBase)); + if (!match) continue; + // Prefer Grok/Codex generated locations over random repo assets named 1.jpg. + const preferred = + relFromRoot.includes(`${NodePath.sep}images${NodePath.sep}`) || + full.includes(`${NodePath.sep}.grok${NodePath.sep}sessions${NodePath.sep}`) || + full.includes(`${NodePath.sep}.codex${NodePath.sep}generated_images${NodePath.sep}`); + if (!preferred && !relFromRoot.endsWith(suffix) && relFromRoot !== suffix) continue; + hits.push({ path: full, mtime: safeStatMtimeMs(full) }); + } + }; + + walk(root, 0); + return hits.sort((a, b) => b.mtime - a.mtime).map((h) => h.path); +} + +/** + * Resolve a markdown image target to an on-disk absolute path. + * + * Grok ImageGen embeds session-relative paths like `images/1.jpg` while the real + * file lives under `~/.grok/sessions///images/1.jpg`. + * Codex usually emits absolute paths under `~/.codex/generated_images/…`. + */ +export function resolveImagePathOnDisk(path: string): string | null { + const normalized = assertFilesystemPath(path); + if (normalized === "" || /^https?:\/\//i.test(normalized) || /^data:/i.test(normalized)) { + return null; + } + + if (NodePath.isAbsolute(normalized)) { + return NodeFS.existsSync(normalized) ? NodePath.normalize(normalized) : null; + } + + const rel = normalized.replace(/^\.\//, ""); + const fromCwd = NodePath.join(process.cwd(), rel); + if (NodeFS.existsSync(fromCwd)) return NodePath.normalize(fromCwd); + + const home = NodeOS.homedir(); + const fileBase = NodePath.basename(rel); + const searchRoots = [ + NodePath.join(home, ".grok", "sessions"), + NodePath.join(home, ".codex", "generated_images"), + // Guest data layout (bot runs as t3; HOME may already be /var/lib/t3). + "/var/lib/t3/.grok/sessions", + "/var/lib/t3/.codex/generated_images", + ]; + + for (const root of searchRoots) { + const hits = findRecentImageFiles(root, rel, fileBase); + if (hits[0] !== undefined) return hits[0]; + } + + return null; +} diff --git a/apps/discord-bot/src/presentation/mentions.test.ts b/apps/discord-bot/src/presentation/mentions.test.ts new file mode 100644 index 00000000000..c3d4bf52ccc --- /dev/null +++ b/apps/discord-bot/src/presentation/mentions.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + bridgedTurnTopicResolutionError, + missingProjectBindingMessage, + normalizeWorkspacePath, + parseMentionFlags, + parseMentionIntent, + parseTopicShortName, + projectTopicFromParentLookup, + readChannelTopic, + resolveDiscordFollowUpDelivery, +} from "./mentions.ts"; +import { + chunkDiscordContent, + formatInProgressChunk, + inProgressChunkLimit, + stripBotMention, + stripWorkingIndicator, + truncateTitle, + WORKING_INDICATOR, +} from "./messages.ts"; + +describe("parseTopicShortName", () => { + it("extracts short names from channel topics", () => { + expect(parseTopicShortName("t3-example-project")).toBe("example-project"); + expect(parseTopicShortName("Project channel t3-example-project please")).toBe( + "example-project", + ); + expect(parseTopicShortName("T3-Example-Project")).toBe("example-project"); + expect(parseTopicShortName("no alias here")).toBeNull(); + }); +}); + +describe("project topic lookup", () => { + it("reads topic from channel-like objects", () => { + expect(readChannelTopic({ topic: "t3-example-project" })).toBe("t3-example-project"); + expect(readChannelTopic({ topic: null })).toBeNull(); + expect(readChannelTopic({ id: "1" })).toBeNull(); + expect(readChannelTopic(null)).toBeNull(); + }); + + it("uses parent topic when parent fetch succeeds", () => { + expect( + projectTopicFromParentLookup({ + channel: { topic: null }, + parentId: "parent-1", + parent: { ok: true, channel: { topic: "t3-configurator please" } }, + }), + ).toEqual({ + kind: "resolved", + topic: "t3-configurator please", + parentChannelId: "parent-1", + }); + }); + + it("does not treat a failed parent fetch as a missing t3 tag", () => { + expect( + projectTopicFromParentLookup({ + channel: { topic: null }, + parentId: "parent-1", + parent: { ok: false, cause: "503 Service Unavailable" }, + }), + ).toEqual({ + kind: "parent-unavailable", + parentChannelId: "parent-1", + cause: "503 Service Unavailable", + }); + }); + + it("uses the channel topic when not in a thread", () => { + expect( + projectTopicFromParentLookup({ + channel: { topic: "t3-example-project" }, + parentId: null, + parent: null, + }), + ).toEqual({ + kind: "resolved", + topic: "t3-example-project", + parentChannelId: null, + }); + }); + + it("writes distinct user-facing errors for outages vs missing tags", () => { + expect(missingProjectBindingMessage({ inThread: true, parentUnavailable: true })).toContain( + "Discord API may be degraded", + ); + expect(missingProjectBindingMessage({ inThread: true, parentUnavailable: false })).toContain( + "t3-", + ); + expect( + bridgedTurnTopicResolutionError({ + topicError: "Channel topic has no t3- tag.", + parentUnavailable: true, + hasExistingLink: false, + recoveredFromLink: false, + }), + ).toContain("Discord API may be degraded"); + expect( + bridgedTurnTopicResolutionError({ + topicError: "Channel topic has no t3- tag.", + parentUnavailable: true, + hasExistingLink: true, + recoveredFromLink: true, + }), + ).toBeNull(); + }); +}); + +describe("parseMentionFlags", () => { + it("parses flags and residual prompt", () => { + const parsed = parseMentionFlags( + "--plan --local --base develop --model compose-2.5 --provider grok fix the flaky test", + ); + expect(parsed).toEqual({ + model: "compose-2.5", + provider: "grok", + base: "develop", + local: true, + plan: true, + prompt: "fix the flaky test", + }); + }); + + it("parses --steer and --queue with last-wins when both appear", () => { + expect(parseMentionFlags("--steer also check the race").followUpDelivery).toBe("steer"); + expect(parseMentionFlags("--queue park this for later").followUpDelivery).toBe("queue"); + expect(parseMentionFlags("--steer --queue prefer queue").followUpDelivery).toBe("queue"); + expect(parseMentionFlags("--queue --steer prefer steer").followUpDelivery).toBe("steer"); + expect(parseMentionFlags("plain follow-up").followUpDelivery).toBeUndefined(); + }); +}); + +describe("resolveDiscordFollowUpDelivery", () => { + it("defaults mid-turn Discord delivery to queue", () => { + expect(resolveDiscordFollowUpDelivery({})).toBe("queue"); + expect(resolveDiscordFollowUpDelivery({ followUpDelivery: "steer" })).toBe("steer"); + expect(resolveDiscordFollowUpDelivery({ followUpDelivery: "queue" })).toBe("queue"); + }); +}); + +describe("parseMentionIntent", () => { + it("recognizes stop words as interrupt commands", () => { + expect(parseMentionIntent("stop")).toEqual({ kind: "interrupt" }); + expect(parseMentionIntent(" cancel! ")).toEqual({ kind: "interrupt" }); + expect(parseMentionIntent("--plan abort")).toEqual({ kind: "interrupt" }); + }); + + it("recognizes help as a help command", () => { + expect(parseMentionIntent("help")).toEqual({ kind: "help" }); + expect(parseMentionIntent(" help! ")).toEqual({ kind: "help" }); + }); + + it("recognizes compact as a control command", () => { + expect(parseMentionIntent("compact")).toEqual({ kind: "compact" }); + expect(parseMentionIntent(" Compact! ")).toEqual({ kind: "compact" }); + }); + + it("recognizes refresh-indicators as a control command", () => { + expect(parseMentionIntent("refresh-indicators")).toEqual({ kind: "refresh-indicators" }); + expect(parseMentionIntent("refresh indicators")).toEqual({ kind: "refresh-indicators" }); + expect(parseMentionIntent("refresh title")).toEqual({ kind: "refresh-indicators" }); + }); + + it("keeps normal prompts as prompts", () => { + expect(parseMentionIntent("stop using the flaky snapshot test")).toEqual({ + kind: "prompt", + local: false, + plan: false, + prompt: "stop using the flaky snapshot test", + }); + }); + + it("recognizes link / pick-up of an existing T3 thread", () => { + expect(parseMentionIntent("link abc-123")).toEqual({ + kind: "link-thread", + t3ThreadId: "abc-123", + }); + expect(parseMentionIntent("pick-up https://t3.example/?thread=uuid-1&foo=1")).toEqual({ + kind: "link-thread", + t3ThreadId: "uuid-1", + }); + expect(parseMentionIntent("pickup http://localhost:5173/?thread=tid-9")).toEqual({ + kind: "link-thread", + t3ThreadId: "tid-9", + }); + }); + + it("does not treat ordinary prompts starting with link words as link commands", () => { + expect(parseMentionIntent("link these files together")).toEqual({ + kind: "prompt", + local: false, + plan: false, + prompt: "link these files together", + }); + expect(parseMentionIntent("please pick-up the slack")).toEqual({ + kind: "prompt", + local: false, + plan: false, + prompt: "please pick-up the slack", + }); + }); +}); + +describe("message helpers", () => { + it("strips bot mentions", () => { + expect(stripBotMention("<@123> please help <@!123>", "123")).toBe("please help"); + }); + + it("strips the app-managed role mention when provided", () => { + expect(stripBotMention("<@&456> thread-talk on", "123", "456")).toBe("thread-talk on"); + }); + + it("chunks long content", () => { + const chunks = chunkDiscordContent("a".repeat(2500), 2000); + expect(chunks.length).toBe(2); + expect(chunks[0]?.length).toBeLessThanOrEqual(2000); + }); + + it("appends italic Working.. only on the last in-progress chunk", () => { + expect(formatInProgressChunk("partial answer", true)).toContain(WORKING_INDICATOR); + expect(formatInProgressChunk("partial answer", true).endsWith(WORKING_INDICATOR)).toBe(true); + expect(WORKING_INDICATOR).toBe("_Working.._"); + expect(formatInProgressChunk("older chunk", false)).toBe("older chunk"); + expect(formatInProgressChunk("", true)).toBe(WORKING_INDICATOR); + expect(formatInProgressChunk("x".repeat(1990), true).length).toBeLessThanOrEqual(2000); + expect(inProgressChunkLimit(2000)).toBeLessThan(2000); + }); + + it("strips Working.. from finalized content", () => { + expect(stripWorkingIndicator(`partial answer\n\n${WORKING_INDICATOR}`)).toBe("partial answer"); + expect(stripWorkingIndicator("partial answer\n\nWorking..")).toBe("partial answer"); + expect(stripWorkingIndicator(WORKING_INDICATOR)).toBe(""); + expect(stripWorkingIndicator("keep Working.. in the middle")).toBe( + "keep Working.. in the middle", + ); + }); + + it("truncates titles", () => { + expect(truncateTitle("short")).toBe("short"); + expect(truncateTitle("x".repeat(120)).length).toBe(100); + }); +}); + +describe("normalizeWorkspacePath", () => { + it("normalizes separators and trailing slashes", () => { + expect(normalizeWorkspacePath("/tmp/Foo/")).toBe("/tmp/foo"); + }); +}); diff --git a/apps/discord-bot/src/presentation/mentions.ts b/apps/discord-bot/src/presentation/mentions.ts new file mode 100644 index 00000000000..6797f47988f --- /dev/null +++ b/apps/discord-bot/src/presentation/mentions.ts @@ -0,0 +1,239 @@ +import { parseProviderModelFlags } from "@t3tools/shared/providerModelSelection"; + +import { parseLinkThreadCommand, type LinkThreadCommand } from "./t3ThreadRef.ts"; + +/** How a mid-turn Discord follow-up is delivered to the server queue. */ +export type DiscordFollowUpDelivery = "steer" | "queue"; + +export interface ParsedMentionFlags { + readonly model?: string; + readonly provider?: string; + readonly base?: string; + readonly local: boolean; + readonly plan: boolean; + /** + * Explicit mid-turn delivery override. When omitted, busy-thread follow-ups + * **queue** server-side (platform default) and are badged with 📥. + * `--steer` injects into the active turn immediately; `--queue` forces park. + */ + readonly followUpDelivery?: DiscordFollowUpDelivery; + readonly prompt: string; +} + +/** + * Resolve mid-turn delivery. Default is **queue** (server-aligned); `--steer` + * / `/omegent steer` force injection. Idle threads ignore this (startTurn + * opens a normal turn). + */ +export function resolveDiscordFollowUpDelivery( + flags: Pick, +): DiscordFollowUpDelivery { + return flags.followUpDelivery ?? "queue"; +} + +export type ParsedMentionIntent = + | { readonly kind: "interrupt" } + | { readonly kind: "help" } + | { readonly kind: "refresh-indicators" } + | { readonly kind: "compact" } + | LinkThreadCommand + | ({ readonly kind: "prompt" } & ParsedMentionFlags); + +const INTERRUPT_PROMPTS = new Set(["stop", "cancel", "abort", "interrupt"]); +const HELP_PROMPTS = new Set(["help"]); +const COMPACT_PROMPTS = new Set(["compact"]); +const REFRESH_INDICATORS_PROMPTS = new Set([ + "refresh-indicators", + "refresh indicators", + "refresh-title", + "refresh title", +]); + +/** + * Parse optional flags from a bot mention body (after stripping the mention). + * Flags: --model --provider --base --local --plan + * --steer --queue + * + * `--steer` / `--queue` last-wins when both appear. + */ +export function parseMentionFlags(raw: string): ParsedMentionFlags { + const providerModel = parseProviderModelFlags(raw); + const tokens = providerModel.prompt + .trim() + .split(/\s+/u) + .filter((token) => token.length > 0); + let base: string | undefined; + let local = false; + let plan = false; + let followUpDelivery: DiscordFollowUpDelivery | undefined; + const promptParts: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "--local") { + local = true; + continue; + } + if (token === "--plan") { + plan = true; + continue; + } + if (token === "--steer") { + followUpDelivery = "steer"; + continue; + } + if (token === "--queue") { + followUpDelivery = "queue"; + continue; + } + if (token === "--base") { + const next = tokens[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + base = next; + index += 1; + continue; + } + } + promptParts.push(token); + } + + return { + ...(providerModel.model === undefined ? {} : { model: providerModel.model }), + ...(providerModel.provider === undefined ? {} : { provider: providerModel.provider }), + ...(base === undefined ? {} : { base }), + local, + plan, + ...(followUpDelivery === undefined ? {} : { followUpDelivery }), + prompt: promptParts.join(" ").trim(), + }; +} + +export function parseMentionIntent(raw: string): ParsedMentionIntent { + const linkThread = parseLinkThreadCommand(raw); + if (linkThread !== null) return linkThread; + + const parsed = parseMentionFlags(raw); + const normalizedPrompt = parsed.prompt + .trim() + .toLocaleLowerCase() + .replace(/^[\s.,!?;:()[\]{}"'`~*_#-]+|[\s.,!?;:()[\]{}"'`~*_#-]+$/gu, ""); + + if (INTERRUPT_PROMPTS.has(normalizedPrompt)) { + return { kind: "interrupt" }; + } + + if (HELP_PROMPTS.has(normalizedPrompt)) { + return { kind: "help" }; + } + + if (COMPACT_PROMPTS.has(normalizedPrompt)) { + return { kind: "compact" }; + } + + if (REFRESH_INDICATORS_PROMPTS.has(normalizedPrompt)) { + return { kind: "refresh-indicators" }; + } + + return { kind: "prompt", ...parsed }; +} + +export function parseTopicShortName(topic: string | null | undefined): string | null { + if (topic === null || topic === undefined) return null; + const match = /\bt3-([a-z0-9]+(?:-[a-z0-9]+)*)\b/i.exec(topic); + return match?.[1]?.toLowerCase() ?? null; +} + +/** Read Discord channel.topic when present (threads usually have none). */ +export function readChannelTopic(channel: unknown): string | null | undefined { + if (channel === null || channel === undefined || typeof channel !== "object") return null; + if (!("topic" in channel)) return null; + return (channel as { readonly topic?: string | null | undefined }).topic; +} + +/** + * Result of resolving the project-binding topic for a mention/slash command. + * Distinguishes a missing `t3-*` tag from a failed parent-channel fetch (Discord outage). + */ +export type ProjectTopicLookup = + | { + readonly kind: "resolved"; + readonly topic: string | null | undefined; + readonly parentChannelId: string | null; + } + | { + readonly kind: "parent-unavailable"; + readonly parentChannelId: string; + readonly cause: string; + }; + +/** + * Pure merge of thread channel + optional parent GET outcome into a topic lookup. + * When `parentId` is set, the parent response is authoritative; a failed parent GET + * must not be confused with "channel has no t3-* tag". + */ +export function projectTopicFromParentLookup(input: { + readonly channel: unknown; + readonly parentId: string | null; + readonly parent: + | { readonly ok: true; readonly channel: unknown } + | { readonly ok: false; readonly cause: string } + | null; +}): ProjectTopicLookup { + if (input.parentId === null) { + return { + kind: "resolved", + topic: readChannelTopic(input.channel), + parentChannelId: null, + }; + } + if (input.parent === null || !input.parent.ok) { + return { + kind: "parent-unavailable", + parentChannelId: input.parentId, + cause: input.parent === null ? "parent channel not fetched" : input.parent.cause, + }; + } + return { + kind: "resolved", + topic: readChannelTopic(input.parent.channel), + parentChannelId: input.parentId, + }; +} + +/** User-facing copy when we cannot bind a Discord channel to a T3 project. */ +export function missingProjectBindingMessage(input: { + readonly inThread: boolean; + readonly parentUnavailable: boolean; +}): string { + if (input.parentUnavailable) { + return input.inThread + ? "Couldn't read the parent channel topic (Discord API may be degraded). Please try again in a moment." + : "Couldn't read the channel topic (Discord API may be degraded). Please try again in a moment."; + } + return input.inThread + ? "This channel is not linked to a T3 project. Set the parent channel topic to include `t3-` (e.g. `t3-example-project`)." + : "This channel is not linked to a T3 project. Set the channel topic to include `t3-` (e.g. `t3-example-project`)."; +} + +/** + * Error string when topic-based project resolution fails for a bridged turn. + * Prefers an existing Discord↔T3 link over a misleading "no topic tag" during outages. + */ +export function bridgedTurnTopicResolutionError(input: { + readonly topicError: string; + readonly parentUnavailable: boolean; + readonly hasExistingLink: boolean; + readonly recoveredFromLink: boolean; +}): string | null { + if (input.recoveredFromLink) return null; + if (input.parentUnavailable) { + return input.hasExistingLink + ? "Couldn't read the parent channel topic and could not recover the linked T3 project. Please try again in a moment." + : "Couldn't read the parent channel topic (Discord API may be degraded). Please try again in a moment."; + } + return input.topicError; +} + +export function normalizeWorkspacePath(path: string): string { + return path.replaceAll("\\", "/").replace(/\/+$/u, "").toLocaleLowerCase(); +} diff --git a/apps/discord-bot/src/presentation/messages.test.ts b/apps/discord-bot/src/presentation/messages.test.ts new file mode 100644 index 00000000000..5a4bbbe2188 --- /dev/null +++ b/apps/discord-bot/src/presentation/messages.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + decorateDiscordThreadTitle, + formatInProgressChunk, + formatThreadTitle, + formatWakeUpTipContent, + idleMessageFields, + nextWorkingDotCount, + stripWorkingIndicator, + truncateTitle, + turnContinueCustomId, + turnStopCustomId, + WAKE_UP_STATUS_LINE, + wakeUpMessageFields, + workingMessageFields, +} from "./messages.ts"; + +describe("formatThreadTitle", () => { + it("collapses whitespace and uses the provided fallback", () => { + expect(formatThreadTitle(" review the open PR ", 72, "Discord thread")).toBe( + "review the open PR", + ); + expect(formatThreadTitle(" ", 72, "Discord thread")).toBe("Discord thread"); + }); + + it("truncates long titles with an ellipsis", () => { + expect(formatThreadTitle("x".repeat(80), 72)).toHaveLength(72); + expect(formatThreadTitle("x".repeat(80), 72).endsWith("…")).toBe(true); + }); +}); + +describe("truncateTitle", () => { + it("preserves the existing Discord thread-name behavior", () => { + expect(truncateTitle("short")).toBe("short"); + expect(truncateTitle("x".repeat(120)).length).toBe(100); + }); +}); + +describe("decorateDiscordThreadTitle", () => { + it("composes optional PR + activity columns like the T3 client", () => { + expect( + decorateDiscordThreadTitle("Review sidebar PR status", { + pr: "open", + activity: "busy", + }), + ).toBe("🔀 ⏳ Review sidebar PR status"); + expect( + decorateDiscordThreadTitle("Review sidebar PR status", { + pr: "open", + activity: "wake-required", + hasFailingChecks: true, + }), + ).toBe("❌ ❗ Review sidebar PR status"); + expect( + decorateDiscordThreadTitle("Review sidebar PR status", { + pr: "initialized", + activity: "busy", + }), + ).toBe("▫️ ⏳ Review sidebar PR status"); + }); + + it("allows either column to be omitted", () => { + expect(decorateDiscordThreadTitle("Review sidebar PR status", { pr: "open" })).toBe( + "🔀 Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("Review sidebar PR status", { activity: "busy" })).toBe( + "⏳ Review sidebar PR status", + ); + expect( + decorateDiscordThreadTitle("Review sidebar PR status", { activity: "wake-required" }), + ).toBe("❗ Review sidebar PR status"); + expect(decorateDiscordThreadTitle("Review sidebar PR status", {})).toBe( + "Review sidebar PR status", + ); + }); + + it("legacy single-slot states still work", () => { + expect(decorateDiscordThreadTitle("Review sidebar PR status", "wake-required")).toBe( + "❗ Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("Review sidebar PR status", "busy")).toBe( + "⏳ Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("Review sidebar PR status", "initialized")).toBe( + "▫️ Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("Review sidebar PR status", "open")).toBe( + "🔀 Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("Review sidebar PR status", "open", 100, true)).toBe( + "❌ Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("Ship the merge flow", "merged")).toBe( + "✔️ Ship the merge flow", + ); + expect(decorateDiscordThreadTitle("Ship the merge flow", "closed")).toBe( + "✖️ Ship the merge flow", + ); + }); + + it("strips dual and legacy prefixes when redecorating", () => { + expect( + decorateDiscordThreadTitle("🔀 ⏳ Review sidebar PR status", { + pr: "open", + activity: null, + }), + ).toBe("🔀 Review sidebar PR status"); + expect(decorateDiscordThreadTitle("❗ Review sidebar PR status", "open")).toBe( + "🔀 Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("⏳ Review sidebar PR status", "initialized")).toBe( + "▫️ Review sidebar PR status", + ); + expect(decorateDiscordThreadTitle("🔀 ⏳ Existing title", { pr: "merged" })).toBe( + "✔️ Existing title", + ); + }); + + it("removes stale pull request prefixes when the PR closes or changes state", () => { + expect(decorateDiscordThreadTitle("🍴 Existing title", null)).toBe("Existing title"); + expect(decorateDiscordThreadTitle("· Existing title", null)).toBe("Existing title"); + expect(decorateDiscordThreadTitle("▫️ Existing title", null)).toBe("Existing title"); + expect(decorateDiscordThreadTitle("⏳ Existing title", null)).toBe("Existing title"); + expect(decorateDiscordThreadTitle("🍴 Existing title", "initialized")).toBe( + "▫️ Existing title", + ); + expect(decorateDiscordThreadTitle("🍴 Existing title", "merged")).toBe("✔️ Existing title"); + expect(decorateDiscordThreadTitle("✅ Existing title", "closed")).toBe("✖️ Existing title"); + expect(decorateDiscordThreadTitle("❌ Existing title", "open")).toBe("🔀 Existing title"); + expect(decorateDiscordThreadTitle("❌ 🔀 Existing title", "open")).toBe("🔀 Existing title"); + }); + + it("never duplicates an existing pull request prefix", () => { + expect(decorateDiscordThreadTitle("▫️ Existing title", "initialized")).toBe( + "▫️ Existing title", + ); + expect(decorateDiscordThreadTitle("⏳ Existing title", "busy")).toBe("⏳ Existing title"); + expect( + decorateDiscordThreadTitle("🔀 ⏳ Existing title", { pr: "open", activity: "busy" }), + ).toBe("🔀 ⏳ Existing title"); + expect(decorateDiscordThreadTitle("🔀 Existing title", "open")).toBe("🔀 Existing title"); + expect(decorateDiscordThreadTitle("❌ Existing title", "open", 100, true)).toBe( + "❌ Existing title", + ); + // Legacy dual ❌ 🔀 redecorates to single ❌ when checks are still failing. + expect(decorateDiscordThreadTitle("❌ 🔀 Existing title", "open", 100, true)).toBe( + "❌ Existing title", + ); + expect(decorateDiscordThreadTitle("✔️ Existing title", "merged")).toBe("✔️ Existing title"); + expect(decorateDiscordThreadTitle("✖️ Existing title", "closed")).toBe("✖️ Existing title"); + }); +}); + +describe("turn stop controls", () => { + it("builds the stop custom id", () => { + expect(turnStopCustomId("thread-123")).toBe("t3_stop:thread-123"); + }); + + it("attaches a Stop button to working messages", () => { + const fields = workingMessageFields("_Working.._", "thread-123"); + expect(fields.content).toBe("_Working.._"); + expect(fields.components).toHaveLength(1); + expect(fields.components[0]?.components[0]?.custom_id).toBe("t3_stop:thread-123"); + }); + + it("clears components for idle messages", () => { + expect(idleMessageFields("done").components).toEqual([]); + }); +}); + +describe("wake-up controls", () => { + it("builds the continue custom id", () => { + expect(turnContinueCustomId("thread-123")).toBe("t3_continue:thread-123"); + }); + + it("formats empty Working tips as bold wake-up status only", () => { + expect(formatWakeUpTipContent("_Working.._")).toBe(WAKE_UP_STATUS_LINE); + expect(formatWakeUpTipContent("")).toBe(WAKE_UP_STATUS_LINE); + }); + + it("keeps partial stream prose above the wake-up status", () => { + expect(formatWakeUpTipContent("partial answer\n\n_Working.._")).toBe( + `partial answer\n\n${WAKE_UP_STATUS_LINE}`, + ); + }); + + it("attaches a blue Continue button and no Stop", () => { + const fields = wakeUpMessageFields(WAKE_UP_STATUS_LINE, "thread-123"); + expect(fields.content).toBe(WAKE_UP_STATUS_LINE); + expect(fields.components).toHaveLength(1); + const button = fields.components[0]?.components[0]; + expect(button?.custom_id).toBe("t3_continue:thread-123"); + expect(button?.label).toBe("Continue"); + expect(button?.style).toBe(1); // PRIMARY (blue) + }); +}); + +describe("Working heartbeat", () => { + it("cycles through two, three, and four dots", () => { + expect(nextWorkingDotCount(2)).toBe(3); + expect(nextWorkingDotCount(3)).toBe(4); + expect(nextWorkingDotCount(4)).toBe(2); + }); + + it("renders the selected heartbeat without exceeding the message limit", () => { + const rendered = formatInProgressChunk("x".repeat(2000), true, 2000, 4); + expect(rendered).toHaveLength(2000); + expect(rendered.endsWith("_Working...._")).toBe(true); + }); + + it("appends a tool-call count on the Working indicator when > 0", () => { + expect(formatInProgressChunk("", true, 2000, 2, 0)).toBe("_Working.._"); + expect(formatInProgressChunk("", true, 2000, 2, 1)).toBe("_Working.. · 1 tool call_"); + expect(formatInProgressChunk("partial", true, 2000, 3, 4)).toBe( + "partial\n\n_Working... · 4 tool calls_", + ); + }); + + it("strips every heartbeat variant during final cleanup", () => { + expect(stripWorkingIndicator("answer\n\n_Working.._")).toBe("answer"); + expect(stripWorkingIndicator("answer\n\n_Working..._")).toBe("answer"); + expect(stripWorkingIndicator("answer\n\n_Working...._")).toBe("answer"); + expect(stripWorkingIndicator("answer\n\n_Working.. · 3 tool calls_")).toBe("answer"); + expect(stripWorkingIndicator("answer\n\n_Working... · 1 tool call_")).toBe("answer"); + }); +}); diff --git a/apps/discord-bot/src/presentation/messages.ts b/apps/discord-bot/src/presentation/messages.ts new file mode 100644 index 00000000000..325978c1f5b --- /dev/null +++ b/apps/discord-bot/src/presentation/messages.ts @@ -0,0 +1,315 @@ +import { Discord } from "dfx"; + +const DISCORD_MESSAGE_LIMIT = 2000; + +/** Appended to the tip of in-progress Discord stream messages (italic in Discord markdown). */ +export const WORKING_INDICATOR = "_Working.._"; +export const WORKING_INDICATOR_SUFFIX = `\n\n${WORKING_INDICATOR}`; +export type WorkingDotCount = 2 | 3 | 4; + +/** Longest Working suffix we reserve room for (dots + large tool count). */ +const WORKING_INDICATOR_MAX = "_Working.... · 9999 tool calls_"; + +/** + * Optional tool-call progress on the Working indicator (Discord only shows a count, + * not individual tool rows — same cadence as the 10s dot heartbeat). + */ +export function formatWorkingToolCountLabel(toolCallCount: number): string | null { + if (!Number.isFinite(toolCallCount) || toolCallCount <= 0) return null; + const n = Math.floor(toolCallCount); + return n === 1 ? "1 tool call" : `${n} tool calls`; +} + +export function workingIndicator(dotCount: WorkingDotCount, toolCallCount = 0): string { + const dots = ".".repeat(dotCount); + const tools = formatWorkingToolCountLabel(toolCallCount); + // Keep the whole marker italic so Discord renders one clean status line. + return tools === null ? `_Working${dots}_` : `_Working${dots} · ${tools}_`; +} + +export function nextWorkingDotCount(current: WorkingDotCount): WorkingDotCount { + if (current === 2) return 3; + if (current === 3) return 4; + return 2; +} + +/** Strip trailing Working.. markers so finalize never leaves them on the final post. */ +export function stripWorkingIndicator(content: string): string { + // Optional " · N tool call(s)" inside the Working marker (italic or plain). + const toolSuffix = "(?:\\s*·\\s*\\d+\\s+tool calls?)?"; + const workingTail = new RegExp(`(?:\\r?\\n)+\\s*_Working\\.{2,4}${toolSuffix}_\\s*$`, "u"); + const workingTailPlain = new RegExp(`(?:\\r?\\n)+\\s*Working\\.{2,4}${toolSuffix}\\s*$`, "u"); + const workingInline = new RegExp(`\\s*_Working\\.{2,4}${toolSuffix}_\\s*$`, "u"); + const workingInlinePlain = new RegExp(`\\s*Working\\.{2,4}${toolSuffix}\\s*$`, "u"); + return content + .replace(workingTail, "") + .replace(workingTailPlain, "") + .replace(workingInline, "") + .replace(workingInlinePlain, "") + .trimEnd(); +} + +export function chunkDiscordContent(content: string, limit = DISCORD_MESSAGE_LIMIT): string[] { + const trimmed = content.trimEnd(); + if (trimmed.length === 0) return [""]; + if (trimmed.length <= limit) return [trimmed]; + + const chunks: string[] = []; + let remaining = trimmed; + while (remaining.length > limit) { + let splitAt = remaining.lastIndexOf("\n", limit); + if (splitAt < Math.floor(limit * 0.5)) { + splitAt = remaining.lastIndexOf(" ", limit); + } + if (splitAt < Math.floor(limit * 0.5)) { + splitAt = limit; + } + chunks.push(remaining.slice(0, splitAt).trimEnd()); + remaining = remaining.slice(splitAt).trimStart(); + } + if (remaining.length > 0) chunks.push(remaining); + return chunks; +} + +/** + * Format a stream chunk for Discord. The last (tip) chunk always ends with _Working.._ + * so users can see the turn is still in progress. Optional toolCallCount shows progress + * without listing individual tools (e.g. `_Working.. · 3 tool calls_`). + */ +export function formatInProgressChunk( + chunk: string, + isLastChunk: boolean, + limit = DISCORD_MESSAGE_LIMIT, + workingDots: WorkingDotCount = 2, + toolCallCount = 0, +): string { + if (!isLastChunk) { + return chunk.length > 0 ? chunk : "…"; + } + const body = chunk.trimEnd(); + const indicator = workingIndicator(workingDots, toolCallCount); + const indicatorSuffix = `\n\n${indicator}`; + if (body.length === 0) return indicator; + const maxBody = Math.max(0, limit - indicatorSuffix.length); + const trimmedBody = body.length > maxBody ? body.slice(0, maxBody).trimEnd() : body; + return `${trimmedBody}${indicatorSuffix}`; +} + +/** Chunk limit reserved so the tip can always fit the Working.. suffix (+ tool count). */ +export function inProgressChunkLimit(limit = DISCORD_MESSAGE_LIMIT): number { + return Math.max(1, limit - `\n\n${WORKING_INDICATOR_MAX}`.length); +} + +export function turnStopCustomId(threadId: string): string { + return `t3_stop:${threadId}`; +} + +export function turnContinueCustomId(threadId: string): string { + return `t3_continue:${threadId}`; +} + +/** Bold wake-up status line when a session was interrupted (e.g. server restart). */ +export const WAKE_UP_STATUS_LINE = "**This thread needs another message to wake the bot.**"; + +/** + * Replace a Working tip body with wake-up status (strip Working.. / Stop context). + * Keeps any partial stream prose above the status line. + */ +export function formatWakeUpTipContent(tipContent: string): string { + const body = stripWorkingIndicator(tipContent).trim(); + if (body === "" || body === "…") return WAKE_UP_STATUS_LINE; + return `${body}\n\n${WAKE_UP_STATUS_LINE}`; +} + +export function workingMessageFields(content: string, threadId: string) { + return { + content, + components: [ + [ + { + type: 2 as const, + custom_id: turnStopCustomId(threadId), + label: "Stop", + style: Discord.ButtonStyleTypes.DANGER, + }, + ], + ].map((components) => ({ + type: 1 as const, + components, + })), + }; +} + +/** Wake-required tip: no Stop; blue Continue to help the user resume. */ +export function wakeUpMessageFields(content: string, threadId: string) { + return { + content, + components: [ + [ + { + type: 2 as const, + custom_id: turnContinueCustomId(threadId), + label: "Continue", + style: Discord.ButtonStyleTypes.PRIMARY, + }, + ], + ].map((components) => ({ + type: 1 as const, + components, + })), + }; +} + +export function idleMessageFields(content: string) { + return { + content, + components: [], + }; +} + +export function formatThreadTitle(value: string, max = 100, fallback = "T3 thread"): string { + const compact = value.replace(/\s+/g, " ").trim(); + if (compact.length === 0) return fallback; + if (compact.length <= max) return compact; + return `${compact.slice(0, max - 1).trimEnd()}…`; +} + +export function truncateTitle(value: string, max = 100): string { + return formatThreadTitle(value, max); +} + +/** + * Discord thread title badges are two independent optional columns (like the T3 client): + * `| PR status | Working status | Title` + * + * Either column may be absent. Single emoji (+ space) so columns stay aligned. + */ +const DISCORD_THREAD_TITLE_PR_PREFIX = { + initialized: "▫️ ", + open: "🔀 ", + /** Open PR with failing checks — single ❌ (no dual ❌ 🔀). */ + openFailing: "❌ ", + merged: "✔️ ", + closed: "✖️ ", +} as const; + +const DISCORD_THREAD_TITLE_ACTIVITY_PREFIX = { + /** Session interrupted (Wake Required). */ + wakeRequired: "❗ ", + /** Turn in progress (Discord shows Working..). */ + busy: "⏳ ", +} as const; + +/** PR / change-request column (optional). */ +export type DiscordThreadPrDecorState = "initialized" | "open" | "merged" | "closed" | null; + +/** Working / lifecycle column (optional). */ +export type DiscordThreadActivityDecorState = "busy" | "wake-required" | null; + +/** Composed title decoration — both slots optional. */ +export type DiscordThreadTitleDecorParts = { + readonly pr?: DiscordThreadPrDecorState; + readonly activity?: DiscordThreadActivityDecorState; + readonly hasFailingChecks?: boolean; +}; + +/** + * Legacy single-slot state (exclusive PR *or* activity). Prefer `DiscordThreadTitleDecorParts`. + * Kept so existing call sites / tests keep compiling during the dual-slot transition. + */ +export type DiscordThreadTitleDecorState = + | DiscordThreadPrDecorState + | DiscordThreadActivityDecorState; + +// Standalone ❌ = open+failing; also strip legacy "❌ 🔀" and normal PR icons. +const DISCORD_THREAD_PR_PREFIX_RE = /^(?:❌\s+🔀\s+|❌\s+|(?:🍴|✅|·|▫️|🔀|✓|✔️|✕|✖️)\s+)/u; +const DISCORD_THREAD_ACTIVITY_PREFIX_RE = /^(?:❗|⏳)\s+/u; + +/** Strip every known badge prefix (any order / legacy single-slot titles). */ +export function stripDiscordThreadTitlePrefixes(title: string): string { + let remaining = title.trimStart(); + for (let i = 0; i < 4; i += 1) { + const next = remaining + .replace(DISCORD_THREAD_PR_PREFIX_RE, "") + .replace(DISCORD_THREAD_ACTIVITY_PREFIX_RE, ""); + if (next === remaining) break; + remaining = next; + } + return remaining; +} + +function prPrefixFor(pr: DiscordThreadPrDecorState, hasFailingChecks: boolean): string { + if (pr === "initialized") return DISCORD_THREAD_TITLE_PR_PREFIX.initialized; + if (pr === "open") { + return hasFailingChecks + ? DISCORD_THREAD_TITLE_PR_PREFIX.openFailing + : DISCORD_THREAD_TITLE_PR_PREFIX.open; + } + if (pr === "merged") return DISCORD_THREAD_TITLE_PR_PREFIX.merged; + if (pr === "closed") return DISCORD_THREAD_TITLE_PR_PREFIX.closed; + return ""; +} + +function activityPrefixFor(activity: DiscordThreadActivityDecorState): string { + if (activity === "wake-required") return DISCORD_THREAD_TITLE_ACTIVITY_PREFIX.wakeRequired; + if (activity === "busy") return DISCORD_THREAD_TITLE_ACTIVITY_PREFIX.busy; + return ""; +} + +function normalizeDecorParts( + stateOrParts: DiscordThreadTitleDecorState | DiscordThreadTitleDecorParts, + hasFailingChecks: boolean, +): { + readonly pr: DiscordThreadPrDecorState; + readonly activity: DiscordThreadActivityDecorState; + readonly hasFailingChecks: boolean; +} { + if (stateOrParts === null || stateOrParts === undefined) { + return { pr: null, activity: null, hasFailingChecks: false }; + } + if (typeof stateOrParts === "string") { + if (stateOrParts === "busy" || stateOrParts === "wake-required") { + return { pr: null, activity: stateOrParts, hasFailingChecks: false }; + } + return { pr: stateOrParts, activity: null, hasFailingChecks }; + } + return { + pr: stateOrParts.pr ?? null, + activity: stateOrParts.activity ?? null, + hasFailingChecks: stateOrParts.hasFailingChecks ?? hasFailingChecks, + }; +} + +/** + * Decorate a Discord thread title. + * + * Preferred: `decorateDiscordThreadTitle(title, { pr: "open", activity: "busy" })` + * → `🔀 ⏳ Title` + * + * Legacy single-slot still works: `decorateDiscordThreadTitle(title, "open")` → `🔀 Title`. + */ +export function decorateDiscordThreadTitle( + title: string, + stateOrParts: DiscordThreadTitleDecorState | DiscordThreadTitleDecorParts = null, + max = 100, + hasFailingChecks = false, +): string { + const parts = normalizeDecorParts(stateOrParts, hasFailingChecks); + const baseTitle = truncateTitle(stripDiscordThreadTitlePrefixes(title), max); + const prefix = `${prPrefixFor(parts.pr, parts.hasFailingChecks)}${activityPrefixFor(parts.activity)}`; + return truncateTitle(`${prefix}${baseTitle}`, max); +} + +export function stripBotMention( + content: string, + botUserId: string, + botRoleId?: string | null, +): string { + const userMention = new RegExp(`<@!?${botUserId}>`, "g"); + const withoutUserMention = content.replace(userMention, " "); + const withoutManagedRoleMention = + botRoleId === undefined || botRoleId === null + ? withoutUserMention + : withoutUserMention.replace(new RegExp(`<@&${botRoleId}>`, "g"), " "); + return withoutManagedRoleMention.replace(/\s+/g, " ").trim(); +} diff --git a/apps/discord-bot/src/presentation/pendingInteractions.ts b/apps/discord-bot/src/presentation/pendingInteractions.ts new file mode 100644 index 00000000000..75c2fc4965e --- /dev/null +++ b/apps/discord-bot/src/presentation/pendingInteractions.ts @@ -0,0 +1,113 @@ +import type { OrchestrationThreadActivity, UserInputQuestion } from "@t3tools/contracts"; + +export interface PendingApproval { + readonly kind: "approval"; + readonly requestId: string; + readonly requestKind: "command" | "file-read" | "file-change"; + readonly detail: string | null; + readonly createdAt: string; +} + +export interface PendingUserInput { + readonly kind: "user-input"; + readonly requestId: string; + readonly questions: ReadonlyArray; + readonly createdAt: string; +} + +export type PendingInteraction = PendingApproval | PendingUserInput; + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function requestKind(value: unknown): PendingApproval["requestKind"] | null { + if (value === "command" || value === "file-read" || value === "file-change") return value; + if ( + value === "command_execution_approval" || + value === "exec_command_approval" || + value === "dynamic_tool_call" + ) + return "command"; + if (value === "file_read_approval") return "file-read"; + if (value === "file_change_approval" || value === "apply_patch_approval") return "file-change"; + return null; +} + +function questions(value: unknown): ReadonlyArray | null { + if (!Array.isArray(value)) return null; + const parsed = value.filter((entry): entry is UserInputQuestion => { + const question = record(entry); + return ( + typeof question?.id === "string" && + typeof question.header === "string" && + typeof question.question === "string" && + Array.isArray(question.options) && + question.options.every((option) => { + const value = record(option); + return typeof value?.label === "string" && typeof value.description === "string"; + }) + ); + }); + return parsed.length === value.length && parsed.length > 0 ? parsed : null; +} + +function isStaleFailure(payload: Record): boolean { + const detail = typeof payload.detail === "string" ? payload.detail.toLowerCase() : ""; + return ( + detail.includes("stale pending approval request") || + detail.includes("stale pending user-input request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending user-input request") + ); +} + +export function derivePendingInteractions( + activities: ReadonlyArray, +): ReadonlyArray { + const pending = new Map(); + for (const activity of [...activities].toSorted((left, right) => { + if (left.sequence !== undefined && right.sequence !== undefined) + return left.sequence - right.sequence; + return left.createdAt.localeCompare(right.createdAt); + })) { + const payload = record(activity.payload); + if (payload === null || typeof payload.requestId !== "string") continue; + const requestId = payload.requestId; + if (activity.kind === "approval.requested") { + const kind = requestKind(payload.requestKind ?? payload.requestType); + if (kind !== null) { + pending.set(requestId, { + kind: "approval", + requestId, + requestKind: kind, + detail: typeof payload.detail === "string" ? payload.detail : null, + createdAt: activity.createdAt, + }); + } + } else if (activity.kind === "user-input.requested") { + const parsed = questions(payload.questions); + if (parsed !== null) { + pending.set(requestId, { + kind: "user-input", + requestId, + questions: parsed, + createdAt: activity.createdAt, + }); + } + } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { + pending.delete(requestId); + } else if ( + (activity.kind === "provider.approval.respond.failed" || + activity.kind === "provider.user-input.respond.failed") && + isStaleFailure(payload) + ) { + pending.delete(requestId); + } + } + return [...pending.values()].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} diff --git a/apps/discord-bot/src/presentation/prAssign.test.ts b/apps/discord-bot/src/presentation/prAssign.test.ts new file mode 100644 index 00000000000..a6eb4f47bd9 --- /dev/null +++ b/apps/discord-bot/src/presentation/prAssign.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + assignPullRequestAssignees, + formatAssignSlashReply, + parseAssignGithubOption, + resolveAssignGithubLogin, +} from "./prAssign.ts"; + +describe("parseAssignGithubOption", () => { + it("treats empty, @me, me, self as self", () => { + expect(parseAssignGithubOption(undefined)).toEqual({ kind: "self" }); + expect(parseAssignGithubOption(null)).toEqual({ kind: "self" }); + expect(parseAssignGithubOption("")).toEqual({ kind: "self" }); + expect(parseAssignGithubOption(" ")).toEqual({ kind: "self" }); + expect(parseAssignGithubOption("@me")).toEqual({ kind: "self" }); + expect(parseAssignGithubOption("ME")).toEqual({ kind: "self" }); + expect(parseAssignGithubOption("self")).toEqual({ kind: "self" }); + }); + + it("accepts logins with or without @", () => { + expect(parseAssignGithubOption("MindfulLearner")).toEqual({ + kind: "login", + login: "MindfulLearner", + }); + expect(parseAssignGithubOption("@MindfulLearner")).toEqual({ + kind: "login", + login: "MindfulLearner", + }); + }); + + it("rejects invalid logins", () => { + expect(parseAssignGithubOption("-bad")).toMatchObject({ kind: "invalid" }); + expect(parseAssignGithubOption("has space")).toMatchObject({ kind: "invalid" }); + }); +}); + +describe("resolveAssignGithubLogin", () => { + const map = new Map< + string, + { readonly github?: { readonly login?: string | undefined } | undefined } + >([ + ["111", { github: { login: "MindfulLearner" } }], + ["222", {}], + ]); + + it("returns explicit login without consulting the map", () => { + expect( + resolveAssignGithubLogin({ + githubOption: "@other-dev", + requesterDiscordId: "111", + resolveByDiscordId: (id) => map.get(id) ?? null, + }), + ).toEqual({ ok: true, login: "other-dev", source: "explicit" }); + }); + + it("resolves @me via identity map", () => { + expect( + resolveAssignGithubLogin({ + githubOption: undefined, + requesterDiscordId: "111", + resolveByDiscordId: (id) => map.get(id) ?? null, + }), + ).toEqual({ ok: true, login: "MindfulLearner", source: "self" }); + }); + + it("fails when unmapped or missing github login", () => { + expect( + resolveAssignGithubLogin({ + requesterDiscordId: "999", + resolveByDiscordId: (id) => map.get(id) ?? null, + }).ok, + ).toBe(false); + expect( + resolveAssignGithubLogin({ + requesterDiscordId: "222", + resolveByDiscordId: (id) => map.get(id) ?? null, + }).ok, + ).toBe(false); + }); +}); + +describe("assignPullRequestAssignees", () => { + it("posts assignees for each valid PR and reports errors", async () => { + const calls: string[][] = []; + const results = await assignPullRequestAssignees({ + prUrls: [ + "https://github.com/acme/app/pull/12", + "not-a-pr", + "https://github.com/acme/app/pull/13", + ], + login: "MindfulLearner", + execFile: async (_file, args) => { + calls.push([...args]); + if (args.includes("repos/acme/app/issues/13/assignees")) { + throw new Error("HTTP 422: Validation Failed"); + } + return { stdout: "", stderr: "" }; + }, + }); + + expect(results).toEqual([ + { url: "https://github.com/acme/app/pull/12", status: "assigned" }, + { url: "not-a-pr", status: "skipped", detail: "not a github pull request url" }, + { + url: "https://github.com/acme/app/pull/13", + status: "error", + detail: "HTTP 422: Validation Failed", + }, + ]); + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual([ + "api", + "repos/acme/app/issues/12/assignees", + "-X", + "POST", + "-f", + "assignees[]=MindfulLearner", + ]); + }); +}); + +describe("formatAssignSlashReply", () => { + it("summarizes assigned and failed PRs", () => { + const text = formatAssignSlashReply({ + login: "MindfulLearner", + results: [ + { url: "https://github.com/a/b/pull/1", status: "assigned" }, + { url: "https://github.com/a/b/pull/2", status: "error", detail: "forbidden" }, + ], + }); + expect(text).toContain("@MindfulLearner"); + expect(text).toContain("https://github.com/a/b/pull/1"); + expect(text).toContain("forbidden"); + }); + + it("handles empty PR list", () => { + expect(formatAssignSlashReply({ login: "x", results: [] })).toContain( + "No linked pull requests", + ); + }); +}); diff --git a/apps/discord-bot/src/presentation/prAssign.ts b/apps/discord-bot/src/presentation/prAssign.ts new file mode 100644 index 00000000000..a1da6636928 --- /dev/null +++ b/apps/discord-bot/src/presentation/prAssign.ts @@ -0,0 +1,196 @@ +/** + * Assign linked GitHub PRs from Discord slash `/omegent assign` (alias `/agent assign`). + * + * - No `github` option → assign to the invoker's mapped GitHub login (`@me`). + * - `github: MindfulLearner` / `@MindfulLearner` → assign that login. + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; + +import { gitCommandEnv } from "./githubLinks.ts"; +import { normalizePullRequestUrl, type NormalizedPullRequestLink } from "./prLinks.ts"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +type ExecFileResult = { + readonly stdout: string; + readonly stderr: string; +}; + +type ExecFileLike = ( + file: string, + args: ReadonlyArray, + options?: NodeChildProcess.ExecFileOptions, +) => Promise; + +/** Discord slash / mention placeholders that mean "the person who ran the command". */ +const SELF_ASSIGNEE_TOKENS = new Set(["", "@me", "me", "self"]); + +/** + * Normalize a GitHub login from slash input. + * Returns `null` for empty / @me / self (caller should resolve via identity map). + * Returns `{ ok: false }` for invalid logins. + */ +export function parseAssignGithubOption(raw: string | null | undefined): + | { readonly kind: "self" } + | { readonly kind: "login"; readonly login: string } + | { + readonly kind: "invalid"; + readonly detail: string; + } { + const trimmed = (raw ?? "").trim(); + if (SELF_ASSIGNEE_TOKENS.has(trimmed.toLowerCase())) { + return { kind: "self" }; + } + const withoutAt = trimmed.replace(/^@/u, "").trim(); + if (withoutAt.length === 0) { + return { kind: "self" }; + } + // GitHub login: alphanumeric / hyphen, max 39, cannot start/end with hyphen. + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(withoutAt)) { + return { + kind: "invalid", + detail: `Invalid GitHub username \`${trimmed}\`. Use a login like \`MindfulLearner\`, or omit for yourself.`, + }; + } + return { kind: "login", login: withoutAt }; +} + +export type ResolveAssignLoginInput = { + readonly githubOption?: string | null | undefined; + readonly requesterDiscordId?: string | null | undefined; + readonly resolveByDiscordId: ( + discordId: string, + ) => { readonly github?: { readonly login?: string | undefined } | undefined } | null; +}; + +export type ResolveAssignLoginResult = + | { readonly ok: true; readonly login: string; readonly source: "self" | "explicit" } + | { readonly ok: false; readonly message: string }; + +/** + * Resolve who to assign: explicit GitHub login, or requester via identity map. + */ +export function resolveAssignGithubLogin(input: ResolveAssignLoginInput): ResolveAssignLoginResult { + const parsed = parseAssignGithubOption(input.githubOption); + if (parsed.kind === "invalid") { + return { ok: false, message: parsed.detail }; + } + if (parsed.kind === "login") { + return { ok: true, login: parsed.login, source: "explicit" }; + } + + const discordId = input.requesterDiscordId?.trim() ?? ""; + if (discordId.length === 0) { + return { + ok: false, + message: "Could not resolve your Discord user id. Pass `github:` explicitly.", + }; + } + const person = input.resolveByDiscordId(discordId); + const login = person?.github?.login?.trim(); + if (login === undefined || login.length === 0) { + return { + ok: false, + message: + "You are not in the Discord→GitHub identity map (or have no `githubLogin`). Pass `github:`, or ask an operator to map your Discord id.", + }; + } + return { ok: true, login, source: "self" }; +} + +export type AssignPullRequestResult = { + readonly url: string; + readonly status: "assigned" | "skipped" | "error"; + readonly detail?: string | undefined; +}; + +/** + * Assign `login` on each PR URL via GitHub Issues assignees API (works for PRs). + * Best-effort: failures are returned per-URL and never throw. + */ +export async function assignPullRequestAssignees(input: { + readonly prUrls: ReadonlyArray; + readonly login: string; + readonly execFile?: ExecFileLike; +}): Promise> { + const execImpl = input.execFile ?? execFile; + const login = input.login.trim().replace(/^@/u, ""); + const results: AssignPullRequestResult[] = []; + + for (const raw of input.prUrls) { + const normalized = normalizePullRequestUrl(raw); + if (normalized === null) { + results.push({ url: raw, status: "skipped", detail: "not a github pull request url" }); + continue; + } + + try { + await postPullRequestAssignee(normalized, login, execImpl); + results.push({ url: normalized.url, status: "assigned" }); + } catch (error) { + results.push({ + url: normalized.url, + status: "error", + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + return results; +} + +async function postPullRequestAssignee( + pr: NormalizedPullRequestLink, + login: string, + execImpl: ExecFileLike, +): Promise { + // Issues assignees endpoint is the supported way to set PR assignees. + await execImpl( + "gh", + [ + "api", + `repos/${pr.owner}/${pr.repo}/issues/${pr.number}/assignees`, + "-X", + "POST", + "-f", + `assignees[]=${login}`, + ], + { env: gitCommandEnv(), maxBuffer: 2 * 1024 * 1024 }, + ); +} + +/** Format a short public slash reply summarizing assign results. */ +export function formatAssignSlashReply(input: { + readonly login: string; + readonly results: ReadonlyArray; +}): string { + const loginLabel = `@${input.login.replace(/^@/u, "")}`; + if (input.results.length === 0) { + return `No linked pull requests on this thread to assign to ${loginLabel}.`; + } + + const assigned = input.results.filter((r) => r.status === "assigned"); + const errors = input.results.filter((r) => r.status === "error"); + const skipped = input.results.filter((r) => r.status === "skipped"); + + const lines: string[] = []; + if (assigned.length > 0) { + lines.push( + `Assigned ${loginLabel} on ${assigned.length} PR(s):`, + ...assigned.map((r) => `• ${r.url}`), + ); + } + if (errors.length > 0) { + lines.push( + `Failed ${errors.length} PR(s):`, + ...errors.map((r) => `• ${r.url}${r.detail !== undefined ? ` — ${r.detail}` : ""}`), + ); + } + if (skipped.length > 0 && assigned.length === 0 && errors.length === 0) { + lines.push(`Nothing assignable (${skipped.length} skipped).`); + } + + return lines.join("\n"); +} diff --git a/apps/discord-bot/src/presentation/prLinks.test.ts b/apps/discord-bot/src/presentation/prLinks.test.ts new file mode 100644 index 00000000000..af7b06ae902 --- /dev/null +++ b/apps/discord-bot/src/presentation/prLinks.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + extractPullRequestUrls, + extractPullRequestUrlsFromDiscordMessage, + formatPullRequestLabel, + formatPullRequestLinksForDiscord, + mergePullRequestUrls, + sortPullRequestUrlsForDisplay, + normalizeGithubRepoSlug, + normalizePullRequestUrl, +} from "./prLinks.ts"; + +describe("normalizePullRequestUrl", () => { + it("canonicalizes GitHub PR URLs", () => { + expect(normalizePullRequestUrl("https://github.com/pingdotgg/t3code/pull/42")).toEqual({ + url: "https://github.com/pingdotgg/t3code/pull/42", + owner: "pingdotgg", + repo: "t3code", + number: 42, + repoSlug: "pingdotgg/t3code", + }); + }); + + it("strips subpaths, query, hash, and www; lowercases owner/repo", () => { + expect( + normalizePullRequestUrl( + "https://www.GitHub.com/PingDotGG/T3Code/pull/7/files?w=1#discussion_r1", + ), + ).toEqual({ + url: "https://github.com/pingdotgg/t3code/pull/7", + owner: "pingdotgg", + repo: "t3code", + number: 7, + repoSlug: "pingdotgg/t3code", + }); + }); + + it("rejects non-PR URLs", () => { + expect(normalizePullRequestUrl("https://github.com/pingdotgg/t3code/issues/1")).toBeNull(); + expect(normalizePullRequestUrl("https://gitlab.com/foo/bar/-/merge_requests/1")).toBeNull(); + expect(normalizePullRequestUrl("not a url")).toBeNull(); + }); +}); + +describe("normalizeGithubRepoSlug", () => { + it("accepts owner/repo and github URLs", () => { + expect(normalizeGithubRepoSlug("Example-Org/Configurator")).toBe("example-org/configurator"); + expect(normalizeGithubRepoSlug("https://github.com/example-org/scanner.git")).toBe( + "example-org/scanner", + ); + }); +}); + +describe("formatPullRequestLabel", () => { + const pr = { + owner: "example-org", + repo: "configurator", + number: 123, + repoSlug: "example-org/configurator", + }; + + it("uses PR #N when channel repo matches or is unknown", () => { + expect(formatPullRequestLabel(pr, "example-org/configurator")).toBe("PR #123"); + expect(formatPullRequestLabel(pr, null)).toBe("PR #123"); + expect(formatPullRequestLabel(pr, undefined)).toBe("PR #123"); + }); + + it("prefixes owner/repo when the PR is from another channel repo", () => { + expect(formatPullRequestLabel(pr, "example-org/scanner")).toBe( + "example-org/configurator PR #123", + ); + }); +}); + +describe("extractPullRequestUrls", () => { + it("extracts URLs in first-seen order without duplicates", () => { + const text = [ + "see https://github.com/acme/widgets/pull/42", + "and https://github.com/acme/widgets/pull/7/files", + "and https://github.com/acme/widgets/pull/42 again", + "plus **Draft PR:** https://github.com/example-org/scanner/pull/1950", + ].join(" "); + expect(extractPullRequestUrls(text)).toEqual([ + "https://github.com/acme/widgets/pull/42", + "https://github.com/acme/widgets/pull/7", + "https://github.com/example-org/scanner/pull/1950", + ]); + }); + + it("reads embeds as well as content", () => { + expect( + extractPullRequestUrlsFromDiscordMessage({ + content: "ping", + embeds: [ + { + url: "https://github.com/acme/widgets/pull/9", + title: "Fix packing", + }, + ], + }), + ).toEqual(["https://github.com/acme/widgets/pull/9"]); + }); +}); + +describe("mergePullRequestUrls", () => { + it("appends only new URLs in order", () => { + expect( + mergePullRequestUrls( + ["https://github.com/a/b/pull/1", "https://github.com/a/b/pull/2"], + [ + "https://github.com/a/b/pull/2/files", + "https://github.com/a/b/pull/3", + "https://github.com/A/B/pull/1", + ], + ), + ).toEqual([ + "https://github.com/a/b/pull/1", + "https://github.com/a/b/pull/2", + "https://github.com/a/b/pull/3", + ]); + }); +}); + +describe("formatPullRequestLinksForDiscord", () => { + it("renders PR #N for same-repo PRs and prefixes foreign repos", () => { + expect( + formatPullRequestLinksForDiscord( + [ + "https://github.com/example-org/scanner/pull/1950", + "https://github.com/example-org/configurator/pull/123", + ], + { channelRepoSlug: "example-org/scanner" }, + ), + ).toBe( + [ + "**PRs**", + "• [PR #1950](https://github.com/example-org/scanner/pull/1950)", + "• [example-org/configurator PR #123](https://github.com/example-org/configurator/pull/123)", + ].join("\n"), + ); + }); + + it("returns null for empty lists", () => { + expect(formatPullRequestLinksForDiscord([])).toBeNull(); + }); + + it("sorts current-project PRs above foreign repos while keeping first-seen order within groups", () => { + expect( + formatPullRequestLinksForDiscord( + [ + "https://github.com/example-org/configurator/pull/10", + "https://github.com/example-org/scanner/pull/2", + "https://github.com/other/repo/pull/99", + "https://github.com/example-org/scanner/pull/1", + "https://github.com/example-org/configurator/pull/11", + ], + { channelRepoSlug: "example-org/scanner" }, + ), + ).toBe( + [ + "**PRs**", + "• [PR #2](https://github.com/example-org/scanner/pull/2)", + "• [PR #1](https://github.com/example-org/scanner/pull/1)", + "• [example-org/configurator PR #10](https://github.com/example-org/configurator/pull/10)", + "• [other/repo PR #99](https://github.com/other/repo/pull/99)", + "• [example-org/configurator PR #11](https://github.com/example-org/configurator/pull/11)", + ].join("\n"), + ); + }); +}); + +describe("sortPullRequestUrlsForDisplay", () => { + it("leaves order unchanged when channel repo is unknown", () => { + const urls = ["https://github.com/a/b/pull/1", "https://github.com/c/d/pull/2"]; + expect(sortPullRequestUrlsForDisplay(urls, null)).toEqual(urls); + }); +}); diff --git a/apps/discord-bot/src/presentation/prLinks.ts b/apps/discord-bot/src/presentation/prLinks.ts new file mode 100644 index 00000000000..4d26f66c228 --- /dev/null +++ b/apps/discord-bot/src/presentation/prLinks.ts @@ -0,0 +1,196 @@ +/** + * Extract and format GitHub pull request URLs from Discord message text. + * + * URLs are stored in first-seen order. Duplicates are ignored after normalization + * (strip query/hash, drop common subpaths like /files, case-fold host/owner/repo). + */ + +/** GitHub PR URL: https://github.com/owner/repo/pull/123[/optional-suffix] */ +const GITHUB_PR_URL_SOURCE = + "https?:\\/\\/(?:www\\.)?github\\.com\\/([A-Za-z0-9_.-]+)\\/([A-Za-z0-9_.-]+)\\/pull\\/(\\d+)(?:\\/[^\\s<>\"'#?]*)?(?:[?#][^\\s<>\"']*)?"; + +/** Non-global: safe for single-URL normalization. */ +const GITHUB_PR_URL_SINGLE = new RegExp(GITHUB_PR_URL_SOURCE, "i"); + +/** Global: for multi-match extraction only — never share lastIndex with single-match helpers. */ +const GITHUB_PR_URL_GLOBAL = new RegExp(GITHUB_PR_URL_SOURCE, "gi"); + +export type NormalizedPullRequestLink = { + /** Canonical browse URL: https://github.com/owner/repo/pull/N */ + readonly url: string; + readonly owner: string; + readonly repo: string; + readonly number: number; + /** `owner/repo` slug (lowercase). */ + readonly repoSlug: string; +}; + +/** + * Normalize `owner/repo` or a github.com repo URL to a lowercase `owner/repo` slug. + */ +export function normalizeGithubRepoSlug(raw: string | null | undefined): string | null { + if (raw === null || raw === undefined) return null; + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + + const urlMatch = + /^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:\/.*)?$/iu.exec( + trimmed, + ); + if (urlMatch?.[1] !== undefined && urlMatch[2] !== undefined) { + return `${urlMatch[1].toLowerCase()}/${urlMatch[2].toLowerCase().replace(/\.git$/iu, "")}`; + } + + const slugMatch = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/u.exec(trimmed); + if (slugMatch?.[1] !== undefined && slugMatch[2] !== undefined) { + return `${slugMatch[1].toLowerCase()}/${slugMatch[2].toLowerCase()}`; + } + return null; +} + +/** + * Discord label for a PR: + * - same repo as the channel (or unknown channel repo) → `PR #N` + * - different repo → `owner/repo PR #N` + */ +export function formatPullRequestLabel( + pr: Pick, + channelRepoSlug?: string | null, +): string { + const channel = normalizeGithubRepoSlug(channelRepoSlug); + if (channel === null || channel === pr.repoSlug) { + return `PR #${pr.number}`; + } + return `${pr.repoSlug} PR #${pr.number}`; +} + +/** + * Normalize a raw GitHub PR URL (or fragment containing one) to a canonical form. + * Returns null when the text does not contain a valid GitHub PR URL. + */ +export function normalizePullRequestUrl(raw: string): NormalizedPullRequestLink | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + + const match = GITHUB_PR_URL_SINGLE.exec(trimmed); + if (match === null) return null; + + const owner = (match[1] ?? "").toLowerCase(); + const repo = (match[2] ?? "").replace(/\.git$/iu, "").toLowerCase(); + const number = Number.parseInt(match[3] ?? "", 10); + if (owner.length === 0 || repo.length === 0 || !Number.isFinite(number) || number <= 0) { + return null; + } + + const url = `https://github.com/${owner}/${repo}/pull/${number}`; + return { + url, + owner, + repo, + number, + repoSlug: `${owner}/${repo}`, + }; +} + +/** + * Extract PR URLs from free text in left-to-right first-seen order without duplicates. + */ +export function extractPullRequestUrls(text: string | null | undefined): ReadonlyArray { + if (text === null || text === undefined || text.length === 0) return []; + + const found: string[] = []; + const seen = new Set(); + + for (const match of text.matchAll(GITHUB_PR_URL_GLOBAL)) { + const normalized = normalizePullRequestUrl(match[0] ?? ""); + if (normalized === null || seen.has(normalized.url)) continue; + seen.add(normalized.url); + found.push(normalized.url); + } + return found; +} + +export function extractPullRequestUrlsFromDiscordMessage(input: { + readonly content?: string | null | undefined; + readonly embeds?: + | ReadonlyArray<{ + readonly url?: string | null | undefined; + readonly title?: string | null | undefined; + readonly description?: string | null | undefined; + readonly footer?: { readonly text?: string | null | undefined } | null | undefined; + }> + | null + | undefined; +}): ReadonlyArray { + const parts: string[] = []; + if (input.content) parts.push(input.content); + for (const embed of input.embeds ?? []) { + if (embed.url) parts.push(embed.url); + if (embed.title) parts.push(embed.title); + if (embed.description) parts.push(embed.description); + if (embed.footer?.text) parts.push(embed.footer.text); + } + return extractPullRequestUrls(parts.join("\n")); +} + +/** Append newly seen PR URLs preserving first-seen order; never duplicates. */ +export function mergePullRequestUrls( + existing: ReadonlyArray | null | undefined, + incoming: ReadonlyArray | null | undefined, +): ReadonlyArray { + const result: string[] = []; + const seen = new Set(); + for (const raw of [...(existing ?? []), ...(incoming ?? [])]) { + const normalized = normalizePullRequestUrl(raw); + if (normalized === null || seen.has(normalized.url)) continue; + seen.add(normalized.url); + result.push(normalized.url); + } + return result; +} + +/** + * Order PR URLs for the pinned thread header: current project/repo first, + * then everything else. Within each group, preserve first-seen order. + */ +export function sortPullRequestUrlsForDisplay( + urls: ReadonlyArray, + channelRepoSlug?: string | null, +): ReadonlyArray { + const ordered = mergePullRequestUrls([], urls); + if (ordered.length === 0) return ordered; + + const channel = normalizeGithubRepoSlug(channelRepoSlug); + if (channel === null) return ordered; + + const current: string[] = []; + const other: string[] = []; + for (const url of ordered) { + const normalized = normalizePullRequestUrl(url); + if (normalized !== null && normalized.repoSlug === channel) { + current.push(normalized.url); + } else { + other.push(normalized?.url ?? url); + } + } + return [...current, ...other]; +} + +export function formatPullRequestLinksForDiscord( + urls: ReadonlyArray, + options?: { + /** Channel / project GitHub repo as `owner/repo` (or github URL). */ + readonly channelRepoSlug?: string | null; + }, +): string | null { + const ordered = sortPullRequestUrlsForDisplay(urls, options?.channelRepoSlug); + if (ordered.length === 0) return null; + + const lines = ordered.map((url) => { + const normalized = normalizePullRequestUrl(url); + if (normalized === null) return `• ${url}`; + const label = formatPullRequestLabel(normalized, options?.channelRepoSlug); + return `• [${label}](${normalized.url})`; + }); + return ["**PRs**", ...lines].join("\n"); +} diff --git a/apps/discord-bot/src/presentation/slashCommands.test.ts b/apps/discord-bot/src/presentation/slashCommands.test.ts new file mode 100644 index 00000000000..92b5561fd27 --- /dev/null +++ b/apps/discord-bot/src/presentation/slashCommands.test.ts @@ -0,0 +1,122 @@ +import { Discord } from "dfx"; +import { describe, expect, it } from "vite-plus/test"; + +import { + formatAskSlashAck, + isThreadTalkSlashAction, + OMEGENT_SLASH_COMMAND, + OMEGENT_SLASH_COMMAND_ALIAS, + OMEGENT_SLASH_COMMAND_NAME, + slashDefer, + slashReply, + threadTalkSlashReply, +} from "./slashCommands.ts"; + +describe("Omegent slash command definition", () => { + it("registers /omegent (alias /agent) with the control-plane subcommands", () => { + expect(OMEGENT_SLASH_COMMAND_NAME).toBe("omegent"); + expect(OMEGENT_SLASH_COMMAND_ALIAS).toBe("agent"); + expect(OMEGENT_SLASH_COMMAND.name).toBe("omegent"); + const names = OMEGENT_SLASH_COMMAND.options.map((option) => option.name); + expect(names).toEqual([ + "ask", + "steer", + "queue", + "steernow", + "help", + "stop", + "compact", + "thread-talk", + "link", + "refresh-indicators", + "assign", + ]); + }); + + it("registers optional github option on assign", () => { + const assign = OMEGENT_SLASH_COMMAND.options.find((option) => option.name === "assign"); + expect(assign?.options?.[0]?.name).toBe("github"); + expect(assign?.options?.[0]?.required).toBe(false); + }); + + it("uses Discord subcommand option types", () => { + for (const option of OMEGENT_SLASH_COMMAND.options) { + expect(option.type).toBe(Discord.ApplicationCommandOptionType.SUB_COMMAND); + } + const threadTalk = OMEGENT_SLASH_COMMAND.options.find( + (option) => option.name === "thread-talk", + ); + expect(threadTalk?.options?.[0]?.name).toBe("action"); + expect(threadTalk?.options?.[0]?.choices?.map((choice) => choice.value)).toEqual([ + "on", + "off", + "status", + ]); + }); +}); + +describe("slash reply helpers", () => { + it("builds public and ephemeral replies", () => { + const publicReply = slashReply("hello"); + expect(publicReply).toEqual({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "hello" }, + }); + + const ephemeralReply = slashReply("secret", { ephemeral: true }); + expect(ephemeralReply).toEqual({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "secret", flags: Discord.MessageFlags.Ephemeral }, + }); + }); + + it("builds an ephemeral deferred ack for slow commands", () => { + expect(slashDefer({ ephemeral: true })).toEqual({ + type: Discord.InteractionCallbackTypes.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { flags: Discord.MessageFlags.Ephemeral }, + }); + }); + + it("formats a public ask ack with optional flags and truncation", () => { + expect( + formatAskSlashAck({ + displayName: "Example User", + prompt: "fix the flaky test", + plan: true, + local: false, + }), + ).toBe("**Example User** asked (`--plan`):\nfix the flaky test"); + + const long = "x".repeat(300); + const ack = formatAskSlashAck({ + displayName: "Example User", + prompt: long, + plan: false, + local: true, + }); + expect(ack).toContain("(`--local`)"); + expect(ack.endsWith("…")).toBe(true); + expect(ack.length).toBeLessThan(long.length + 40); + }); + + it("makes thread-talk status ephemeral and on/off public", () => { + expect(isThreadTalkSlashAction("on")).toBe(true); + expect(isThreadTalkSlashAction("nope")).toBe(false); + + const onReply = threadTalkSlashReply({ action: "on", enabled: true }); + expect(onReply).toMatchObject({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: expect.stringContaining("Thread-talk is **on**") }, + }); + expect(onReply).not.toMatchObject({ data: { flags: Discord.MessageFlags.Ephemeral } }); + + const statusReply = threadTalkSlashReply({ action: "status", enabled: false }); + expect(statusReply).toMatchObject({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: expect.stringContaining("Thread-talk is **off**"), + flags: Discord.MessageFlags.Ephemeral, + }, + }); + }); +}); diff --git a/apps/discord-bot/src/presentation/slashCommands.ts b/apps/discord-bot/src/presentation/slashCommands.ts new file mode 100644 index 00000000000..6d0d3957024 --- /dev/null +++ b/apps/discord-bot/src/presentation/slashCommands.ts @@ -0,0 +1,282 @@ +import { Discord, Ix } from "dfx"; + +/** + * Guild-scoped `/omegent` control commands, with `/agent` as an alias, alongside + * existing `@Omegent` mentions. Guild registration propagates immediately for + * try-out; mentions stay the prompt path. + */ +export const OMEGENT_SLASH_COMMAND_NAME = "omegent" as const; + +/** + * Alias command name. Discord has no native command aliases, so `/agent` is + * registered as a second command that shares the `/omegent` handler. + */ +export const OMEGENT_SLASH_COMMAND_ALIAS = "agent" as const; + +export const OMEGENT_SLASH_COMMAND = { + name: OMEGENT_SLASH_COMMAND_NAME, + description: "Omegent bot commands (mentions still work for prompts)", + options: [ + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "ask", + description: "Start or continue a turn (public; same as @Omegent prompt)", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "prompt", + description: "What you want Omegent to do", + required: true, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "model", + description: "Model slug (e.g. gpt-5.4)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "provider", + description: "Provider instance id (e.g. codex)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "base", + description: "Base branch for a new worktree", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "local", + description: "Work without a worktree", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "plan", + description: "Run in plan mode", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "steer", + description: "Mid-turn: inject this prompt now (default is queue)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "queue", + description: "Mid-turn: park until the turn finishes (default)", + required: false, + }, + ], + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "steer", + description: "Continue work and inject mid-turn (steer into the active turn)", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "prompt", + description: "What you want Omegent to do", + required: true, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "model", + description: "Model slug (e.g. gpt-5.4)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "provider", + description: "Provider instance id (e.g. codex)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "plan", + description: "Run in plan mode", + required: false, + }, + ], + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "queue", + description: "Continue work and park mid-turn until the active turn finishes", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "prompt", + description: "What you want Omegent to do", + required: true, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "model", + description: "Model slug (e.g. gpt-5.4)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "provider", + description: "Provider instance id (e.g. codex)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "plan", + description: "Run in plan mode", + required: false, + }, + ], + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "steernow", + description: "Inject every parked follow-up into the active turn (FIFO)", + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "help", + description: "Show the channel info / help pin", + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "stop", + description: "Stop the active turn in this linked thread", + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "compact", + description: "Compact provider context for this linked thread (posts token stats)", + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "thread-talk", + description: "Mention-free replies in this linked thread", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "action", + description: "Turn mention-free mode on/off, or report status", + required: true, + choices: [ + { name: "on", value: "on" }, + { name: "off", value: "off" }, + { name: "status", value: "status" }, + ], + }, + ], + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "link", + description: "Link this channel or unlinked thread to an existing Omegent thread", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "ref", + description: "Omegent thread id or web URL containing ?thread=", + required: true, + }, + ], + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "refresh-indicators", + description: "Refresh Discord thread title badges (PR/VCS indicators)", + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "assign", + description: "Assign linked PR(s) on this thread (default: you via identity map)", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "github", + description: "GitHub login (default: you / @me from the identity map)", + required: false, + }, + ], + }, + ], +} as const; + +export type ThreadTalkSlashAction = "on" | "off" | "status"; + +export function isThreadTalkSlashAction(value: string): value is ThreadTalkSlashAction { + return value === "on" || value === "off" || value === "status"; +} + +/** Build a standard interaction message response. */ +export function slashReply( + content: string, + options?: { readonly ephemeral?: boolean }, +): ReturnType { + return Ix.response({ + type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content, + ...(options?.ephemeral === true ? { flags: Discord.MessageFlags.Ephemeral } : {}), + }, + }); +} + +/** + * Ack within Discord's ~3s interaction window when work continues in the background. + * Follow up with `updateOriginalWebhookMessage(application_id, token, …)`. + * + * Note: dfx's `Ix.response` typing omits `data` on deferred type 5, but Discord accepts + * ephemeral flags there — return the REST payload shape directly. + */ +export function slashDefer(options?: { + readonly ephemeral?: boolean; +}): Discord.CreateInteractionResponseRequest { + if (options?.ephemeral === true) { + return { + type: Discord.InteractionCallbackTypes.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { flags: Discord.MessageFlags.Ephemeral }, + }; + } + return { + type: Discord.InteractionCallbackTypes.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + }; +} + +export function threadTalkSlashReply(input: { + readonly action: ThreadTalkSlashAction; + readonly enabled: boolean; +}): ReturnType { + const content = input.enabled + ? "Thread-talk is **on**. New human messages in this linked thread will be sent to Omegent without requiring a mention." + : "Thread-talk is **off**. Mention `@Omegent` or use `/omegent` to send a message to Omegent."; + // on/off change shared thread policy → public; status is personal + return slashReply(content, { ephemeral: input.action === "status" }); +} + +/** Preview used in public `/omegent ask` acks (keep Discord-message friendly). */ +export function formatAskSlashAck(input: { + readonly displayName: string; + readonly prompt: string; + readonly plan: boolean; + readonly local: boolean; + readonly followUpDelivery?: "steer" | "queue"; +}): string { + const flags = [ + input.plan ? "`--plan`" : null, + input.local ? "`--local`" : null, + input.followUpDelivery === "queue" + ? "`--queue`" + : input.followUpDelivery === "steer" + ? "`--steer`" + : null, + ].filter((value): value is string => value !== null); + const flagSuffix = flags.length > 0 ? ` (${flags.join(" ")})` : ""; + const preview = + input.prompt.length > 280 ? `${input.prompt.slice(0, 277).trimEnd()}…` : input.prompt; + return `**${input.displayName}** asked${flagSuffix}:\n${preview}`; +} diff --git a/apps/discord-bot/src/presentation/t3ThreadRef.test.ts b/apps/discord-bot/src/presentation/t3ThreadRef.test.ts new file mode 100644 index 00000000000..6da96a11e56 --- /dev/null +++ b/apps/discord-bot/src/presentation/t3ThreadRef.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { extractT3ThreadId, parseLinkThreadCommand } from "./t3ThreadRef.ts"; + +describe("extractT3ThreadId", () => { + it("returns bare ids", () => { + expect(extractT3ThreadId(" abc-123 ")).toBe("abc-123"); + expect(extractT3ThreadId("550e8400-e29b-41d4-a716-446655440000")).toBe( + "550e8400-e29b-41d4-a716-446655440000", + ); + }); + + it("extracts thread from query strings and full URLs", () => { + expect(extractT3ThreadId("https://t3.example.com/?thread=tid-1")).toBe("tid-1"); + expect(extractT3ThreadId("http://127.0.0.1:5173/?foo=1&thread=tid-2")).toBe("tid-2"); + expect(extractT3ThreadId("https://host/?thread=tid%2Fwith%2Fslash")).toBe("tid/with/slash"); + }); + + it("rejects empty or ambiguous values", () => { + expect(extractT3ThreadId("")).toBeNull(); + expect(extractT3ThreadId("https://example.com/path")).toBeNull(); + expect(extractT3ThreadId("not a single token")).toBeNull(); + }); +}); + +describe("parseLinkThreadCommand", () => { + it("parses link / pick-up / pickup with bare id or url", () => { + expect(parseLinkThreadCommand("link tid-1")).toEqual({ + kind: "link-thread", + t3ThreadId: "tid-1", + }); + expect(parseLinkThreadCommand(" Pick-Up https://x/?thread=tid-2 ")).toEqual({ + kind: "link-thread", + t3ThreadId: "tid-2", + }); + expect(parseLinkThreadCommand("pickup tid-3")).toEqual({ + kind: "link-thread", + t3ThreadId: "tid-3", + }); + }); + + it("requires exact command form", () => { + expect(parseLinkThreadCommand("link")).toBeNull(); + expect(parseLinkThreadCommand("link tid-1 please")).toBeNull(); + expect(parseLinkThreadCommand("please link tid-1")).toBeNull(); + expect(parseLinkThreadCommand("fix the flaky test")).toBeNull(); + }); +}); diff --git a/apps/discord-bot/src/presentation/t3ThreadRef.ts b/apps/discord-bot/src/presentation/t3ThreadRef.ts new file mode 100644 index 00000000000..e950301e5f2 --- /dev/null +++ b/apps/discord-bot/src/presentation/t3ThreadRef.ts @@ -0,0 +1,42 @@ +/** + * Parse a bare T3 thread id or a T3 web URL that embeds one (`?thread=` / `&thread=`). + */ +export function extractT3ThreadId(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + + const fromQuery = /(?:\?|&)thread=([^&\s#]+)/iu.exec(trimmed); + if (fromQuery?.[1] !== undefined) { + try { + const decoded = decodeURIComponent(fromQuery[1]).trim(); + return decoded.length > 0 ? decoded : null; + } catch { + const value = fromQuery[1].trim(); + return value.length > 0 ? value : null; + } + } + + // Bare id: single token, not a URL/path. + if (/^https?:\/\//iu.test(trimmed) || trimmed.includes("/") || /\s/u.test(trimmed)) { + return null; + } + return trimmed; +} + +export type LinkThreadCommand = { + readonly kind: "link-thread"; + readonly t3ThreadId: string; +}; + +/** + * `@bot link ` / `pick-up` / `pickup` — open or join an existing T3 thread. + * Exact command form only (no extra prompt text). + */ +export function parseLinkThreadCommand(raw: string): LinkThreadCommand | null { + const trimmed = raw.trim().replace(/\s+/gu, " "); + const match = /^(?:link|pick-up|pickup)\s+(\S+)\s*$/iu.exec(trimmed); + if (match?.[1] === undefined) return null; + const t3ThreadId = extractT3ThreadId(match[1]); + if (t3ThreadId === null) return null; + return { kind: "link-thread", t3ThreadId }; +} diff --git a/apps/discord-bot/src/presentation/tasks.test.ts b/apps/discord-bot/src/presentation/tasks.test.ts new file mode 100644 index 00000000000..f9827cce155 --- /dev/null +++ b/apps/discord-bot/src/presentation/tasks.test.ts @@ -0,0 +1,73 @@ +import { TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { formatTasksForDiscord, presentTasks } from "./tasks.ts"; + +function plan( + id: string, + turnId: string | null, + createdAt: string, + steps: ReadonlyArray>, + explanation?: string, +): OrchestrationThreadActivity { + return { + id, + kind: "turn.plan.updated", + tone: "info", + summary: "Plan updated", + payload: { + plan: steps, + ...(explanation === undefined ? {} : { explanation }), + }, + turnId, + createdAt, + } as OrchestrationThreadActivity; +} + +describe("presentTasks", () => { + it("uses the active turn id as the task context key", () => { + expect( + presentTasks( + [ + plan("old", "turn-1", "2026-07-16T00:00:00.000Z", [{ step: "Old task" }]), + plan("current", "turn-2", "2026-07-16T00:00:01.000Z", [{ step: "Ship task" }]), + ], + TurnId.make("turn-2"), + ), + ).toMatchObject({ + contextKey: "turn-2", + tasks: [{ step: "Ship task", status: "pending" }], + }); + }); + + it("falls back to the activity id when the plan is not attached to a turn", () => { + expect( + presentTasks( + [plan("evt-plan", null, "2026-07-16T00:00:00.000Z", [{ step: "Detached task" }])], + null, + ), + ).toMatchObject({ + contextKey: "evt-plan", + tasks: [{ step: "Detached task", status: "pending" }], + }); + }); +}); + +describe("formatTasksForDiscord", () => { + it("renders a compact progress summary", () => { + const rendered = formatTasksForDiscord({ + contextKey: "turn-2", + createdAt: "2026-07-16T00:00:01.000Z", + explanation: "Keep one task message updated", + tasks: [ + { step: "Ship task", status: "inProgress" }, + { step: "Verify", status: "completed" }, + ], + }); + + expect(rendered).toContain("**Tasks 1/2**"); + expect(rendered).toContain("_Keep one task message updated_"); + expect(rendered).toContain("◐ Ship task"); + expect(rendered).toContain("✅ Verify"); + }); +}); diff --git a/apps/discord-bot/src/presentation/tasks.ts b/apps/discord-bot/src/presentation/tasks.ts new file mode 100644 index 00000000000..aee757f82cd --- /dev/null +++ b/apps/discord-bot/src/presentation/tasks.ts @@ -0,0 +1,70 @@ +import type { OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; + +export interface PresentedTask { + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; +} + +export interface PresentedTasks { + readonly contextKey: string; + readonly explanation: string | null; + readonly createdAt: string; + readonly tasks: ReadonlyArray; +} + +function planFromActivity(activity: OrchestrationThreadActivity): PresentedTasks | null { + if (activity.kind !== "turn.plan.updated") return null; + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + if (!Array.isArray(payload?.plan)) return null; + const tasks: PresentedTask[] = []; + for (const entry of payload.plan) { + if (typeof entry !== "object" || entry === null) continue; + const task = entry as Record; + if (typeof task.step !== "string" || task.step.trim() === "") continue; + tasks.push({ + step: task.step.trim(), + status: task.status === "completed" || task.status === "inProgress" ? task.status : "pending", + }); + } + if (tasks.length === 0) return null; + return { + contextKey: activity.turnId ?? activity.id, + explanation: typeof payload.explanation === "string" ? payload.explanation : null, + createdAt: activity.createdAt, + tasks, + }; +} + +export function presentTasks( + activities: ReadonlyArray, + latestTurnId: TurnId | null, +): PresentedTasks | null { + const plans = activities + .filter((activity) => activity.kind === "turn.plan.updated") + .toSorted( + (left, right) => + (left.sequence ?? 0) - (right.sequence ?? 0) || + left.createdAt.localeCompare(right.createdAt), + ); + const preferred = + (latestTurnId === null + ? undefined + : plans.findLast((activity) => activity.turnId === latestTurnId)) ?? plans.at(-1); + return preferred === undefined ? null : planFromActivity(preferred); +} + +export function formatTasksForDiscord(tasks: PresentedTasks): string { + const completed = tasks.tasks.filter((task) => task.status === "completed").length; + const lines = [ + `**Tasks ${completed}/${tasks.tasks.length}**`, + ...(tasks.explanation ? [`_${tasks.explanation}_`] : []), + ...tasks.tasks.map((task) => { + const icon = task.status === "completed" ? "✅" : task.status === "inProgress" ? "◐" : "○"; + return `${icon} ${task.step}`; + }), + ]; + return lines.join("\n"); +} diff --git a/apps/discord-bot/src/presentation/threadContext.test.ts b/apps/discord-bot/src/presentation/threadContext.test.ts new file mode 100644 index 00000000000..3798553cba6 --- /dev/null +++ b/apps/discord-bot/src/presentation/threadContext.test.ts @@ -0,0 +1,434 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + buildFirstTurnPrompt, + buildDiscordTurnPrompt, + buildSentryBootstrapPrompt, + compactDiscordJumpRef, + extractSentryHints, + formatDiscordMessage, + formatEmbed, + formatLinkedJiraWorkItemsBlock, + formatReferencedMessageBlock, + formatRequesterLine, + looksLikeSentryContext, + resolveAgentTurnRulesPath, +} from "./threadContext.ts"; + +describe("formatEmbed", () => { + it("formats Sentry-like embeds", () => { + const text = formatEmbed({ + title: "CarrierErrorWrapped", + description: "SchenkerUnexpectedError: Unerwarteter Schenker-Fehler bei der Buchung", + fields: [ + { name: "company", value: "mako" }, + { name: "environment", value: "prod" }, + { name: "os", value: "Alpine Linux 3.24.1" }, + ], + footer: { text: "EXAMPLE-PROJECT-API-JW via Scanner API • Today at 12:55 PM" }, + }); + expect(text).toContain("CarrierErrorWrapped"); + expect(text).toContain("company: mako"); + expect(text).toContain("EXAMPLE-PROJECT-API-JW"); + }); +}); + +describe("extractSentryHints", () => { + it("finds issue ids and sentry urls", () => { + const hints = extractSentryHints( + "Issue EXAMPLE-PROJECT-API-JW see https://example.sentry.io/issues/123/", + ); + expect(hints.issueIds).toContain("EXAMPLE-PROJECT-API-JW"); + expect(hints.sentryUrls[0]).toContain("sentry.io"); + }); +}); + +describe("compactDiscordJumpRef / formatRequesterLine", () => { + it("compresses channel jumps to g/c/m", () => { + expect( + compactDiscordJumpRef( + "https://discord.com/channels/1083767712431480922/1531376362399465595/1531376362399465595", + ), + ).toBe("1083767712431480922/1531376362399465595/1531376362399465595"); + expect(compactDiscordJumpRef("https://example.com/x")).toBeNull(); + }); + + it("formats compact requester lines", () => { + expect( + formatRequesterLine({ + id: "m", + author: { id: "1", username: "patroza", displayName: "Patrick Roza" }, + }), + ).toBe("1@patroza Patrick Roza"); + expect( + formatRequesterLine({ + id: "m", + author: { id: "1", username: "patroza", displayName: "patroza" }, + }), + ).toBe("1@patroza"); + }); +}); + +describe("looksLikeSentryContext / buildFirstTurnPrompt", () => { + it("uses full Sentry bootstrap only when Sentry is present", () => { + const sentryStarter = { + id: "1", + author: { username: "Sentry", bot: true }, + content: "", + embeds: [ + { + title: "CarrierErrorWrapped", + description: "SchenkerUnexpectedError", + url: "https://example.sentry.io/issues/7506163172/", + fields: [{ name: "environment", value: "prod" }], + footer: { text: "EXAMPLE-PROJECT-API-JW via Scanner API" }, + }, + ], + }; + + expect( + looksLikeSentryContext({ + starter: sentryStarter, + mentionPrompt: "whats going on?", + }), + ).toBe(true); + + const sentryPrompt = buildFirstTurnPrompt({ + projectShortName: "example-project", + workspaceRoot: "/home/tester/projects/example", + mentionPrompt: "whats going on?", + mentionMessage: { + id: "mention-1", + author: { id: "42", username: "tester", displayName: "Example User" }, + }, + honeycombTraceUrlTemplate: + "https://ui.honeycomb.io/example/environments/{environment}/trace?trace_id={traceId}", + starter: sentryStarter, + }); + expect(sentryPrompt).toContain("Discord investigation bootstrap"); + expect(sentryPrompt).toContain("hc_tpl:"); + expect(sentryPrompt).toContain("CarrierErrorWrapped"); + expect(sentryPrompt).toContain("## Discord conversation context"); + expect(sentryPrompt).toContain(`rules: ${resolveAgentTurnRulesPath()}`); + expect(sentryPrompt).toContain("req: 42@tester Example User"); + expect(sentryPrompt).not.toContain("Lead with the essential answer"); + expect(sentryPrompt).not.toContain("Always open a GitHub PR"); + expect(sentryPrompt).not.toContain("https://discord.com/users/"); + }); + + it("does not use Sentry bootstrap for ordinary thread starters", () => { + const starter = { + id: "2", + author: { username: "Example User", bot: false }, + content: "Can you check the open PR?", + }; + + expect( + looksLikeSentryContext({ + starter, + mentionPrompt: "please review", + }), + ).toBe(false); + + const prompt = buildFirstTurnPrompt({ + projectShortName: "example-project", + workspaceRoot: "/tmp/x", + mentionPrompt: "please review", + honeycombTraceUrlTemplate: undefined, + starter, + }); + expect(prompt).not.toContain("Discord investigation bootstrap"); + expect(prompt).not.toContain("hc_tpl:"); + expect(prompt).toContain(`rules: ${resolveAgentTurnRulesPath()}`); + expect(prompt).toContain("Can you check the open PR?"); + expect(prompt).toContain("please review"); + }); + + it("returns bare mention when there is no useful starter", () => { + const prompt = buildFirstTurnPrompt({ + projectShortName: "example-project", + workspaceRoot: "/tmp/x", + mentionPrompt: "hello", + honeycombTraceUrlTemplate: undefined, + starter: null, + }); + expect(prompt).toContain(`rules: ${resolveAgentTurnRulesPath()}`); + expect(prompt).toContain("## User request"); + expect(prompt).toContain("hello"); + expect(buildSentryBootstrapPrompt).toBeTypeOf("function"); + expect(formatDiscordMessage({ id: "x", content: "hi", author: { username: "a" } })).toContain( + "hi", + ); + }); +}); + +describe("resolveAgentTurnRulesPath", () => { + it("points at the package static policy document", () => { + const path = resolveAgentTurnRulesPath(); + expect(path.endsWith("docs/agent-turn-rules.md")).toBe(true); + expect(NodeFS.existsSync(path)).toBe(true); + const body = NodeFS.readFileSync(path, "utf8"); + expect(body).toContain("cab"); + expect(body).toContain("PR footer"); + expect(body).toContain("Style:"); + expect(body).toContain("client overlay"); + }); +}); + +describe("buildDiscordTurnPrompt", () => { + it("adds unified agent rules + Discord context and requester", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "Can you check your last reply?", + requester: { + id: "message-7", + author: { + id: "user-1", + username: "example-user", + displayName: "Example User", + }, + }, + }); + + expect(prompt).toContain("## Discord conversation context"); + expect(prompt).toContain(`rules: ${resolveAgentTurnRulesPath()}`); + expect(prompt).toContain("req: user-1@example-user Example User"); + expect(prompt).not.toContain("Always open a GitHub PR"); + expect(prompt).not.toContain("posted back into the same Discord thread"); + expect(prompt).not.toContain("```json"); + expect(prompt).toContain("Can you check your last reply?"); + }); + + it("includes referenced message content, embeds, and compact jump", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "what would this error mean?", + requester: { + id: "mention-9", + author: { id: "user-1", username: "example-user", displayName: "Example User" }, + }, + referencedMessage: { + id: "sentry-msg-1", + author: { username: "Sentry", bot: true }, + content: "", + embeds: [ + { + title: "CarrierErrorWrapped", + description: "DPDRemoteValidationError: Fehler bei der Sendung [Code COMMON_2]", + fields: [ + { name: "company", value: "empasa" }, + { name: "environment", value: "prod" }, + ], + footer: { text: "EXAMPLE-PROJECT-API-JW via Scanner API" }, + }, + ], + }, + referencedMessageUrl: "https://discord.com/channels/1/2/sentry-msg-1", + }); + + expect(prompt).toContain("## ref"); + expect(prompt).toContain("CarrierErrorWrapped"); + expect(prompt).toContain("company: empasa"); + expect(prompt).toContain("EXAMPLE-PROJECT-API-JW"); + expect(prompt).toContain("jump: 1/2/sentry-msg-1"); + expect(prompt).not.toContain("https://discord.com/channels/"); + expect(prompt).toContain("what would this error mean?"); + }); + + it("collapses newlines in requester username/display", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "hello", + requester: { + id: "message-8", + author: { + id: "9", + username: "name\n## Fake instruction", + displayName: 'Display "quoted"', + }, + }, + }); + + expect(prompt).toContain('req: 9@name ## Fake instruction Display "quoted"'); + expect(prompt).not.toMatch(/req: 9@name\n/u); + }); + + it("injects jira keys only (no browse URLs)", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "create a PR for this", + requester: { + id: "message-9", + author: { id: "user-1", username: "example-user", displayName: "Example User" }, + }, + jiraIssueKeys: ["PROJ-367", "PROJ-400"], + jiraBrowseBaseUrl: "https://example.atlassian.net", + }); + + expect(prompt).toContain("jira: PROJ-367 PROJ-400"); + expect(prompt).not.toContain("atlassian.net"); + expect(prompt).not.toContain("Linked work items"); + expect(prompt).toContain("create a PR for this"); + }); + + it("omits the Jira work-items block when no keys are known", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "hello", + jiraIssueKeys: [], + jiraBrowseBaseUrl: "https://example.atlassian.net", + }); + expect(prompt).not.toContain("jira:"); + }); + + it("injects compact cab for starter + requester", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "open a PR", + starter: { + id: "starter-1", + author: { id: "222", username: "davide", displayName: "Davide" }, + }, + requester: { + id: "mention-1", + author: { + id: "95218063095377920", + username: "patroza", + displayName: "Patrick Roza", + }, + }, + identityPeople: [ + { + name: "Davide", + discord: { id: "222", username: "davide" }, + github: { login: "davide", id: "99" }, + }, + { + name: "Patrick Roza", + discord: { id: "95218063095377920", username: "patroza" }, + github: { login: "patroza", id: "12345" }, + }, + ], + }); + + expect(prompt).toContain( + "cab: Davide <99+davide@users.noreply.github.com> | Patrick Roza <12345+patroza@users.noreply.github.com>", + ); + expect(prompt).not.toContain("Co-authored-by:"); + expect(prompt).not.toContain("who:"); + expect(prompt).not.toContain("do not invent emails"); + expect(prompt).not.toContain("Always open a PR"); + expect(prompt).not.toContain("jiraAccountId"); + }); + + it("omits identity block when the map is empty/unset", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "hello", + requester: { + id: "m1", + author: { id: "1", username: "x" }, + }, + identityPeople: [], + }); + expect(prompt).not.toContain("who:"); + expect(prompt).not.toContain("cab:"); + expect(prompt).not.toContain("Co-authored-by"); + }); + + it("injects compact pr fields (ids, not full URLs)", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "make a pr", + requester: { + id: "m1", + author: { id: "593167616273809448", username: "joshuadima", displayName: "joshuadima" }, + }, + starter: { + id: "1531376362399465595", + author: { id: "593167616273809448", username: "joshuadima", displayName: "joshuadima" }, + }, + guildId: "1083767712431480922", + discordThreadId: "1531376362399465595", + discordThreadTitle: "Open Random PR Test", + t3ThreadId: "t3-thread-1", + webUiBaseUrl: "https://t3vm.tail86038f.ts.net", + }); + expect(prompt).toContain("pr:"); + expect(prompt).toContain("uid=593167616273809448"); + expect(prompt).toContain("g=1083767712431480922"); + expect(prompt).toContain("c=1531376362399465595"); + expect(prompt).toContain("m=1531376362399465595"); + expect(prompt).toContain("title=Open Random PR Test"); + expect(prompt).not.toContain("https://discord.com/"); + expect(prompt).toContain( + "t3: full=https://t3vm.tail86038f.ts.net/?thread=t3-thread-1 short=https://t3vm/?thread=t3-thread-1", + ); + }); + + it("falls back to bare keys when browse base is unset", () => { + const block = formatLinkedJiraWorkItemsBlock({ + jiraIssueKeys: ["proj-367"], + jiraBrowseBaseUrl: undefined, + }); + expect(block).toBe("jira: PROJ-367"); + expect(block).not.toContain("atlassian.net"); + }); +}); + +describe("referenced message + Sentry bootstrap", () => { + it("treats a Sentry referenced message as investigation context even without starter", () => { + const referenced = { + id: "sentry-ref", + author: { username: "Sentry", bot: true }, + embeds: [ + { + title: "CarrierErrorWrapped", + description: "DPDRemoteValidationError", + url: "https://example.sentry.io/issues/7506163172/", + fields: [{ name: "environment", value: "prod" }], + footer: { text: "EXAMPLE-PROJECT-API-JW via Scanner API" }, + }, + ], + }; + + expect( + looksLikeSentryContext({ + starter: null, + mentionPrompt: "what would this error mean?", + referencedMessage: referenced, + }), + ).toBe(true); + + const prompt = buildFirstTurnPrompt({ + projectShortName: "example-project", + workspaceRoot: "/tmp/scanner", + mentionPrompt: "what would this error mean?", + mentionMessage: { + id: "mention-10", + author: { id: "42", username: "example-user", displayName: "Example User" }, + }, + referencedMessage: referenced, + referencedMessageUrl: "https://discord.com/channels/g/c/sentry-ref", + honeycombTraceUrlTemplate: + "https://ui.eu1.honeycomb.io/example-project/environments/{environment}/trace?trace_id={traceId}", + starter: null, + }); + + expect(prompt).toContain("Discord investigation bootstrap"); + expect(prompt).toContain("## ref"); + expect(prompt).toContain("CarrierErrorWrapped"); + expect(prompt).toContain("EXAMPLE-PROJECT-API-JW"); + expect(prompt).toContain("jump: g/c/sentry-ref"); + expect(prompt).not.toContain("https://discord.com/channels/"); + }); + + it("formatReferencedMessageBlock labels the reply target", () => { + const block = formatReferencedMessageBlock({ + message: { + id: "m1", + author: { username: "alice" }, + content: "please look at this", + }, + url: "https://discord.com/channels/1/2/m1", + }); + expect(block).toContain("## ref"); + expect(block).toContain("please look at this"); + expect(block).toContain("jump: 1/2/m1"); + }); +}); diff --git a/apps/discord-bot/src/presentation/threadContext.ts b/apps/discord-bot/src/presentation/threadContext.ts new file mode 100644 index 00000000000..0a79136c2f6 --- /dev/null +++ b/apps/discord-bot/src/presentation/threadContext.ts @@ -0,0 +1,563 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Build initial T3 prompts when the bot is first pulled into a Discord thread. + * Combines the thread starter (e.g. Sentry alert embed) with the user @mention. + * + * Global product rules are injected by the T3 server on session start (and again + * after compaction). This builder only emits **dynamic** Discord fields + a + * pointer to the Discord-only overlay (`docs/agent-turn-rules.md`). + */ + +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { + formatIdentityAttributionBlock, + resolveParticipantIdentity, + type PersonIdentity, + type ResolvedParticipantIdentity, +} from "../identityMap.ts"; +import { + buildT3WebThreadUrl, + starterDisplayName, + starterUserId, + toT3PublicShortThreadUrl, +} from "./discordPrAttribution.ts"; +import { mergeJiraIssueKeys } from "./jiraLinks.ts"; + +function discordBotPackageRoot(): string { + // presentation/ → src/ → package root + return NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../.."); +} + +/** Absolute path to the Discord-only overlay policy document. */ +export function resolveAgentTurnRulesPath(): string { + return NodePath.resolve(discordBotPackageRoot(), "docs/agent-turn-rules.md"); +} + +export interface DiscordEmbedLike { + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly url?: string | undefined; + readonly timestamp?: string | undefined; + readonly author?: { readonly name?: string | undefined } | undefined; + readonly footer?: { readonly text?: string | undefined } | undefined; + readonly fields?: + | ReadonlyArray<{ + readonly name: string; + readonly value: string; + }> + | undefined; +} + +export interface DiscordMessageLike { + readonly id: string; + readonly content?: string | null | undefined; + readonly author?: + | { + readonly id?: string | undefined; + readonly username?: string | undefined; + readonly displayName?: string | undefined; + readonly bot?: boolean | undefined; + } + | undefined; + readonly embeds?: ReadonlyArray | undefined; + readonly timestamp?: string | undefined; + /** Channel that holds the message (for jump links). */ + readonly channelId?: string | undefined; +} + +export interface ThreadBootstrapContext { + readonly starter: DiscordMessageLike | null; + readonly mentionMessage?: DiscordMessageLike | undefined; + /** + * Message the user replied to / referenced when addressing the bot. + * Prefer this over inventing context from screenshots or partial text. + */ + readonly referencedMessage?: DiscordMessageLike | null | undefined; + /** Jump link for the referenced message when known. */ + readonly referencedMessageUrl?: string | undefined; + readonly mentionPrompt: string; + readonly projectShortName: string; + readonly workspaceRoot: string; + /** Optional template: use {traceId}, {environment}, {dataset} */ + readonly honeycombTraceUrlTemplate: string | undefined; + /** + * Durable Jira issue keys for this Discord thread (first-seen order). + * Re-injected every turn so later PR/work turns still see earlier ticket links. + */ + readonly jiraIssueKeys?: ReadonlyArray | undefined; + /** Browse base for turning keys into links (e.g. https://org.atlassian.net). */ + readonly jiraBrowseBaseUrl?: string | undefined; + /** + * Operator identity map (Discord → GitHub/Jira). Used to inject Co-authored-by + * guidance for the thread starter and current requester. + */ + readonly identityPeople?: ReadonlyArray | undefined; + /** Guild snowflake — required to build a real Discord thread jump URL for PR footers. */ + readonly guildId?: string | null | undefined; + /** Discord thread (or channel) snowflake for the PR footer jump link. */ + readonly discordThreadId?: string | null | undefined; + /** Discord thread title (channel name) for the PR footer label. */ + readonly discordThreadTitle?: string | null | undefined; + /** T3 Code thread id for PR footer T3 link (when known). */ + readonly t3ThreadId?: string | null | undefined; + /** T3 web UI base (e.g. https://t3vm.tail….ts.net) for full private PR links. */ + readonly webUiBaseUrl?: string | null | undefined; +} + +/** Collapse whitespace so one-line turn fields cannot inject extra markdown headers. */ +function oneLine(value: string): string { + return value + .replace(/[\r\n\t]+/gu, " ") + .replace(/ {2,}/gu, " ") + .trim(); +} + +/** Compact requester line: `id@user name` (name omitted when same as user). */ +export function formatRequesterLine(message: DiscordMessageLike | undefined): string { + const id = oneLine(message?.author?.id?.trim() || "?") || "?"; + const user = oneLine(message?.author?.username?.trim() || "?") || "?"; + const name = oneLine((message?.author?.displayName ?? message?.author?.username ?? "").trim()); + if (name.length > 0 && name !== user) return `${id}@${user} ${name}`; + return `${id}@${user}`; +} + +/** + * Compress a discord.com/channels/... jump to `g/c` or `g/c/m` (no scheme/host). + * Returns null when the URL is missing or not a channel jump. + */ +export function compactDiscordJumpRef(url: string | undefined): string | null { + if (url === undefined) return null; + const trimmed = url.trim(); + if (trimmed.length === 0) return null; + const match = trimmed.match( + /^https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/([^/\s]+)\/([^/\s]+)(?:\/([^/\s]+))?$/u, + ); + if (match === null) return null; + const guild = match[1]!; + const channel = match[2]!; + const message = match[3]; + return message !== undefined && message.length > 0 + ? `${guild}/${channel}/${message}` + : `${guild}/${channel}`; +} + +/** + * Format a referenced (reply-to) Discord message for agent context. + * Jump is `g/c/m` ids only (expand with rules doc URL forms when needed). + */ +export function formatReferencedMessageBlock(input: { + readonly message: DiscordMessageLike; + readonly url?: string | undefined; +}): string { + const parts = ["## ref", formatDiscordMessage(input.message)]; + const jump = compactDiscordJumpRef(input.url); + if (jump !== null) parts.push(`jump: ${jump}`); + return parts.join("\n"); +} + +/** + * Durable per-thread Jira keys for agent turns (keys only — no browse URLs). + * Omitted when no keys are known so ordinary prompts stay compact. + */ +export function formatLinkedJiraWorkItemsBlock(input: { + readonly jiraIssueKeys?: ReadonlyArray | undefined; + /** @deprecated Ignored; keys only in prompts (browse base unused). */ + readonly jiraBrowseBaseUrl?: string | undefined; +}): string | null { + const ordered = mergeJiraIssueKeys([], input.jiraIssueKeys); + if (ordered.length === 0) return null; + return `jira: ${ordered.join(" ")}`; +} + +/** + * Resolve starter + requester against the identity map for prompt injection. + * Order: thread starter first, then requester (when distinct Discord ids). + */ +export function resolveTurnIdentityParticipants(input: { + readonly people?: ReadonlyArray | undefined; + readonly starter?: DiscordMessageLike | null | undefined; + readonly requester?: DiscordMessageLike | undefined; +}): ReadonlyArray { + const people = input.people ?? []; + const out: ResolvedParticipantIdentity[] = []; + + const starterAuthor = input.starter?.author; + if (starterAuthor !== undefined && starterAuthor.bot !== true) { + out.push( + resolveParticipantIdentity({ + role: "thread_starter", + discordId: starterAuthor.id, + discordUsername: starterAuthor.username, + discordDisplayName: starterAuthor.displayName ?? starterAuthor.username, + people, + }), + ); + } + + const requesterAuthor = input.requester?.author; + if (requesterAuthor !== undefined) { + // Always list requester role even when same person as starter so the agent + // sees both roles; trailer dedupe happens in formatIdentityAttributionBlock. + out.push( + resolveParticipantIdentity({ + role: "requester", + discordId: requesterAuthor.id, + discordUsername: requesterAuthor.username, + discordDisplayName: requesterAuthor.displayName ?? requesterAuthor.username, + people, + }), + ); + } + + return out; +} + +/** + * Compact PR footer fields for agents (ids only — expand via rules doc). + * Returns null when starter user id is missing. + */ +export function formatDiscordPrFooterPromptBlock(input: { + readonly starter?: DiscordMessageLike | null | undefined; + readonly requester?: DiscordMessageLike | undefined; + readonly guildId?: string | null | undefined; + readonly discordThreadId?: string | null | undefined; + readonly discordThreadTitle?: string | null | undefined; +}): string | null { + const attributionPerson = input.starter ?? input.requester; + const userId = starterUserId(attributionPerson ?? null); + if (userId === null) return null; + + const name = starterDisplayName(attributionPerson ?? null); + const guildId = input.guildId?.trim() ?? ""; + const threadId = input.discordThreadId?.trim() ?? ""; + const messageId = + attributionPerson?.id?.trim() || + input.requester?.id?.trim() || + (threadId.length > 0 ? threadId : ""); + const title = input.discordThreadTitle?.trim() || "Discord"; + + const parts = [`name=${name}`, `uid=${userId}`]; + if (guildId.length > 0) parts.push(`g=${guildId}`); + if (threadId.length > 0) parts.push(`c=${threadId}`); + if (messageId.length > 0) parts.push(`m=${messageId}`); + parts.push(`title=${title}`); + return `pr: ${parts.join(" ")}`; +} + +/** + * Compact T3 thread link fields for PR footers. + * Agents pick full (private GH repo) vs short host `t3vm` (public). + */ +export function formatT3PrLinkPromptBlock(input: { + readonly t3ThreadId?: string | null | undefined; + readonly webUiBaseUrl?: string | null | undefined; +}): string | null { + const id = input.t3ThreadId?.trim(); + if (id === undefined || id.length === 0) return null; + + const full = buildT3WebThreadUrl(input.webUiBaseUrl, id); + const short = full !== null ? toT3PublicShortThreadUrl(full) : `https://t3vm/?thread=${id}`; + + if (full !== null) return `t3: full=${full} short=${short}`; + return `t3: short=${short}`; +} + +export function buildDiscordTurnPrompt(input: { + readonly mentionPrompt: string; + readonly requester?: DiscordMessageLike | undefined; + readonly starter?: DiscordMessageLike | null | undefined; + readonly referencedMessage?: DiscordMessageLike | null | undefined; + readonly referencedMessageUrl?: string | undefined; + readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly jiraBrowseBaseUrl?: string | undefined; + readonly identityPeople?: ReadonlyArray | undefined; + readonly guildId?: string | null | undefined; + readonly discordThreadId?: string | null | undefined; + readonly discordThreadTitle?: string | null | undefined; + readonly t3ThreadId?: string | null | undefined; + readonly webUiBaseUrl?: string | null | undefined; + /** Override Discord overlay rules path (tests). Defaults to package docs path. */ + readonly agentTurnRulesPath?: string | undefined; +}): string { + const overlayRulesPath = input.agentTurnRulesPath ?? resolveAgentTurnRulesPath(); + + const referencedBlock = + input.referencedMessage !== null && input.referencedMessage !== undefined + ? `\n\n${formatReferencedMessageBlock({ + message: input.referencedMessage, + url: input.referencedMessageUrl, + })}` + : ""; + + const jiraBlock = formatLinkedJiraWorkItemsBlock({ + jiraIssueKeys: input.jiraIssueKeys, + jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, + }); + const jiraSection = jiraBlock !== null ? `\n${jiraBlock}` : ""; + + const identityBlock = formatIdentityAttributionBlock({ + participants: resolveTurnIdentityParticipants({ + people: input.identityPeople, + starter: input.starter, + requester: input.requester, + }), + }); + // Only inject when the operator map is configured (non-empty). Empty map keeps prompts compact. + const identitySection = + input.identityPeople !== undefined && input.identityPeople.length > 0 && identityBlock !== null + ? `\n${identityBlock}` + : ""; + + const prFooterBlock = formatDiscordPrFooterPromptBlock({ + starter: input.starter, + requester: input.requester, + guildId: input.guildId, + discordThreadId: input.discordThreadId, + discordThreadTitle: input.discordThreadTitle, + }); + const prFooterSection = prFooterBlock !== null ? `\n${prFooterBlock}` : ""; + + const t3Block = formatT3PrLinkPromptBlock({ + t3ThreadId: input.t3ThreadId, + webUiBaseUrl: input.webUiBaseUrl, + }); + const t3Section = t3Block !== null ? `\n${t3Block}` : ""; + + // Header kept for ResponseBridge echo-suppression (isDiscordOriginatedUserPrompt). + // Global product rules: server session inject + post-compaction re-inject. + // Discord overlay path is surface-specific static policy for this turn. + return `## Discord conversation context +rules: ${overlayRulesPath} +req: ${formatRequesterLine(input.requester)}${jiraSection}${identitySection}${prFooterSection}${t3Section} + +## User request +${input.mentionPrompt.trim()}${referencedBlock}`; +} + +const SENTRY_ISSUE_ID = /\b([A-Z][A-Z0-9]+(?:-[A-Z0-9]+)+)\b/g; +const SENTRY_URL = /https?:\/\/[^\s)]*sentry\.io\/[^\s)]+/gi; +const TRACE_ID = /\b(?:trace[_-]?id|trace)\s*[:=]\s*([a-f0-9]{16,32})\b/i; +const HEX_TRACE = /\b([a-f0-9]{32})\b/i; + +export function formatEmbed(embed: DiscordEmbedLike): string { + const lines: string[] = []; + if (embed.author?.name) lines.push(`Author: ${embed.author.name}`); + if (embed.title) lines.push(`Title: ${embed.title}`); + if (embed.description) lines.push(embed.description.trim()); + if (embed.url) lines.push(`URL: ${embed.url}`); + if (embed.fields) { + for (const field of embed.fields) { + lines.push(`${field.name}: ${field.value}`); + } + } + if (embed.footer?.text) lines.push(`Footer: ${embed.footer.text}`); + if (embed.timestamp) lines.push(`Timestamp: ${embed.timestamp}`); + return lines.join("\n"); +} + +export function formatDiscordMessage(message: DiscordMessageLike): string { + const who = + message.author?.username !== undefined + ? `${message.author.username}${message.author.bot === true ? " [bot]" : ""}` + : "?"; + const parts = [`from=${who} id=${message.id}`]; + const content = (message.content ?? "").trim(); + if (content.length > 0) parts.push(content); + if (message.embeds && message.embeds.length > 0) { + message.embeds.forEach((embed, index) => { + parts.push(`embed${index + 1}:`, formatEmbed(embed)); + }); + } + return parts.join("\n"); +} + +export function extractSentryHints(text: string): { + readonly issueIds: ReadonlyArray; + readonly sentryUrls: ReadonlyArray; + readonly possibleTraceIds: ReadonlyArray; +} { + const issueIds = new Set(); + for (const match of text.matchAll(SENTRY_ISSUE_ID)) { + const id = match[1]; + if (id === undefined || !id.includes("-") || id.length < 8) continue; + // Sentry short ids are typically PROJECT-CODE (letters + hyphens, sometimes digits). + // Skip pure hex-ish tokens (trace/event ids). + if (/^[a-f0-9-]+$/i.test(id) && !/[g-zG-Z]/.test(id)) continue; + if (!/[A-Z]/.test(id)) continue; + issueIds.add(id); + } + const sentryUrls = [...text.matchAll(SENTRY_URL)].map((m) => m[0]!); + const possibleTraceIds: string[] = []; + const labeled = TRACE_ID.exec(text); + if (labeled?.[1]) possibleTraceIds.push(labeled[1]); + const hex = HEX_TRACE.exec(text); + if (hex?.[1] && !possibleTraceIds.includes(hex[1])) possibleTraceIds.push(hex[1]); + return { + issueIds: [...issueIds], + sentryUrls: [...new Set(sentryUrls)], + possibleTraceIds, + }; +} + +function collectMessageText(message: DiscordMessageLike | null | undefined): string { + if (message === null || message === undefined) return ""; + const parts: string[] = [message.content ?? "", message.author?.username ?? ""]; + for (const embed of message.embeds ?? []) { + parts.push(formatEmbed(embed)); + if (embed.url) parts.push(embed.url); + } + return parts.join("\n"); +} + +/** True when starter/mention/referenced message clearly references Sentry (avoid burning tokens otherwise). */ +export function looksLikeSentryContext(input: { + readonly starter: DiscordMessageLike | null; + readonly mentionPrompt: string; + readonly referencedMessage?: DiscordMessageLike | null | undefined; +}): boolean { + const parts: string[] = [ + input.mentionPrompt, + collectMessageText(input.starter), + collectMessageText(input.referencedMessage), + ]; + const text = parts.join("\n"); + if (/sentry/i.test(text)) return true; + if (/sentry\.io/i.test(text)) return true; + const hints = extractSentryHints(text); + return hints.sentryUrls.length > 0; +} + +/** + * Choose first-turn prompt: + * - Sentry-like context → full investigation bootstrap (Sentry + Honeycomb instructions) + * - Non-Sentry with a distinct starter and/or referenced message → short context + user request + * - Otherwise → user request only (still includes referenced message when present) + */ +export function buildFirstTurnPrompt(input: ThreadBootstrapContext): string { + if (looksLikeSentryContext(input)) { + return buildSentryBootstrapPrompt(input); + } + + const turnPrompt = buildDiscordTurnPrompt({ + mentionPrompt: input.mentionPrompt, + requester: input.mentionMessage, + starter: input.starter, + referencedMessage: input.referencedMessage, + referencedMessageUrl: input.referencedMessageUrl, + jiraIssueKeys: input.jiraIssueKeys, + jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, + identityPeople: input.identityPeople, + guildId: input.guildId, + discordThreadId: input.discordThreadId, + discordThreadTitle: input.discordThreadTitle, + t3ThreadId: input.t3ThreadId, + webUiBaseUrl: input.webUiBaseUrl, + }); + + if (input.starter !== null) { + const starterText = formatDiscordMessage(input.starter).trim(); + const mention = input.mentionPrompt.trim(); + // Avoid doubling the same text when the mention is the starter. + if (starterText.length > 0 && !starterText.includes(mention)) { + // Skip starter block when it is the same message the user referenced. + const starterIsReferenced = + input.referencedMessage !== null && + input.referencedMessage !== undefined && + input.referencedMessage.id === input.starter.id; + if (!starterIsReferenced) { + return `${turnPrompt} + +## Discord thread starter +${starterText} +`; + } + } + } + + return turnPrompt; +} + +export function buildSentryBootstrapPrompt(input: ThreadBootstrapContext): string { + const starterText = + input.starter === null ? "(no starter message available)" : formatDiscordMessage(input.starter); + const referencedText = + input.referencedMessage === null || input.referencedMessage === undefined + ? null + : formatDiscordMessage(input.referencedMessage); + const referencedIsDistinctStarter = + referencedText !== null && + input.referencedMessage !== null && + input.referencedMessage !== undefined && + (input.starter === null || input.referencedMessage.id !== input.starter.id); + + const combinedForHints = [ + starterText, + referencedText ?? "", + input.mentionPrompt, + input.starter?.embeds?.map(formatEmbed).join("\n") ?? "", + input.referencedMessage?.embeds?.map(formatEmbed).join("\n") ?? "", + ].join("\n"); + const hints = extractSentryHints(combinedForHints); + + const honeycombTpl = + input.honeycombTraceUrlTemplate !== undefined && input.honeycombTraceUrlTemplate.trim() !== "" + ? input.honeycombTraceUrlTemplate.trim() + : null; + + const hintBits: string[] = []; + if (hints.issueIds.length > 0) hintBits.push(`issues=${hints.issueIds.join(",")}`); + if (hints.sentryUrls.length > 0) { + // Prefer issue path/id over full URL when possible. + const compact = hints.sentryUrls.map((u) => { + const m = u.match(/sentry\.io(\/issues\/\d+)/iu); + return m?.[1] ?? u; + }); + hintBits.push(`sentry=${compact.join(",")}`); + } + if (hints.possibleTraceIds.length > 0) { + hintBits.push(`traces=${hints.possibleTraceIds.join(",")}`); + } + + const primary = referencedIsDistinctStarter ? "primary=ref" : "primary=starter"; + + const jump = compactDiscordJumpRef(input.referencedMessageUrl); + const referencedSection = + referencedIsDistinctStarter && referencedText !== null + ? ` +## ref +${referencedText}${jump !== null ? `\njump: ${jump}` : ""} +` + : ""; + + return `## Discord investigation bootstrap + +${buildDiscordTurnPrompt({ + mentionPrompt: input.mentionPrompt, + requester: input.mentionMessage, + starter: input.starter, + // Referenced / starter bodies are rendered in dedicated bootstrap sections below. + jiraIssueKeys: input.jiraIssueKeys, + jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, + identityPeople: input.identityPeople, + guildId: input.guildId, + discordThreadId: input.discordThreadId, + discordThreadTitle: input.discordThreadTitle, + t3ThreadId: input.t3ThreadId, + webUiBaseUrl: input.webUiBaseUrl, +})} + +project: ${input.projectShortName} root: ${input.workspaceRoot} +${primary} +${referencedSection} +## starter +${starterText} + +hints: ${hintBits.length > 0 ? hintBits.join(" ") : "none"} +hc_tpl: ${honeycombTpl ?? "(none — use team UI)"} +`; +} + +/** @deprecated Use buildFirstTurnPrompt / buildSentryBootstrapPrompt */ +export const buildThreadBootstrapPrompt = buildSentryBootstrapPrompt; diff --git a/apps/discord-bot/src/presentation/threadInfoPin.test.ts b/apps/discord-bot/src/presentation/threadInfoPin.test.ts new file mode 100644 index 00000000000..46d752ed9a7 --- /dev/null +++ b/apps/discord-bot/src/presentation/threadInfoPin.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyModelHistoryUpdate, + formatModelSinceLabel, + formatThreadInfoModelLine, + isThreadInfoPinContent, + renderThreadInfoPin, + THREAD_INFO_PIN_MARKER, +} from "./threadInfoPin.ts"; + +describe("renderThreadInfoPin", () => { + it("renders model, worktree, open link, ordered jira keys, and PR links", () => { + const rendered = renderThreadInfoPin({ + modelLine: "grok/grok-4.5", + worktreeLine: "Worktree off `main`", + webLink: "http://198.18.83.2:3773/?thread=abc", + jiraIssueKeys: ["PROJ-367", "PROJ-400"], + jiraBrowseBaseUrl: "https://example.atlassian.net", + channelGithubRepoSlug: "example-org/scanner", + prUrls: [ + "https://github.com/example-org/scanner/pull/1950", + "https://github.com/example-org/configurator/pull/123", + ], + }); + + expect(rendered).toContain(`**${THREAD_INFO_PIN_MARKER}**`); + expect(rendered).toContain("Model: `grok/grok-4.5`"); + expect(rendered).toContain("Worktree off `main`"); + expect(rendered).toContain("Open in Omegent: http://198.18.83.2:3773/?thread=abc"); + expect(rendered).toContain("**Jira**"); + expect(rendered).toContain("[PROJ-367](https://example.atlassian.net/browse/PROJ-367)"); + expect(rendered).toContain("[PROJ-400](https://example.atlassian.net/browse/PROJ-400)"); + expect(rendered.indexOf("PROJ-367")).toBeLessThan(rendered.indexOf("PROJ-400")); + expect(rendered).toContain("**PRs**"); + expect(rendered).toContain("[PR #1950](https://github.com/example-org/scanner/pull/1950)"); + expect(rendered).toContain( + "[example-org/configurator PR #123](https://github.com/example-org/configurator/pull/123)", + ); + expect(rendered.indexOf("**Jira**")).toBeLessThan(rendered.indexOf("**PRs**")); + }); + + it("omits jira and PR sections when empty", () => { + const rendered = renderThreadInfoPin({ + modelLine: "codex/gpt-5.4", + worktreeLine: "Mode: local (no worktree)", + webLink: null, + jiraIssueKeys: [], + prUrls: [], + }); + expect(rendered).not.toContain("**Jira**"); + expect(rendered).not.toContain("**PRs**"); + expect(rendered).toContain("Mode: local (no worktree)"); + }); + + it("renders model change note when provided as a full Model: line", () => { + const rendered = renderThreadInfoPin({ + modelLine: "Model: `grok/grok-4.5` (since 2026-07-20 at 10:05, started with `codex/gpt-5.4`)", + worktreeLine: "Worktree off `main`", + webLink: null, + }); + expect(rendered).toContain("started with `codex/gpt-5.4`"); + }); +}); + +describe("model history", () => { + it("records initial model without a since stamp", () => { + const history = applyModelHistoryUpdate(null, "codex/gpt-5.4", "2026-07-20T08:00:00.000Z"); + expect(history).toEqual({ + initialModelLine: "codex/gpt-5.4", + currentModelLine: "codex/gpt-5.4", + modelSinceAt: null, + }); + expect(formatThreadInfoModelLine(history)).toBe("Model: `codex/gpt-5.4`"); + }); + + it("stamps since when the model changes and keeps the original", () => { + const initial = applyModelHistoryUpdate(null, "codex/gpt-5.4", "2026-07-20T08:00:00.000Z"); + const changed = applyModelHistoryUpdate(initial, "grok/grok-4.5", "2026-07-20T08:05:00.000Z"); + expect(changed).toEqual({ + initialModelLine: "codex/gpt-5.4", + currentModelLine: "grok/grok-4.5", + modelSinceAt: "2026-07-20T08:05:00.000Z", + }); + // 08:05 UTC → 10:05 Europe/Berlin (CEST in July) + expect(formatThreadInfoModelLine(changed)).toBe( + "Model: `grok/grok-4.5` (since 2026-07-20 at 10:05, started with `codex/gpt-5.4`)", + ); + }); + + it("does not re-stamp when the same model is observed again", () => { + const changed = applyModelHistoryUpdate( + { + initialModelLine: "codex/gpt-5.4", + currentModelLine: "grok/grok-4.5", + modelSinceAt: "2026-07-20T08:05:00.000Z", + }, + "grok/grok-4.5", + "2026-07-20T09:00:00.000Z", + ); + expect(changed.modelSinceAt).toBe("2026-07-20T08:05:00.000Z"); + }); + + it("formats Germany local since labels without a timezone suffix", () => { + // Summer (CEST, UTC+2) + expect(formatModelSinceLabel("2026-07-20T08:05:30.000Z")).toBe("2026-07-20 at 10:05"); + // Winter (CET, UTC+1) + expect(formatModelSinceLabel("2026-01-15T08:05:30.000Z")).toBe("2026-01-15 at 09:05"); + }); +}); + +describe("isThreadInfoPinContent", () => { + it("detects marked and legacy bot messages", () => { + expect(isThreadInfoPinContent(`**${THREAD_INFO_PIN_MARKER}**\nModel: \`x\``)).toBe(true); + // Legacy pre-rebrand marker is still recognized so old pins are upgraded in place. + expect(isThreadInfoPinContent("**T3 Thread Info**\nModel: `x`")).toBe(true); + // Legacy pre-marker message (no marker line, "Open in T3:" label). + expect( + isThreadInfoPinContent("Model: `grok/grok-4.5`\nWorktree off `main`\nOpen in T3: http://x"), + ).toBe(true); + // New pre-marker-equivalent (no marker line, "Open in Omegent:" label). + expect( + isThreadInfoPinContent( + "Model: `grok/grok-4.5`\nWorktree off `main`\nOpen in Omegent: http://x", + ), + ).toBe(true); + expect(isThreadInfoPinContent("hello world")).toBe(false); + }); +}); diff --git a/apps/discord-bot/src/presentation/threadInfoPin.ts b/apps/discord-bot/src/presentation/threadInfoPin.ts new file mode 100644 index 00000000000..947473788a2 --- /dev/null +++ b/apps/discord-bot/src/presentation/threadInfoPin.ts @@ -0,0 +1,195 @@ +// @effect-diagnostics globalDate:off +import { formatJiraLinksForDiscord } from "./jiraLinks.ts"; +import { formatPullRequestLinksForDiscord } from "./prLinks.ts"; + +/** Stable marker so we can find/update the pinned thread-info message after restarts. */ +export const THREAD_INFO_PIN_MARKER = "Omegent Info"; + +/** + * Markers the bot used before the Omegent rebrand. Detection matches these too so an + * existing pre-rebrand pin is found and rewritten in place instead of orphaned. + */ +export const LEGACY_THREAD_INFO_PIN_MARKERS = ["T3 Thread Info"] as const; + +export type ThreadInfoPinRenderInput = { + readonly modelLine: string | null; + readonly worktreeLine: string | null; + readonly webLink: string | null; + readonly extraLines?: ReadonlyArray; + readonly jiraIssueKeys?: ReadonlyArray; + readonly jiraBrowseBaseUrl?: string | undefined; + readonly prUrls?: ReadonlyArray; + /** Channel / project GitHub repo (`owner/repo`) for PR label disambiguation. */ + readonly channelGithubRepoSlug?: string | null | undefined; +}; + +/** Durable model history for the pinned thread-info message. */ +export type ThreadModelHistory = { + readonly initialModelLine: string | null; + readonly currentModelLine: string | null; + /** ISO timestamp when `currentModelLine` became active (if different from initial). */ + readonly modelSinceAt: string | null; +}; + +export function formatModelSelectionLine(input: { + readonly instanceId: string; + readonly model: string; +}): string { + return `${input.instanceId}/${input.model}`; +} + +/** Germany local time for pin text: `2026-07-20 at 10:05` (no timezone label). */ +const GERMANY_TIME_ZONE = "Europe/Berlin"; + +export function formatModelSinceLabel(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + + const parts = new Intl.DateTimeFormat("en-GB", { + timeZone: GERMANY_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + + const get = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((part) => part.type === type)?.value ?? ""; + + const yyyy = get("year"); + const mm = get("month"); + const dd = get("day"); + const hh = get("hour"); + const min = get("minute"); + return `${yyyy}-${mm}-${dd} at ${hh}:${min}`; +} + +/** + * Merge an observed model into durable history. + * - First observation sets initial + current. + * - Later changes update current and stamp modelSinceAt. + */ +export function applyModelHistoryUpdate( + existing: ThreadModelHistory | null | undefined, + nextModelLine: string | null | undefined, + nowIso: string = new Date().toISOString(), +): ThreadModelHistory { + const next = + nextModelLine === null || nextModelLine === undefined || nextModelLine.trim() === "" + ? null + : nextModelLine.trim(); + + if (next === null) { + return { + initialModelLine: existing?.initialModelLine ?? null, + currentModelLine: existing?.currentModelLine ?? null, + modelSinceAt: existing?.modelSinceAt ?? null, + }; + } + + const initial = existing?.initialModelLine ?? next; + const previousCurrent = existing?.currentModelLine ?? null; + + if (previousCurrent === null) { + return { + initialModelLine: initial, + currentModelLine: next, + modelSinceAt: next === initial ? null : (existing?.modelSinceAt ?? nowIso), + }; + } + + if (previousCurrent === next) { + return { + initialModelLine: initial, + currentModelLine: next, + modelSinceAt: next === initial ? null : (existing?.modelSinceAt ?? null), + }; + } + + // Model changed relative to last known current. + return { + initialModelLine: initial, + currentModelLine: next, + modelSinceAt: next === initial ? null : nowIso, + }; +} + +/** + * `Model: \`current\`` or + * `Model: \`current\` (since YYYY-MM-DD at HH:MM, started with \`initial\`)` + * when the current model differs from the original (time is Europe/Berlin, no zone label). + */ +export function formatThreadInfoModelLine(history: ThreadModelHistory): string | null { + const current = history.currentModelLine?.trim() || null; + if (current === null) return null; + + const initial = history.initialModelLine?.trim() || null; + if ( + initial === null || + initial === current || + history.modelSinceAt === null || + history.modelSinceAt === undefined + ) { + return `Model: \`${current}\``; + } + + return `Model: \`${current}\` (since ${formatModelSinceLabel(history.modelSinceAt)}, started with \`${initial}\`)`; +} + +/** + * Render the per-thread status message (Model / worktree / Open in Omegent / Jira / PRs). + * Always includes the marker as the first line for pin discovery. + */ +export function renderThreadInfoPin(input: ThreadInfoPinRenderInput): string { + const lines: string[] = [`**${THREAD_INFO_PIN_MARKER}**`]; + + if (input.modelLine !== null && input.modelLine.trim() !== "") { + lines.push( + input.modelLine.startsWith("Model:") ? input.modelLine : `Model: \`${input.modelLine}\``, + ); + } + + for (const extra of input.extraLines ?? []) { + if (extra !== null && extra !== undefined && extra.trim() !== "") { + lines.push(extra); + } + } + + if (input.worktreeLine !== null && input.worktreeLine.trim() !== "") { + lines.push(input.worktreeLine); + } + + if (input.webLink !== null && input.webLink.trim() !== "") { + // Accept either prefix (callers pass the pre-labelled form; legacy used "Open in T3:") + // so re-rendering an old value never double-prefixes; emit the Omegent label otherwise. + const alreadyLabelled = + input.webLink.startsWith("Open in Omegent:") || input.webLink.startsWith("Open in T3:"); + lines.push(alreadyLabelled ? input.webLink : `Open in Omegent: ${input.webLink}`); + } + + const jiraSection = formatJiraLinksForDiscord(input.jiraIssueKeys ?? [], input.jiraBrowseBaseUrl); + if (jiraSection !== null) { + lines.push(""); + lines.push(jiraSection); + } + + const prSection = formatPullRequestLinksForDiscord(input.prUrls ?? [], { + channelRepoSlug: input.channelGithubRepoSlug ?? null, + }); + if (prSection !== null) { + lines.push(""); + lines.push(prSection); + } + + return lines.join("\n"); +} + +export function isThreadInfoPinContent(content: string | null | undefined): boolean { + if (content === null || content === undefined || content.length === 0) return false; + if (content.includes(THREAD_INFO_PIN_MARKER)) return true; + if (LEGACY_THREAD_INFO_PIN_MARKERS.some((marker) => content.includes(marker))) return true; + // Legacy pre-marker messages from the bot (Model: `…` first line + Open-in link). + return /^Model:\s*`[^`]+`/mu.test(content.trim()) && /Open in (?:T3|Omegent):/u.test(content); +} diff --git a/apps/discord-bot/src/presentation/toolCalls.test.ts b/apps/discord-bot/src/presentation/toolCalls.test.ts new file mode 100644 index 00000000000..b736be998ec --- /dev/null +++ b/apps/discord-bot/src/presentation/toolCalls.test.ts @@ -0,0 +1,280 @@ +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { countTurnToolCalls } from "./toolCalls.ts"; + +function activity( + input: Omit, "id" | "kind"> & { + readonly id: string; + readonly kind: string; + }, +): OrchestrationThreadActivity { + return { + tone: "tool", + summary: "Tool call", + payload: {}, + turnId: null, + createdAt: "2026-07-10T00:00:00.000Z", + ...input, + } as OrchestrationThreadActivity; +} + +describe("countTurnToolCalls", () => { + it("returns 0 when there are no tool activities", () => { + expect( + countTurnToolCalls([activity({ id: "m1", kind: "message.created", tone: "info" })], null), + ).toBe(0); + }); + + it("collapses lifecycle updates for the same toolCallId even when not consecutive", () => { + expect( + countTurnToolCalls( + [ + activity({ + id: "a-start", + kind: "tool.updated", + sequence: 1, + createdAt: "2026-07-10T00:00:01.000Z", + payload: { itemType: "command_execution", data: { toolCallId: "call-a" } }, + }), + activity({ + id: "b", + kind: "tool.completed", + sequence: 2, + createdAt: "2026-07-10T00:00:02.000Z", + payload: { itemType: "command_execution", data: { toolCallId: "call-b" } }, + }), + activity({ + id: "a-done", + kind: "tool.completed", + sequence: 3, + createdAt: "2026-07-10T00:00:03.000Z", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { toolCallId: "call-a" }, + }, + }), + ], + null, + ), + ).toBe(2); + }); + + it("counts distinct tool calls", () => { + expect( + countTurnToolCalls( + [ + activity({ + id: "a", + kind: "tool.completed", + sequence: 1, + payload: { itemType: "dynamic_tool_call", data: { toolCallId: "t1" } }, + }), + activity({ + id: "b", + kind: "tool.completed", + sequence: 2, + payload: { itemType: "dynamic_tool_call", data: { toolCallId: "t2" } }, + }), + activity({ + id: "c", + kind: "tool.updated", + sequence: 3, + payload: { itemType: "mcp_tool_call", data: { toolCallId: "t3" } }, + }), + ], + null, + ), + ).toBe(3); + }); + + it("scopes strictly to the latest turn (ignores null-turn noise)", () => { + const turnA = TurnId.make("turn-a"); + const turnB = TurnId.make("turn-b"); + expect( + countTurnToolCalls( + [ + activity({ + id: "orphan", + kind: "tool.completed", + turnId: null, + sequence: 0, + payload: { data: { toolCallId: "orphan-1" } }, + }), + activity({ + id: "old", + kind: "tool.completed", + turnId: turnA, + sequence: 1, + payload: { data: { toolCallId: "old-1" } }, + }), + activity({ + id: "new-1", + kind: "tool.completed", + turnId: turnB, + sequence: 2, + payload: { data: { toolCallId: "new-1" } }, + }), + activity({ + id: "new-2", + kind: "tool.updated", + turnId: turnB, + sequence: 3, + payload: { data: { toolCallId: "new-2" } }, + }), + ], + turnB, + ), + ).toBe(2); + }); + + it("counts only tools after the last settled assistant (latest in-progress segment)", () => { + const turn = TurnId.make("turn-1"); + expect( + countTurnToolCalls( + [ + activity({ + id: "early-1", + kind: "tool.completed", + turnId: turn, + sequence: 10, + createdAt: "2026-07-10T00:00:10.000Z", + payload: { data: { toolCallId: "early-1" } }, + }), + activity({ + id: "early-2", + kind: "tool.completed", + turnId: turn, + sequence: 11, + createdAt: "2026-07-10T00:00:11.000Z", + payload: { data: { toolCallId: "early-2" } }, + }), + // Settled intermediate assistant lands at seq 20 + activity({ + id: "late-1", + kind: "tool.completed", + turnId: turn, + sequence: 30, + createdAt: "2026-07-10T00:00:30.000Z", + payload: { data: { toolCallId: "late-1" } }, + }), + activity({ + id: "late-2", + kind: "tool.updated", + turnId: turn, + sequence: 31, + createdAt: "2026-07-10T00:00:31.000Z", + payload: { data: { toolCallId: "late-2" } }, + }), + activity({ + id: "late-2-done", + kind: "tool.completed", + turnId: turn, + sequence: 32, + createdAt: "2026-07-10T00:00:32.000Z", + payload: { data: { toolCallId: "late-2" } }, + }), + ], + turn, + [ + { + role: "user", + turnId: turn, + createdAt: "2026-07-10T00:00:01.000Z", + sequence: 1, + }, + { + role: "assistant", + turnId: turn, + streaming: false, + createdAt: "2026-07-10T00:00:20.000Z", + sequence: 20, + }, + { + role: "assistant", + turnId: turn, + streaming: true, + createdAt: "2026-07-10T00:00:25.000Z", + sequence: 25, + }, + ], + ), + ).toBe(2); + }); + + it("counts the whole turn when no settled assistant exists yet", () => { + const turn = TurnId.make("turn-1"); + expect( + countTurnToolCalls( + [ + activity({ + id: "t1", + kind: "tool.completed", + turnId: turn, + sequence: 2, + payload: { data: { toolCallId: "t1" } }, + }), + activity({ + id: "t2", + kind: "tool.updated", + turnId: turn, + sequence: 3, + payload: { data: { toolCallId: "t2" } }, + }), + ], + turn, + [{ role: "user", turnId: turn, createdAt: "2026-07-10T00:00:01.000Z", sequence: 1 }], + ), + ).toBe(2); + }); + + it("ignores older segments after multiple settled assistants (never whole-turn accumulation)", () => { + // Regression: completed answers re-synced as Working showed inflated counts like + // "81 tool calls" by counting every tool before the latest work segment. + const turn = TurnId.make("turn-1"); + const manyEarly = Array.from({ length: 80 }, (_, index) => + activity({ + id: `early-${index}`, + kind: "tool.completed", + turnId: turn, + sequence: index + 1, + createdAt: `2026-07-10T00:00:${String(index + 1).padStart(2, "0")}.000Z`, + payload: { data: { toolCallId: `early-${index}` } }, + }), + ); + expect( + countTurnToolCalls( + [ + ...manyEarly, + activity({ + id: "late-only", + kind: "tool.updated", + turnId: turn, + sequence: 200, + createdAt: "2026-07-10T00:03:00.000Z", + payload: { data: { toolCallId: "late-only" } }, + }), + ], + turn, + [ + { + role: "assistant", + turnId: turn, + streaming: false, + createdAt: "2026-07-10T00:02:00.000Z", + sequence: 100, + }, + { + role: "assistant", + turnId: turn, + streaming: true, + createdAt: "2026-07-10T00:02:30.000Z", + sequence: 150, + }, + ], + ), + ).toBe(1); + }); +}); diff --git a/apps/discord-bot/src/presentation/toolCalls.ts b/apps/discord-bot/src/presentation/toolCalls.ts new file mode 100644 index 00000000000..30609c8dd7f --- /dev/null +++ b/apps/discord-bot/src/presentation/toolCalls.ts @@ -0,0 +1,118 @@ +import type { OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value.trim() : null; +} + +function toolCallIdFromPayload(payload: Record): string | null { + // Match web client: payload.data.toolCallId is the stable id. + return text(asRecord(payload.data)?.toolCallId) ?? text(payload.toolCallId); +} + +function normalizeToolLabel(value: string): string { + return value.replace(/\s+(?:complete|completed)\s*$/iu, "").trim(); +} + +function collapseKeyForToolActivity(activity: OrchestrationThreadActivity): string | null { + if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") return null; + // Plan-boundary markers are not real tools (client skips them in the work log). + const payload = asRecord(activity.payload) ?? {}; + if (typeof payload.detail === "string" && payload.detail.startsWith("ExitPlanMode:")) { + return null; + } + if (activity.tone !== "tool" && activity.tone !== "error") return null; + + const id = toolCallIdFromPayload(payload); + if (id !== null) return `id:${id}`; + + const itemType = text(payload.itemType) ?? ""; + const title = normalizeToolLabel( + text(payload.title) ?? activity.summary.replace(/\s+complete(?:d)?$/iu, "").trim(), + ); + const detail = text(payload.detail) ?? ""; + if (itemType === "" && title === "" && detail === "") return null; + // No stable id — fall back to a content key (same idea as the web work-log collapse key). + return `meta:${itemType}\u001f${title}\u001f${detail}`; +} + +export type ToolCountMessage = { + readonly role: string; + readonly turnId?: string | null; + readonly streaming?: boolean; + readonly createdAt?: string; + readonly sequence?: number; +}; + +/** + * Count distinct tool calls in the **latest in-progress work segment** for the active turn. + * + * - Strictly scopes to `latestTurnId` when known (does not pull older turns / null-turn noise). + * - Collapses lifecycle updates by stable toolCallId (Set, not only consecutive). + * - Only tools after the last **settled** (non-streaming) assistant message of that turn, + * so intermediate prose does not keep accumulating the whole turn’s historical tools. + * - If there is no settled assistant yet, counts tools for the whole turn (first work batch). + * + * Discord only shows this count on Working — never individual tool rows. + */ +export function countTurnToolCalls( + activities: ReadonlyArray, + latestTurnId: TurnId | string | null, + messages: ReadonlyArray = [], +): number { + const turnId = latestTurnId?.trim() ?? ""; + + // Start of the current in-progress segment: right after the last settled assistant + // for this turn (so we don't keep counting tools from earlier intermediate bubbles). + let segmentAfterCreatedAt: string | null = null; + let segmentAfterSequence: number | null = null; + if (turnId !== "" && messages.length > 0) { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message === undefined) continue; + if (message.role !== "assistant") continue; + if (message.streaming === true) continue; + const messageTurn = message.turnId?.trim() ?? ""; + if (messageTurn !== "" && messageTurn !== turnId) continue; + segmentAfterCreatedAt = message.createdAt ?? null; + segmentAfterSequence = + typeof message.sequence === "number" && Number.isFinite(message.sequence) + ? message.sequence + : null; + break; + } + } + + const ordered = [...activities].toSorted((left, right) => { + if (left.sequence !== undefined && right.sequence !== undefined) { + return left.sequence - right.sequence; + } + return left.createdAt.localeCompare(right.createdAt); + }); + + const seen = new Set(); + for (const activity of ordered) { + // Strict turn scope: when we know the active turn, require matching turnId. + if (turnId !== "") { + if (activity.turnId === null || activity.turnId !== turnId) continue; + } + + if (segmentAfterSequence !== null && activity.sequence !== undefined) { + if (activity.sequence <= segmentAfterSequence) continue; + } else if (segmentAfterCreatedAt !== null) { + // Inclusive guard: tool created at the same instant as the settled assistant still + // belongs to the prior segment if sequence is unavailable. + if (activity.createdAt.localeCompare(segmentAfterCreatedAt) <= 0) continue; + } + + const key = collapseKeyForToolActivity(activity); + if (key === null) continue; + seen.add(key); + } + return seen.size; +} diff --git a/apps/discord-bot/src/projectAliases.test.ts b/apps/discord-bot/src/projectAliases.test.ts new file mode 100644 index 00000000000..a2e312943b4 --- /dev/null +++ b/apps/discord-bot/src/projectAliases.test.ts @@ -0,0 +1,74 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + loadProjectAliasesFromFileSync, + normalizeProjectAliasShortName, + parseProjectAliasesDocument, +} from "./projectAliases.ts"; + +describe("normalizeProjectAliasShortName", () => { + it("normalizes valid short names", () => { + expect(normalizeProjectAliasShortName("Example-Project")).toBe("example-project"); + expect(normalizeProjectAliasShortName(" t3-code ")).toBe("t3-code"); + }); + + it("rejects invalid short names", () => { + expect(normalizeProjectAliasShortName("")).toBeNull(); + expect(normalizeProjectAliasShortName("Macs_Scanner")).toBeNull(); + }); +}); + +describe("parseProjectAliasesDocument", () => { + it("parses flat path map", () => { + const aliases = parseProjectAliasesDocument({ + "example-project": "/home/user/projects/example-project", + "t3-code": "~/pj/t3code", + }); + expect(aliases.map((entry) => entry.shortName)).toEqual(["example-project", "t3-code"]); + expect(aliases[0]?.workspaceRoot).toBe("/home/user/projects/example-project"); + expect(aliases[1]?.workspaceRoot.endsWith("/pj/t3code")).toBe(true); + }); + + it("parses structured aliases map", () => { + const aliases = parseProjectAliasesDocument({ + aliases: { + "example-project": { workspaceRoot: "/tmp/scanner" }, + }, + }); + expect(aliases).toEqual([{ shortName: "example-project", workspaceRoot: "/tmp/scanner" }]); + }); + + it("parses a Discord mirror channel for GitHub-created threads", () => { + const aliases = parseProjectAliasesDocument({ + aliases: { + "t3-code": { + workspaceRoot: "/tmp/t3code", + discordChannelId: "123456789", + }, + }, + }); + expect(aliases).toEqual([ + { + shortName: "t3-code", + workspaceRoot: "/tmp/t3code", + discordChannelId: "123456789", + }, + ]); + }); +}); + +describe("loadProjectAliasesFromFileSync", () => { + it("loads yaml files", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bot-aliases-")); + const yamlPath = NodePath.join(dir, "aliases.yaml"); + await NodeFSP.writeFile(yamlPath, "example-project: /tmp/example-project\n", "utf8"); + const aliases = loadProjectAliasesFromFileSync(yamlPath); + expect(aliases).toEqual([ + { shortName: "example-project", workspaceRoot: "/tmp/example-project" }, + ]); + }); +}); diff --git a/apps/discord-bot/src/projectAliases.ts b/apps/discord-bot/src/projectAliases.ts new file mode 100644 index 00000000000..4313281b0b5 --- /dev/null +++ b/apps/discord-bot/src/projectAliases.ts @@ -0,0 +1,283 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off tryCatchInEffectGen:off +/** + * Bot-local shortName → workspaceRoot mapping. + * The T3 server does not know about these aliases. + */ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +const SHORT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export interface ProjectAlias { + readonly shortName: string; + readonly workspaceRoot: string; + /** Parent Discord text channel where GitHub-created T3 threads should be mirrored. */ + readonly discordChannelId?: string; +} + +export class ProjectAliasesLoadError extends Schema.TaggedErrorClass()( + "ProjectAliasesLoadError", + { + path: Schema.String, + message: Schema.String, + }, +) {} + +const isProjectAliasesLoadError = Schema.is(ProjectAliasesLoadError); + +export function expandHomePath(value: string): string { + if (!value) return value; + if (value === "~") return NodeOS.homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return NodePath.join(NodeOS.homedir(), value.slice(2)); + } + return value; +} + +export function normalizeProjectAliasShortName(raw: string): string | null { + const shortName = raw.trim().toLowerCase(); + if (shortName.length === 0) return null; + if (!SHORT_NAME_PATTERN.test(shortName)) return null; + return shortName; +} + +export function normalizeWorkspaceRootPath(raw: string): string { + const expanded = expandHomePath(raw.trim()); + return expanded.replaceAll("\\", "/").replace(/\/+$/u, "") || expanded; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Minimal YAML subset for flat maps and nested workspaceRoot form. + * Prefer JSON when possible; full YAML is not required. + */ +export function parseSimpleAliasesYaml(raw: string): unknown { + const lines = raw.split(/\r?\n/); + const flat: Record = {}; + const nested: Record = {}; + let inAliasesBlock = false; + let currentKey: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + + if (/^aliases:\s*$/.test(trimmed)) { + inAliasesBlock = true; + continue; + } + + const nestedValueMatch = /^\s+(workspaceRoot|discordChannelId):\s*(.+)\s*$/.exec(line); + if (nestedValueMatch && currentKey !== null) { + const field = nestedValueMatch[1]! as "workspaceRoot" | "discordChannelId"; + const value = stripYamlScalar(nestedValueMatch[2]!); + nested[currentKey] = { ...nested[currentKey], [field]: value }; + continue; + } + + const nestedKeyMatch = /^\s{2,}([A-Za-z0-9][A-Za-z0-9_-]*):\s*$/.exec(line); + if (nestedKeyMatch && inAliasesBlock) { + currentKey = nestedKeyMatch[1]!; + continue; + } + + const flatMatch = /^([A-Za-z0-9][A-Za-z0-9_-]*):\s*(.+)\s*$/.exec(trimmed); + if (flatMatch) { + const key = flatMatch[1]!; + const value = stripYamlScalar(flatMatch[2]!); + if (key === "aliases") continue; + flat[key] = value; + currentKey = null; + continue; + } + } + + if (Object.keys(nested).length > 0) { + return { aliases: nested }; + } + return flat; +} + +function stripYamlScalar(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +/** + * Accept either: + * example-project: ~/projects/example-project + * or: + * aliases: + * example-project: + * workspaceRoot: ~/projects/example-project + * or array of { shortName, workspaceRoot }. + */ +export function parseProjectAliasesDocument( + document: unknown, + options?: { readonly resolveRelativeTo?: string }, +): ReadonlyArray { + const resolveRoot = (raw: string): string => { + const expanded = expandHomePath(raw.trim()); + const absolute = + options?.resolveRelativeTo !== undefined && !NodePath.isAbsolute(expanded) + ? NodePath.resolve(options.resolveRelativeTo, expanded) + : expanded; + return normalizeWorkspaceRootPath(absolute); + }; + + const entries = new Map(); + + const add = (shortNameRaw: string, workspaceRootRaw: string, discordChannelIdRaw?: string) => { + const shortName = normalizeProjectAliasShortName(shortNameRaw); + if (shortName === null) { + throw new Error( + `Invalid project alias shortName '${shortNameRaw}'. Use lowercase letters, digits, and hyphens (e.g. example-project).`, + ); + } + const workspaceRoot = resolveRoot(workspaceRootRaw); + if (workspaceRoot.trim().length === 0) { + throw new Error(`Project alias '${shortName}' has an empty workspaceRoot.`); + } + if (entries.has(shortName)) { + throw new Error(`Duplicate project alias shortName '${shortName}'.`); + } + entries.set(shortName, workspaceRoot); + if (discordChannelIdRaw?.trim()) discordChannels.set(shortName, discordChannelIdRaw.trim()); + }; + + const discordChannels = new Map(); + + if (Array.isArray(document)) { + for (const item of document) { + if (!isRecord(item)) { + throw new Error("Project aliases array entries must be objects."); + } + const shortName = item.shortName; + const workspaceRoot = item.workspaceRoot; + if (typeof shortName !== "string" || typeof workspaceRoot !== "string") { + throw new Error("Each project alias must include string shortName and workspaceRoot."); + } + add( + shortName, + workspaceRoot, + typeof item.discordChannelId === "string" ? item.discordChannelId : undefined, + ); + } + } else if (isRecord(document)) { + const aliasesNode = document.aliases; + const source = isRecord(aliasesNode) ? aliasesNode : document; + for (const [key, value] of Object.entries(source)) { + if (key === "aliases" && source === document) continue; + if (typeof value === "string") { + add(key, value); + continue; + } + if (isRecord(value) && typeof value.workspaceRoot === "string") { + add( + key, + value.workspaceRoot, + typeof value.discordChannelId === "string" ? value.discordChannelId : undefined, + ); + continue; + } + throw new Error( + `Project alias '${key}' must be a path string or an object with workspaceRoot.`, + ); + } + } else { + throw new Error("Project aliases file must be a mapping, aliases object, or array."); + } + + return [...entries.entries()] + .map(([shortName, workspaceRoot]) => ({ + shortName, + workspaceRoot, + ...(discordChannels.has(shortName) + ? { discordChannelId: discordChannels.get(shortName)! } + : {}), + })) + .toSorted((left, right) => left.shortName.localeCompare(right.shortName)); +} + +export function loadProjectAliasesFromFileSync(filePath: string): ReadonlyArray { + const resolvedPath = NodePath.resolve(expandHomePath(filePath.trim())); + if (!NodeFS.existsSync(resolvedPath)) { + throw new ProjectAliasesLoadError({ + path: resolvedPath, + message: `Project aliases file not found: ${resolvedPath}`, + }); + } + + const raw = NodeFS.readFileSync(resolvedPath, "utf8"); + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + + let document: unknown; + if (resolvedPath.endsWith(".json")) { + document = JSON.parse(trimmed) as unknown; + } else { + document = parseSimpleAliasesYaml(trimmed); + } + + return parseProjectAliasesDocument(document, { + resolveRelativeTo: NodePath.dirname(resolvedPath), + }); +} + +export interface ProjectAliasStoreService { + readonly list: () => ReadonlyArray; + readonly resolve: (shortName: string) => ProjectAlias | null; +} + +export class ProjectAliasStore extends Context.Service< + ProjectAliasStore, + ProjectAliasStoreService +>()("@t3tools/discord-bot/projectAliases/ProjectAliasStore") {} + +export const makeProjectAliasStore = (aliases: ReadonlyArray) => + ProjectAliasStore.of({ + list: () => aliases, + resolve: (shortName) => { + const normalized = shortName.trim().toLowerCase(); + return aliases.find((entry) => entry.shortName === normalized) ?? null; + }, + }); + +export const layerFromOptionalPath = (filePath: string | undefined) => + Layer.effect( + ProjectAliasStore, + Effect.gen(function* () { + if (filePath === undefined || filePath.trim().length === 0) { + yield* Effect.logWarning( + "T3_PROJECT_ALIASES_PATH is unset; channel topics will not resolve to projects until configured.", + ); + return makeProjectAliasStore([]); + } + const aliases = yield* Effect.try({ + try: () => loadProjectAliasesFromFileSync(filePath), + catch: (cause) => { + if (isProjectAliasesLoadError(cause)) return cause; + return new ProjectAliasesLoadError({ + path: filePath, + message: cause instanceof Error ? cause.message : String(cause), + }); + }, + }); + yield* Effect.logInfo(`Loaded ${aliases.length} project alias(es) from ${filePath}`); + return makeProjectAliasStore(aliases); + }), + ); diff --git a/apps/discord-bot/src/store/ServerWorkItemJoin.test.ts b/apps/discord-bot/src/store/ServerWorkItemJoin.test.ts new file mode 100644 index 00000000000..37743d0ce83 --- /dev/null +++ b/apps/discord-bot/src/store/ServerWorkItemJoin.test.ts @@ -0,0 +1,121 @@ +// @effect-diagnostics nodeBuiltinImport:off +import type { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { normalizeThreadLinkInput, type ThreadLink } from "./ThreadLinkStore.ts"; +import { + normalizeGitHubPullRequestRef, + normalizeJiraIssueKey, + resolveUniqueT3ThreadIdForWorkItems, + serverWorkItemsPathFromStateSqlite, +} from "./ServerWorkItemJoin.ts"; + +function discordLink(t3ThreadId: string, status: "active" | "tombstone" = "active"): ThreadLink { + return normalizeThreadLinkInput({ + discordThreadId: `discord-${t3ThreadId}`, + t3ThreadId: t3ThreadId as ThreadId, + projectId: "project-1" as ProjectId, + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-30T00:00:00.000Z", + status, + }); +} + +describe("ServerWorkItemJoin", () => { + it("normalizes keys and PR refs", () => { + expect(normalizeJiraIssueKey("sa-401")).toBe("SA-401"); + expect(normalizeJiraIssueKey("UTF-8")).toBeNull(); + expect(normalizeGitHubPullRequestRef("https://github.com/Acme/Repo/pull/9")).toBe( + "github.com/acme/repo/pull/9", + ); + }); + + it("joins unique server store hits and fails closed on ambiguity", () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "work-item-join-")); + const filePath = NodePath.join(dir, "thread-work-items.json"); + NodeFS.writeFileSync( + filePath, + JSON.stringify({ + version: 1, + records: [ + { + threadId: "t-jira", + jiraIssueKeys: ["SA-401"], + githubPullRequests: [], + sources: ["jira-webhook"], + updatedAt: "2026-07-30T00:00:00.000Z", + }, + { + threadId: "t-other", + jiraIssueKeys: ["SA-402"], + githubPullRequests: ["github.com/acme/repo/pull/1"], + sources: ["github-webhook"], + updatedAt: "2026-07-30T00:00:00.000Z", + }, + ], + }), + ); + + expect( + resolveUniqueT3ThreadIdForWorkItems({ + jiraIssueKeys: ["SA-401"], + prUrls: [], + discordLinks: [], + serverWorkItemsPath: filePath, + }), + ).toBe("t-jira"); + + expect( + resolveUniqueT3ThreadIdForWorkItems({ + jiraIssueKeys: ["SA-401", "SA-402"], + prUrls: [], + discordLinks: [], + serverWorkItemsPath: filePath, + }), + ).toBeNull(); + + expect(serverWorkItemsPathFromStateSqlite("/var/lib/t3/userdata/state.sqlite")).toBe( + "/var/lib/t3/userdata/thread-work-items.json", + ); + }); + + it("never joins a server work-item thread that already has an active Discord link", () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "work-item-join-")); + const filePath = NodePath.join(dir, "thread-work-items.json"); + NodeFS.writeFileSync( + filePath, + JSON.stringify({ + version: 1, + records: [ + { + threadId: "t-pr", + jiraIssueKeys: [], + githubPullRequests: ["https://github.com/acme/repo/pull/9"], + }, + ], + }), + ); + + expect( + resolveUniqueT3ThreadIdForWorkItems({ + jiraIssueKeys: [], + prUrls: ["https://github.com/acme/repo/pull/9"], + discordLinks: [discordLink("t-pr")], + serverWorkItemsPath: filePath, + }), + ).toBeNull(); + + expect( + resolveUniqueT3ThreadIdForWorkItems({ + jiraIssueKeys: [], + prUrls: ["https://github.com/acme/repo/pull/9"], + discordLinks: [discordLink("t-pr", "tombstone")], + serverWorkItemsPath: filePath, + }), + ).toBe("t-pr"); + }); +}); diff --git a/apps/discord-bot/src/store/ServerWorkItemJoin.ts b/apps/discord-bot/src/store/ServerWorkItemJoin.ts new file mode 100644 index 00000000000..14598133aad --- /dev/null +++ b/apps/discord-bot/src/store/ServerWorkItemJoin.ts @@ -0,0 +1,134 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Join an existing T3 thread by shared work-item identity (Jira key / GitHub PR) + * before Discord creates a new session. + * + * Sources: + * - Server `thread-work-items.json` next to state.sqlite (Jira/GitHub bridges) + * + * Active Discord links are exclusions: one T3 thread must never be implicitly + * joined from more than one Discord thread. + */ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import type { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { ThreadLink } from "./ThreadLinkStore.ts"; + +const FALSE_POSITIVE_JIRA_KEYS = new Set(["UTF-8", "ISO-8601", "HTTP-1", "HTTP-2", "TLS-1"]); + +export function normalizeJiraIssueKey(raw: string): string | null { + const key = raw.trim().toUpperCase(); + if (!/^[A-Z][A-Z0-9]{1,9}-\d{1,7}$/u.test(key)) return null; + if (FALSE_POSITIVE_JIRA_KEYS.has(key)) return null; + return key; +} + +export function normalizeGitHubPullRequestRef(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + const urlMatch = trimmed.match( + /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/[^?\s]*)?(?:[?#]\S*)?$/iu, + ); + if (urlMatch) { + return `github.com/${urlMatch[1]!.toLowerCase()}/${urlMatch[2]!.toLowerCase()}/pull/${urlMatch[3]!}`; + } + const shortMatch = trimmed.match(/^([^/\s]+)\/([^#\s]+)#(\d+)$/u); + if (shortMatch) { + return `github.com/${shortMatch[1]!.toLowerCase()}/${shortMatch[2]!.toLowerCase()}/pull/${shortMatch[3]!}`; + } + return null; +} + +type ServerWorkItemRecord = { + readonly threadId: string; + readonly jiraIssueKeys?: ReadonlyArray; + readonly githubPullRequests?: ReadonlyArray; +}; + +function readServerWorkItemRecords(filePath: string): ReadonlyArray { + try { + const raw = NodeFS.readFileSync(filePath, "utf8"); + const parsed: unknown = JSON.parse(raw); + if (parsed === null || typeof parsed !== "object") return []; + const records = (parsed as { records?: unknown }).records; + if (!Array.isArray(records)) return []; + return records.filter( + (row): row is ServerWorkItemRecord => + row !== null && + typeof row === "object" && + typeof (row as ServerWorkItemRecord).threadId === "string", + ); + } catch { + return []; + } +} + +/** + * Return a unique T3 thread id if the given Jira keys / PR URLs map to exactly one thread. + * Fail closed on zero or many matches. + */ +export function resolveUniqueT3ThreadIdForWorkItems(input: { + readonly jiraIssueKeys: ReadonlyArray; + readonly prUrls: ReadonlyArray; + readonly discordLinks: ReadonlyArray; + readonly serverWorkItemsPath: string; +}): ThreadId | null { + const jiraKeys = [ + ...new Set( + input.jiraIssueKeys + .map((key) => normalizeJiraIssueKey(key)) + .filter((key): key is string => key !== null), + ), + ]; + const prRefs = [ + ...new Set( + input.prUrls + .map((url) => normalizeGitHubPullRequestRef(url)) + .filter((ref): ref is string => ref !== null), + ), + ]; + if (jiraKeys.length === 0 && prRefs.length === 0) return null; + + const threadIds = new Set(); + const discordLinkedThreadIds = new Set( + input.discordLinks + .filter((link) => link.status === "active" && (link.sourceKind ?? "discord") === "discord") + .map((link) => link.t3ThreadId), + ); + + const serverRecords = readServerWorkItemRecords(input.serverWorkItemsPath); + for (const record of serverRecords) { + if (discordLinkedThreadIds.has(record.threadId)) continue; + const recordKeys = new Set( + (record.jiraIssueKeys ?? []) + .map((key) => normalizeJiraIssueKey(key)) + .filter((key): key is string => key !== null), + ); + const recordPrs = new Set( + (record.githubPullRequests ?? []) + .map((url) => normalizeGitHubPullRequestRef(url)) + .filter((ref): ref is string => ref !== null), + ); + const jiraHit = jiraKeys.some((key) => recordKeys.has(key)); + const prHit = prRefs.some((ref) => recordPrs.has(ref)); + if (jiraHit || prHit) threadIds.add(record.threadId); + } + + if (threadIds.size !== 1) return null; + const [only] = threadIds; + return only as ThreadId; +} + +export function serverWorkItemsPathFromStateSqlite(stateSqlitePath: string): string { + return NodePath.join(NodePath.dirname(stateSqlitePath), "thread-work-items.json"); +} + +export const resolveUniqueT3ThreadIdForWorkItemsEffect = (input: { + readonly jiraIssueKeys: ReadonlyArray; + readonly prUrls: ReadonlyArray; + readonly discordLinks: ReadonlyArray; + readonly serverWorkItemsPath: string; +}) => Effect.sync(() => resolveUniqueT3ThreadIdForWorkItems(input)); diff --git a/apps/discord-bot/src/store/TeamsSeenStore.ts b/apps/discord-bot/src/store/TeamsSeenStore.ts new file mode 100644 index 00000000000..3b5fcf1ec10 --- /dev/null +++ b/apps/discord-bot/src/store/TeamsSeenStore.ts @@ -0,0 +1,87 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off tryCatchInEffectGen:off missingEffectError:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { expandHomePath } from "../projectAliases.ts"; + +export interface TeamsSeenStoreService { + readonly hasSeen: (channelKey: string, messageId: string) => Effect.Effect; + readonly listSeenIds: (channelKey: string) => Effect.Effect>; + readonly markSeen: (channelKey: string, messageId: string) => Effect.Effect; +} + +export class TeamsSeenStore extends Context.Service()( + "@t3tools/discord-bot/store/TeamsSeenStore", +) {} + +const MAX_MESSAGE_IDS_PER_CHANNEL = 500; + +export const layer = (dataDirRaw: string) => + Layer.effect( + TeamsSeenStore, + Effect.gen(function* () { + const dataDir = expandHomePath(dataDirRaw); + const filePath = NodePath.join(dataDir, "teams-seen.json"); + yield* Effect.promise(() => NodeFSP.mkdir(dataDir, { recursive: true, mode: 0o700 })); + + const initial = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(filePath, "utf8"), + catch: () => null, + }).pipe( + Effect.map((raw) => { + if (raw === null) return {} as Record>; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {} as Record>; + } + const entries = Object.entries(parsed as Record).flatMap( + ([channelKey, value]) => + Array.isArray(value) && value.every((item) => typeof item === "string") + ? [[channelKey, value] as const] + : [], + ); + return Object.fromEntries(entries); + }), + Effect.orElseSucceed(() => ({}) as Record>), + ); + + const state = yield* Ref.make( + new Map( + Object.entries(initial).map(([channelKey, messageIds]) => [channelKey, [...messageIds]]), + ), + ); + + const persist = (value: Map) => + Effect.promise(() => + NodeFSP.writeFile( + filePath, + `${JSON.stringify(Object.fromEntries(value.entries()), null, 2)}\n`, + { mode: 0o600 }, + ), + ); + + return TeamsSeenStore.of({ + hasSeen: (channelKey, messageId) => + Ref.get(state).pipe(Effect.map((map) => (map.get(channelKey) ?? []).includes(messageId))), + listSeenIds: (channelKey) => + Ref.get(state).pipe(Effect.map((map) => [...(map.get(channelKey) ?? [])])), + markSeen: (channelKey, messageId) => + Effect.gen(function* () { + const next = yield* Ref.updateAndGet(state, (current) => { + const copy = new Map(current); + const existing = copy.get(channelKey) ?? []; + if (existing.includes(messageId)) { + return copy; + } + copy.set(channelKey, [...existing, messageId].slice(-MAX_MESSAGE_IDS_PER_CHANNEL)); + return copy; + }); + yield* persist(next); + }), + }); + }), + ); diff --git a/apps/discord-bot/src/store/ThreadLinkStore.test.ts b/apps/discord-bot/src/store/ThreadLinkStore.test.ts new file mode 100644 index 00000000000..404e1ec7c7e --- /dev/null +++ b/apps/discord-bot/src/store/ThreadLinkStore.test.ts @@ -0,0 +1,462 @@ +// @effect-diagnostics nodeBuiltinImport:off anyUnknownInErrorContext:off missingEffectError:off missingEffectContext:off +/* oxlint-disable t3code/no-manual-effect-runtime-in-tests -- Legacy filesystem fixture uses a manually scoped runtime. */ +import type { ProjectId, ThreadId } from "@t3tools/contracts"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { ProjectId as ProjectIdBrand, ThreadId as ThreadIdBrand } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect, it as vitestIt } from "vite-plus/test"; + +import { + LINKS_DOCUMENT_VERSION, + makeThreadLinkStore, + migrateV1Link, + normalizeThreadLinkInput, + parseLinksDocument, + type ThreadLinkInput, + type ThreadLinkStoreService, +} from "./ThreadLinkStore.ts"; + +const makeTempDir = Effect.acquireRelease( + Effect.tryPromise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "thread-link-store-"))), + (dir) => Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })), +); + +const sampleV1 = { + discordThreadId: "d-1", + t3ThreadId: "t-1", + projectId: "p-1", + channelId: "c-1", + guildId: "g-1", + createdAt: "2026-01-01T00:00:00.000Z", +}; + +function input(overrides: Partial = {}): ThreadLinkInput { + return { + discordThreadId: "d-1", + t3ThreadId: "t-1" as ThreadId, + projectId: "p-1" as ProjectId, + channelId: "c-1", + guildId: "g-1", + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +async function withStore( + body: (store: ThreadLinkStoreService) => Effect.Effect, +): Promise { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bot-links-")); + try { + return await Effect.runPromise( + Effect.gen(function* () { + const store = yield* makeThreadLinkStore(dir); + return yield* body(store); + }) as Effect.Effect, + ); + } finally { + await NodeFSP.rm(dir, { recursive: true, force: true }); + } +} + +describe("migrateV1Link / parseLinksDocument", () => { + vitestIt("migrates v1 fields to v2 defaults", () => { + const migrated = migrateV1Link(sampleV1); + expect(migrated).toEqual({ + ...sampleV1, + t3ThreadId: "t-1", + projectId: "p-1", + updatedAt: sampleV1.createdAt, + lastActivityAt: sampleV1.createdAt, + status: "active", + lastSeenTurnId: null, + lastFinalizedAssistantId: null, + lastThreadSnapshotSequence: null, + lastDeliveredSequence: null, + threadTalkMode: undefined, + taskDiscordMessageId: undefined, + streamDiscordMessageIds: undefined, + sentDiscordUserMessageIds: undefined, + jiraIssueKeys: undefined, + prUrls: undefined, + infoDiscordMessageId: undefined, + initialModelLine: undefined, + currentModelLine: undefined, + modelSinceAt: undefined, + }); + }); + + vitestIt("parses bare v1 array as migrated v2", () => { + const parsed = parseLinksDocument([sampleV1]); + expect(parsed.migratedFromV1).toBe(true); + expect(parsed.version).toBe(LINKS_DOCUMENT_VERSION); + expect(parsed.links).toHaveLength(1); + expect(parsed.links[0]?.lastActivityAt).toBe(sampleV1.createdAt); + expect(parsed.links[0]?.status).toBe("active"); + expect(parsed.links[0]?.streamDiscordMessageIds).toBeUndefined(); + }); + + vitestIt("parses versioned v2 document", () => { + const link = normalizeThreadLinkInput(input()); + const parsed = parseLinksDocument({ version: 2, links: [link] }); + expect(parsed.migratedFromV1).toBe(false); + expect(parsed.links[0]).toEqual(link); + }); + + vitestIt("returns empty list for corrupt payload", () => { + expect(parseLinksDocument(null).links).toEqual([]); + expect(parseLinksDocument("nope").links).toEqual([]); + expect(parseLinksDocument({ version: 2, links: "bad" }).links).toEqual([]); + }); + + vitestIt("preserves stream tip ids when migrating v1 with extras", () => { + const parsed = parseLinksDocument([ + { + ...sampleV1, + streamDiscordMessageIds: ["s1", "s2"], + taskDiscordMessageId: "task-1", + }, + ]); + expect(parsed.links[0]?.streamDiscordMessageIds).toEqual(["s1", "s2"]); + expect(parsed.links[0]?.taskDiscordMessageId).toBe("task-1"); + }); +}); + +describe("normalizeThreadLinkInput", () => { + vitestIt("fills durable defaults for optional bridge fields", () => { + const link = normalizeThreadLinkInput(input()); + expect(link.updatedAt).toBe(link.createdAt); + expect(link.lastActivityAt).toBe(link.createdAt); + expect(link.status).toBe("active"); + expect(link.lastSeenTurnId).toBeNull(); + expect(link.lastFinalizedAssistantId).toBeNull(); + }); +}); + +describe("makeThreadLinkStore", () => { + vitestIt("put / getByDiscordThreadId / getByT3ThreadId / list", async () => { + await withStore((store) => + Effect.gen(function* () { + yield* store.put(input()); + const byDiscord = yield* store.getByDiscordThreadId("d-1"); + const byT3 = yield* store.getByT3ThreadId("t-1"); + const missing = yield* store.getByDiscordThreadId("missing"); + const all = yield* store.list(); + + expect(byDiscord?.t3ThreadId).toBe("t-1"); + expect(byT3?.discordThreadId).toBe("d-1"); + expect(missing).toBeNull(); + expect(all).toHaveLength(1); + }), + ); + }); + + vitestIt("touch updates lastActivityAt and updatedAt", async () => { + await withStore((store) => + Effect.gen(function* () { + yield* store.put(input()); + const touched = yield* store.touch("d-1", "2026-06-01T12:00:00.000Z"); + expect(touched?.lastActivityAt).toBe("2026-06-01T12:00:00.000Z"); + expect(touched?.updatedAt).toBe("2026-06-01T12:00:00.000Z"); + }), + ); + }); + + vitestIt("tombstone marks link inactive", async () => { + await withStore((store) => + Effect.gen(function* () { + yield* store.put(input()); + const tombstoned = yield* store.tombstone("d-1"); + expect(tombstoned?.status).toBe("tombstone"); + }), + ); + }); + + vitestIt("updateBridgeHints persists finalize + stream tip ids", async () => { + await withStore((store) => + Effect.gen(function* () { + yield* store.put(input()); + yield* store.updateBridgeHints("d-1", { + lastFinalizedAssistantId: "asst-1", + streamDiscordMessageIds: ["s1", "s1", ""], + }); + const link = yield* store.getByDiscordThreadId("d-1"); + expect(link?.lastFinalizedAssistantId).toBe("asst-1"); + expect(link?.streamDiscordMessageIds).toEqual(["s1"]); + + yield* store.updateBridgeHints("d-1", { streamDiscordMessageIds: [] }); + const cleared = yield* store.getByDiscordThreadId("d-1"); + expect(cleared?.streamDiscordMessageIds).toBeUndefined(); + expect(cleared?.lastFinalizedAssistantId).toBe("asst-1"); + }), + ); + }); + + vitestIt("partial bridge hint writes do not wipe sequence or stream markers", async () => { + await withStore((store) => + Effect.gen(function* () { + yield* store.put(input()); + yield* store.updateBridgeHints("d-1", { + lastThreadSnapshotSequence: 42, + lastDeliveredSequence: 40, + streamDiscordMessageIds: ["tip-1"], + }); + // Sequence-only update must keep stream tip ids + delivery cursor. + yield* store.updateBridgeHints("d-1", { lastThreadSnapshotSequence: 99 }); + const afterSeq = yield* store.getByDiscordThreadId("d-1"); + expect(afterSeq?.lastThreadSnapshotSequence).toBe(99); + expect(afterSeq?.lastDeliveredSequence).toBe(40); + expect(afterSeq?.streamDiscordMessageIds).toEqual(["tip-1"]); + + // Delivery cursor-only update must keep orchestration sequence. + yield* store.updateBridgeHints("d-1", { lastDeliveredSequence: 99 }); + const afterDelivered = yield* store.getByDiscordThreadId("d-1"); + expect(afterDelivered?.lastDeliveredSequence).toBe(99); + expect(afterDelivered?.lastThreadSnapshotSequence).toBe(99); + + // Stream-only update must keep both sequence cursors. + yield* store.setStreamDiscordMessageIds("d-1", ["tip-2"]); + const afterStream = yield* store.getByDiscordThreadId("d-1"); + expect(afterStream?.streamDiscordMessageIds).toEqual(["tip-2"]); + expect(afterStream?.lastThreadSnapshotSequence).toBe(99); + expect(afterStream?.lastDeliveredSequence).toBe(99); + + // Minimal put must preserve durable dual-cursor + tip hints. + yield* store.put(input()); + const afterPut = yield* store.getByDiscordThreadId("d-1"); + expect(afterPut?.lastThreadSnapshotSequence).toBe(99); + expect(afterPut?.lastDeliveredSequence).toBe(99); + expect(afterPut?.streamDiscordMessageIds).toEqual(["tip-2"]); + }), + ); + }); +}); + +it.effect("persists the task message id for later bridge restarts", () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.setTaskDiscordMessageId("discord-thread-1", "task-message-1"); + + const reloaded = yield* makeThreadLinkStore(dataDir); + const link = yield* reloaded.getByDiscordThreadId("discord-thread-1"); + + assert.strictEqual(link?.taskDiscordMessageId, "task-message-1"); + assert.strictEqual(link?.status, "active"); + assert.strictEqual(link?.lastActivityAt, "2026-07-18T00:00:00.000Z"); + }), +); + +it.effect("persists jira keys in first-seen order and info message id", () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.appendJiraIssueKeys("discord-thread-1", ["PROJ-2", "PROJ-1"]); + yield* store.appendJiraIssueKeys("discord-thread-1", ["PROJ-1", "PROJ-3"]); + yield* store.setInfoDiscordMessageId("discord-thread-1", "info-msg-1"); + + // Minimal put must preserve durable jira + info hints. + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + + const reloaded = yield* makeThreadLinkStore(dataDir); + const link = yield* reloaded.getByDiscordThreadId("discord-thread-1"); + assert.deepStrictEqual(link?.jiraIssueKeys, ["PROJ-2", "PROJ-1", "PROJ-3"]); + assert.strictEqual(link?.infoDiscordMessageId, "info-msg-1"); + }), +); + +it.effect("persists PR urls in first-seen order across reloads and minimal puts", () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.appendPrUrls("discord-thread-1", [ + "https://github.com/acme/widgets/pull/42", + "https://github.com/acme/widgets/pull/7/files", + ]); + yield* store.appendPrUrls("discord-thread-1", [ + "https://github.com/acme/widgets/pull/42", + "https://github.com/example-org/scanner/pull/1950", + ]); + + // Minimal put must preserve durable PR urls. + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + + const reloaded = yield* makeThreadLinkStore(dataDir); + const link = yield* reloaded.getByDiscordThreadId("discord-thread-1"); + assert.deepStrictEqual(link?.prUrls, [ + "https://github.com/acme/widgets/pull/42", + "https://github.com/acme/widgets/pull/7", + "https://github.com/example-org/scanner/pull/1950", + ]); + }), +); + +it.effect("persists model history for thread info pin", () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.setModelHistory("discord-thread-1", { + initialModelLine: "codex/gpt-5.4", + currentModelLine: "grok/grok-4.5", + modelSinceAt: "2026-07-20T08:05:00.000Z", + }); + + const reloaded = yield* makeThreadLinkStore(dataDir); + const link = yield* reloaded.getByDiscordThreadId("discord-thread-1"); + assert.strictEqual(link?.initialModelLine, "codex/gpt-5.4"); + assert.strictEqual(link?.currentModelLine, "grok/grok-4.5"); + assert.strictEqual(link?.modelSinceAt, "2026-07-20T08:05:00.000Z"); + }), +); + +it.effect("persists and clears stream message ids for bridge restart cleanup", () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.setStreamDiscordMessageIds("discord-thread-1", [ + "stream-1", + "stream-2", + "stream-1", + "", + ]); + + const reloaded = yield* makeThreadLinkStore(dataDir); + const link = yield* reloaded.getByDiscordThreadId("discord-thread-1"); + + assert.deepStrictEqual(link?.streamDiscordMessageIds, ["stream-1", "stream-2"]); + + yield* reloaded.setStreamDiscordMessageIds("discord-thread-1", []); + const cleared = yield* (yield* makeThreadLinkStore(dataDir)).getByDiscordThreadId( + "discord-thread-1", + ); + + assert.strictEqual(cleared?.streamDiscordMessageIds, undefined); + }), +); + +it.effect( + "persists and clears Discord-originated user message ids for external echo suppression", + () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.setSentDiscordUserMessageIds("discord-thread-1", [ + "user-1", + "user-2", + "user-1", + "", + ]); + + const reloaded = yield* makeThreadLinkStore(dataDir); + const link = yield* reloaded.getByDiscordThreadId("discord-thread-1"); + + assert.deepStrictEqual(link?.sentDiscordUserMessageIds, ["user-1", "user-2"]); + + yield* reloaded.setSentDiscordUserMessageIds("discord-thread-1", []); + const cleared = yield* (yield* makeThreadLinkStore(dataDir)).getByDiscordThreadId( + "discord-thread-1", + ); + + assert.strictEqual(cleared?.sentDiscordUserMessageIds, undefined); + }), +); + +it.effect("persists and disables thread-talk mode", () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.setThreadTalkMode("discord-thread-1", "all-messages"); + + const enabled = yield* (yield* makeThreadLinkStore(dataDir)).getByDiscordThreadId( + "discord-thread-1", + ); + assert.strictEqual(enabled?.threadTalkMode, "all-messages"); + assert.strictEqual(enabled?.sentDiscordUserMessageIds, undefined); + + yield* store.setThreadTalkMode("discord-thread-1", null); + const disabled = yield* (yield* makeThreadLinkStore(dataDir)).getByDiscordThreadId( + "discord-thread-1", + ); + assert.strictEqual(disabled?.threadTalkMode, undefined); + assert.strictEqual(disabled?.sentDiscordUserMessageIds, undefined); + }), +); diff --git a/apps/discord-bot/src/store/ThreadLinkStore.ts b/apps/discord-bot/src/store/ThreadLinkStore.ts new file mode 100644 index 00000000000..49fa0628428 --- /dev/null +++ b/apps/discord-bot/src/store/ThreadLinkStore.ts @@ -0,0 +1,848 @@ +// @effect-diagnostics globalErrorInEffectCatch:off globalErrorInEffectFailure:off preferSchemaOverJson:off tryCatchInEffectGen:off missingEffectError:off nodeBuiltinImport:off globalDate:off +import type { ProjectId, ThreadId } from "@t3tools/contracts"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +export const LINKS_DOCUMENT_VERSION = 2 as const; + +export const ThreadLinkStatus = Schema.Literals(["active", "tombstone"]); +export type ThreadLinkStatus = typeof ThreadLinkStatus.Type; + +/** + * Durable Discord ↔ T3 link (links.json v2). + * + * Keeps existing Discord-bot fields (threadTalkMode, task/stream/sent message ids) + * and adds restore hints (activity, tombstone, last finalized assistant). + */ +export const ThreadLink = Schema.Struct({ + /** Originating chat platform; defaults to discord when omitted (older links.json). */ + sourceKind: Schema.optional(Schema.Literals(["discord", "teams"])), + /** Stable external conversation key (Teams root message id, or Discord thread id). */ + sourceThreadId: Schema.optional(Schema.String), + discordThreadId: Schema.String, + t3ThreadId: Schema.String, + projectId: Schema.String, + channelId: Schema.String, + guildId: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + lastActivityAt: Schema.String, + status: ThreadLinkStatus, + lastSeenTurnId: Schema.NullOr(Schema.String), + lastFinalizedAssistantId: Schema.NullOr(Schema.String), + /** + * Last applied orchestration event/snapshot sequence for this linked T3 thread. + * Scalar marker only — we never persist pre-sync event history. Used with + * `lastDeliveredSequence` to resume `subscribeThread({ afterSequence })` without + * re-walking the whole event log. Advances when T3 state is observed (WS/HTTP), not + * when Discord I/O finishes. Partial bridge hint writes must not clear this. + * Optional in schema so older links.json rows still decode (default null). + */ + lastThreadSnapshotSequence: Schema.optional(Schema.NullOr(Schema.Number)), + /** + * Last orchestration sequence that was successfully applied to Discord + * (stream tip and/or finalize path finished without fatal process failure). + * Scalar marker only (not message/event history). Resume prefers this cursor + * (minus a small buffer) so already-synced sequences are not re-read. + * May lag `lastThreadSnapshotSequence` when Discord I/O is slow/hung or the + * process dies mid-delivery. Used for HTTP reconcile + rehydrate catch-up. + * Optional for older links.json rows (default null = unknown). + */ + lastDeliveredSequence: Schema.optional(Schema.NullOr(Schema.Number)), + threadTalkMode: Schema.optional(Schema.Literal("all-messages")), + taskDiscordMessageId: Schema.optional(Schema.String), + /** In-progress Discord stream tip ids (+ stale tips). Deleted on finalize / restart cleanup. */ + streamDiscordMessageIds: Schema.optional(Schema.Array(Schema.String)), + sentDiscordUserMessageIds: Schema.optional(Schema.Array(Schema.String)), + /** + * Jira issue keys observed for this Discord thread, in first-seen order (no duplicates). + * Surfaced on the pinned thread-info message. + */ + jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)), + /** + * GitHub pull request URLs observed for this Discord thread, in first-seen order + * (canonical https://github.com/owner/repo/pull/N, no duplicates). + * Surfaced on the pinned thread-info message next to Jira. + */ + prUrls: Schema.optional(Schema.Array(Schema.String)), + /** Discord message id of the pinned Model / worktree / Open in Omegent / Jira / PRs info message. */ + infoDiscordMessageId: Schema.optional(Schema.String), + /** First model used when this Discord↔T3 link was created (`instanceId/model`). */ + initialModelLine: Schema.optional(Schema.String), + /** Last known model on the linked T3 thread (`instanceId/model`). */ + currentModelLine: Schema.optional(Schema.String), + /** + * When `currentModelLine` became active if it differs from `initialModelLine` + * (ISO timestamp). Cleared when current matches initial. + */ + modelSinceAt: Schema.optional(Schema.String), +}); +export type ThreadLink = { + readonly sourceKind?: "discord" | "teams" | undefined; + readonly sourceThreadId?: string | undefined; + readonly discordThreadId: string; + readonly t3ThreadId: ThreadId; + readonly projectId: ProjectId; + readonly channelId: string; + readonly guildId: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastActivityAt: string; + readonly status: ThreadLinkStatus; + readonly lastSeenTurnId: string | null; + readonly lastFinalizedAssistantId: string | null; + readonly lastThreadSnapshotSequence: number | null; + readonly lastDeliveredSequence: number | null; + readonly threadTalkMode?: "all-messages" | undefined; + readonly taskDiscordMessageId?: string | undefined; + readonly streamDiscordMessageIds?: ReadonlyArray | undefined; + readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; + readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly prUrls?: ReadonlyArray | undefined; + readonly infoDiscordMessageId?: string | undefined; + readonly initialModelLine?: string | undefined; + readonly currentModelLine?: string | undefined; + readonly modelSinceAt?: string | undefined; +}; + +/** Fields callers may omit on put — filled with durable defaults. */ +export type ThreadLinkInput = { + readonly sourceKind?: "discord" | "teams" | undefined; + readonly sourceThreadId?: string | undefined; + readonly discordThreadId: string; + readonly t3ThreadId: ThreadId; + readonly projectId: ProjectId; + readonly channelId: string; + readonly guildId: string; + readonly createdAt: string; + readonly updatedAt?: string; + readonly lastActivityAt?: string; + readonly status?: ThreadLinkStatus; + readonly lastSeenTurnId?: string | null; + readonly lastFinalizedAssistantId?: string | null; + readonly lastThreadSnapshotSequence?: number | null; + readonly lastDeliveredSequence?: number | null; + readonly threadTalkMode?: "all-messages" | undefined; + readonly taskDiscordMessageId?: string | undefined; + readonly streamDiscordMessageIds?: ReadonlyArray | undefined; + readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; + readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly prUrls?: ReadonlyArray | undefined; + readonly infoDiscordMessageId?: string | undefined; + readonly initialModelLine?: string | undefined; + readonly currentModelLine?: string | undefined; + readonly modelSinceAt?: string | undefined; +}; + +/** Partial durable bridge hints written while streaming / finalizing. */ +export type ThreadLinkBridgeHints = { + readonly lastSeenTurnId?: string | null; + readonly lastFinalizedAssistantId?: string | null; + readonly lastThreadSnapshotSequence?: number | null; + readonly lastDeliveredSequence?: number | null; + readonly streamDiscordMessageIds?: ReadonlyArray; +}; + +export type ThreadLinkModelHistory = { + readonly initialModelLine?: string | null | undefined; + readonly currentModelLine?: string | null | undefined; + readonly modelSinceAt?: string | null | undefined; +}; + +const LinksDocumentV2 = Schema.Struct({ + version: Schema.Literal(LINKS_DOCUMENT_VERSION), + links: Schema.Array(ThreadLink), +}); + +const decodeLinksArray = Schema.decodeUnknownSync(Schema.Array(ThreadLink)); +const decodeLinksDocumentV2 = Schema.decodeUnknownSync(LinksDocumentV2); + +/** Legacy v1 link (pre-restore fields). Extra keys are ignored by the decoder. */ +const ThreadLinkV1 = Schema.Struct({ + discordThreadId: Schema.String, + t3ThreadId: Schema.String, + projectId: Schema.String, + channelId: Schema.String, + guildId: Schema.String, + createdAt: Schema.String, + threadTalkMode: Schema.optional(Schema.Literal("all-messages")), + taskDiscordMessageId: Schema.optional(Schema.String), + streamDiscordMessageIds: Schema.optional(Schema.Array(Schema.String)), + sentDiscordUserMessageIds: Schema.optional(Schema.Array(Schema.String)), + jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)), + prUrls: Schema.optional(Schema.Array(Schema.String)), + infoDiscordMessageId: Schema.optional(Schema.String), +}); +const decodeLinksV1 = Schema.decodeUnknownSync(Schema.Array(ThreadLinkV1)); + +function nowIso(): string { + return new Date().toISOString(); +} + +/** First-seen order, case-normalized uppercase, no duplicates. */ +function mergeJiraKeysOrdered( + existing: ReadonlyArray | null | undefined, + incoming: ReadonlyArray | null | undefined, +): ReadonlyArray { + const result: string[] = []; + const seen = new Set(); + for (const raw of [...(existing ?? []), ...(incoming ?? [])]) { + const key = raw.trim().toUpperCase(); + if (key.length === 0 || seen.has(key)) continue; + seen.add(key); + result.push(key); + } + return result; +} + +/** + * First-seen order for GitHub PR URLs. Light normalize: trim, strip query/hash/subpaths, + * lowercase host path for dedup key. Full URL validation lives in presentation/prLinks. + */ +function mergePrUrlsOrdered( + existing: ReadonlyArray | null | undefined, + incoming: ReadonlyArray | null | undefined, +): ReadonlyArray { + const result: string[] = []; + const seen = new Set(); + for (const raw of [...(existing ?? []), ...(incoming ?? [])]) { + const normalized = normalizeStoredPrUrl(raw); + if (normalized === null || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function normalizeStoredPrUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + const match = + /^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/iu.exec( + trimmed, + ); + if (match === null) return null; + const owner = (match[1] ?? "").toLowerCase(); + const repo = (match[2] ?? "").replace(/\.git$/iu, "").toLowerCase(); + const number = match[3] ?? ""; + if (owner.length === 0 || repo.length === 0 || number.length === 0) return null; + return `https://github.com/${owner}/${repo}/pull/${number}`; +} + +function asThreadLink(link: { + readonly sourceKind?: "discord" | "teams" | undefined; + readonly sourceThreadId?: string | undefined; + readonly discordThreadId: string; + readonly t3ThreadId: string; + readonly projectId: string; + readonly channelId: string; + readonly guildId: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastActivityAt: string; + readonly status: ThreadLinkStatus; + readonly lastSeenTurnId: string | null; + readonly lastFinalizedAssistantId: string | null; + readonly lastThreadSnapshotSequence?: number | null | undefined; + readonly lastDeliveredSequence?: number | null | undefined; + readonly threadTalkMode?: "all-messages" | undefined; + readonly taskDiscordMessageId?: string | undefined; + readonly streamDiscordMessageIds?: ReadonlyArray | undefined; + readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; + readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly prUrls?: ReadonlyArray | undefined; + readonly infoDiscordMessageId?: string | undefined; + readonly initialModelLine?: string | undefined; + readonly currentModelLine?: string | undefined; + readonly modelSinceAt?: string | undefined; +}): ThreadLink { + return { + sourceKind: link.sourceKind, + sourceThreadId: link.sourceThreadId, + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId as ThreadId, + projectId: link.projectId as ProjectId, + channelId: link.channelId, + guildId: link.guildId, + createdAt: link.createdAt, + updatedAt: link.updatedAt, + lastActivityAt: link.lastActivityAt, + status: link.status, + lastSeenTurnId: link.lastSeenTurnId, + lastFinalizedAssistantId: link.lastFinalizedAssistantId, + lastThreadSnapshotSequence: link.lastThreadSnapshotSequence ?? null, + lastDeliveredSequence: link.lastDeliveredSequence ?? null, + threadTalkMode: link.threadTalkMode, + taskDiscordMessageId: link.taskDiscordMessageId, + streamDiscordMessageIds: link.streamDiscordMessageIds, + sentDiscordUserMessageIds: link.sentDiscordUserMessageIds, + jiraIssueKeys: link.jiraIssueKeys, + prUrls: link.prUrls, + infoDiscordMessageId: link.infoDiscordMessageId, + initialModelLine: link.initialModelLine, + currentModelLine: link.currentModelLine, + modelSinceAt: link.modelSinceAt, + }; +} + +/** Migrate a bare v1 array entry to full v2 shape. */ +export function migrateV1Link(link: { + readonly discordThreadId: string; + readonly t3ThreadId: string; + readonly projectId: string; + readonly channelId: string; + readonly guildId: string; + readonly createdAt: string; + readonly threadTalkMode?: "all-messages" | undefined; + readonly taskDiscordMessageId?: string | undefined; + readonly streamDiscordMessageIds?: ReadonlyArray | undefined; + readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; + readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly prUrls?: ReadonlyArray | undefined; + readonly infoDiscordMessageId?: string | undefined; + readonly initialModelLine?: string | undefined; + readonly currentModelLine?: string | undefined; + readonly modelSinceAt?: string | undefined; +}): ThreadLink { + return asThreadLink({ + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + projectId: link.projectId, + channelId: link.channelId, + guildId: link.guildId, + createdAt: link.createdAt, + updatedAt: link.createdAt, + lastActivityAt: link.createdAt, + status: "active", + lastSeenTurnId: null, + lastFinalizedAssistantId: null, + lastThreadSnapshotSequence: null, + lastDeliveredSequence: null, + threadTalkMode: link.threadTalkMode, + taskDiscordMessageId: link.taskDiscordMessageId, + streamDiscordMessageIds: link.streamDiscordMessageIds, + sentDiscordUserMessageIds: link.sentDiscordUserMessageIds, + jiraIssueKeys: link.jiraIssueKeys, + prUrls: link.prUrls, + infoDiscordMessageId: link.infoDiscordMessageId, + initialModelLine: link.initialModelLine, + currentModelLine: link.currentModelLine, + modelSinceAt: link.modelSinceAt, + }); +} + +export function normalizeThreadLinkInput(link: ThreadLinkInput): ThreadLink { + const createdAt = link.createdAt; + return asThreadLink({ + sourceKind: link.sourceKind, + sourceThreadId: link.sourceThreadId, + discordThreadId: link.discordThreadId, + t3ThreadId: link.t3ThreadId, + projectId: link.projectId, + channelId: link.channelId, + guildId: link.guildId, + createdAt, + updatedAt: link.updatedAt ?? createdAt, + lastActivityAt: link.lastActivityAt ?? createdAt, + status: link.status ?? "active", + lastSeenTurnId: link.lastSeenTurnId ?? null, + lastFinalizedAssistantId: link.lastFinalizedAssistantId ?? null, + lastThreadSnapshotSequence: link.lastThreadSnapshotSequence ?? null, + lastDeliveredSequence: link.lastDeliveredSequence ?? null, + threadTalkMode: link.threadTalkMode, + taskDiscordMessageId: link.taskDiscordMessageId, + streamDiscordMessageIds: link.streamDiscordMessageIds, + sentDiscordUserMessageIds: link.sentDiscordUserMessageIds, + jiraIssueKeys: link.jiraIssueKeys, + prUrls: link.prUrls, + infoDiscordMessageId: link.infoDiscordMessageId, + initialModelLine: link.initialModelLine, + currentModelLine: link.currentModelLine, + modelSinceAt: link.modelSinceAt, + }); +} + +/** + * Parse links.json (v1 bare array or v2 document). Corrupt / unknown → empty list. + * Exported for unit tests. + */ +export function parseLinksDocument(raw: unknown): { + readonly version: typeof LINKS_DOCUMENT_VERSION; + readonly links: ReadonlyArray; + readonly migratedFromV1: boolean; +} { + if (Array.isArray(raw)) { + try { + // Prefer strict v2 array decode (if someone wrote plain array of v2 objects). + try { + const v2Array = decodeLinksArray(raw); + return { + version: LINKS_DOCUMENT_VERSION, + links: v2Array.map((link) => asThreadLink(link)), + migratedFromV1: false, + }; + } catch { + const v1 = decodeLinksV1(raw); + return { + version: LINKS_DOCUMENT_VERSION, + links: v1.map(migrateV1Link), + migratedFromV1: true, + }; + } + } catch { + return { version: LINKS_DOCUMENT_VERSION, links: [], migratedFromV1: false }; + } + } + + if (raw !== null && typeof raw === "object") { + try { + const doc = decodeLinksDocumentV2(raw); + return { + version: LINKS_DOCUMENT_VERSION, + links: doc.links.map((link) => asThreadLink(link)), + migratedFromV1: false, + }; + } catch { + return { version: LINKS_DOCUMENT_VERSION, links: [], migratedFromV1: false }; + } + } + + return { version: LINKS_DOCUMENT_VERSION, links: [], migratedFromV1: false }; +} + +function serializeLinksDocument(links: ReadonlyArray): string { + return `${JSON.stringify( + { + version: LINKS_DOCUMENT_VERSION, + links: [...links], + }, + null, + 2, + )}\n`; +} + +export interface ThreadLinkStoreService { + readonly getByDiscordThreadId: (discordThreadId: string) => Effect.Effect; + readonly getByT3ThreadId: (t3ThreadId: string) => Effect.Effect; + readonly getBySourceThread: ( + sourceKind: "discord" | "teams", + sourceThreadId: string, + ) => Effect.Effect; + readonly put: (link: ThreadLinkInput) => Effect.Effect; + readonly touch: (discordThreadId: string, at?: string) => Effect.Effect; + readonly tombstone: (discordThreadId: string) => Effect.Effect; + readonly updateBridgeHints: ( + discordThreadId: string, + partial: ThreadLinkBridgeHints, + ) => Effect.Effect; + readonly setThreadTalkMode: ( + discordThreadId: string, + mode: "all-messages" | null, + ) => Effect.Effect; + readonly setTaskDiscordMessageId: ( + discordThreadId: string, + taskDiscordMessageId: string | null, + ) => Effect.Effect; + readonly setStreamDiscordMessageIds: ( + discordThreadId: string, + streamDiscordMessageIds: ReadonlyArray, + ) => Effect.Effect; + readonly setSentDiscordUserMessageIds: ( + discordThreadId: string, + sentDiscordUserMessageIds: ReadonlyArray, + ) => Effect.Effect; + /** Merge newly observed Jira keys in first-seen order (no duplicates). */ + readonly appendJiraIssueKeys: ( + discordThreadId: string, + jiraIssueKeys: ReadonlyArray, + ) => Effect.Effect; + readonly setJiraIssueKeys: ( + discordThreadId: string, + jiraIssueKeys: ReadonlyArray, + ) => Effect.Effect; + /** Merge newly observed GitHub PR URLs in first-seen order (no duplicates). */ + readonly appendPrUrls: ( + discordThreadId: string, + prUrls: ReadonlyArray, + ) => Effect.Effect; + readonly setPrUrls: ( + discordThreadId: string, + prUrls: ReadonlyArray, + ) => Effect.Effect; + readonly setInfoDiscordMessageId: ( + discordThreadId: string, + infoDiscordMessageId: string | null, + ) => Effect.Effect; + readonly setModelHistory: ( + discordThreadId: string, + history: ThreadLinkModelHistory, + ) => Effect.Effect; + readonly list: () => Effect.Effect>; +} + +export class ThreadLinkStore extends Context.Service()( + "@t3tools/discord-bot/store/ThreadLinkStore", +) {} + +function expandHome(path: string): string { + if (path === "~") return NodeOS.homedir(); + if (path.startsWith("~/") || path.startsWith("~\\")) { + return NodePath.join(NodeOS.homedir(), path.slice(2)); + } + return path; +} + +async function atomicWriteFile(filePath: string, contents: string): Promise { + const dir = NodePath.dirname(filePath); + const tempPath = NodePath.join( + dir, + `.${NodePath.basename(filePath)}.${process.pid}.${Date.now()}.tmp`, + ); + await NodeFSP.writeFile(tempPath, contents, { mode: 0o600 }); + await NodeFSP.rename(tempPath, filePath); +} + +export const makeThreadLinkStore = (dataDirRaw: string) => + Effect.gen(function* () { + const dataDir = expandHome(dataDirRaw); + const filePath = NodePath.join(dataDir, "links.json"); + yield* Effect.promise(() => NodeFSP.mkdir(dataDir, { recursive: true, mode: 0o700 })); + + // Missing/unreadable file is fine (first run). Effect async failures are not JS throwables, + // so recover with orElseSucceed rather than try/catch around yield*. + const loaded = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(filePath, "utf8"), + catch: () => "unreadable" as const, + }).pipe( + Effect.map((raw) => { + try { + return parseLinksDocument(JSON.parse(raw) as unknown); + } catch { + return parseLinksDocument(null); + } + }), + Effect.orElseSucceed(() => parseLinksDocument(null)), + ); + + const state = yield* Ref.make( + new Map(loaded.links.map((link) => [link.discordThreadId, link] as const)), + ); + + // Persist migrated v1 → v2 so the next boot does not re-migrate. + if (loaded.migratedFromV1 && loaded.links.length > 0) { + yield* Effect.promise(() => atomicWriteFile(filePath, serializeLinksDocument(loaded.links))); + } + + const persist = (map: Map) => + Effect.promise(() => atomicWriteFile(filePath, serializeLinksDocument([...map.values()]))); + + // Serialize mutate+persist so concurrent hint writers cannot flush a stale map + // over a newer in-memory state (stream ids vs sequence marker, etc.). + const writeLock = yield* Semaphore.make(1); + + const updateLink = ( + discordThreadId: string, + mutate: (current: ThreadLink) => ThreadLink, + ): Effect.Effect => + writeLock.withPermit( + Effect.gen(function* () { + let updated: ThreadLink | null = null; + const next = yield* Ref.updateAndGet(state, (map) => { + const current = map.get(discordThreadId); + if (current === undefined) return map; + updated = mutate(current); + const copy = new Map(map); + copy.set(discordThreadId, updated); + return copy; + }); + if (updated === null) return null; + yield* persist(next); + return updated; + }), + ); + + return ThreadLinkStore.of({ + getByDiscordThreadId: (discordThreadId) => + Ref.get(state).pipe(Effect.map((map) => map.get(discordThreadId) ?? null)), + + getByT3ThreadId: (t3ThreadId) => + Ref.get(state).pipe( + Effect.map((map) => { + for (const link of map.values()) { + if (link.t3ThreadId === t3ThreadId) return link; + } + return null; + }), + ), + + getBySourceThread: (sourceKind, sourceThreadId) => + Ref.get(state).pipe( + Effect.map((map) => { + for (const link of map.values()) { + const linkSourceKind = link.sourceKind ?? "discord"; + const linkSourceThreadId = link.sourceThreadId ?? link.discordThreadId; + if (linkSourceKind === sourceKind && linkSourceThreadId === sourceThreadId) { + return link; + } + } + return null; + }), + ), + + put: (link) => + writeLock.withPermit( + Effect.gen(function* () { + const normalized = normalizeThreadLinkInput(link); + const next = yield* Ref.updateAndGet(state, (map) => { + const existing = map.get(normalized.discordThreadId); + // Preserve durable bridge hints when callers re-put a minimal link. + const merged = + existing === undefined + ? normalized + : asThreadLink({ + ...normalized, + // Never wipe durable hints on a minimal re-put (overlapping writers). + sourceKind: + link.sourceKind !== undefined ? normalized.sourceKind : existing.sourceKind, + sourceThreadId: + link.sourceThreadId !== undefined + ? normalized.sourceThreadId + : existing.sourceThreadId, + lastFinalizedAssistantId: + link.lastFinalizedAssistantId !== undefined + ? normalized.lastFinalizedAssistantId + : existing.lastFinalizedAssistantId, + lastSeenTurnId: + link.lastSeenTurnId !== undefined + ? normalized.lastSeenTurnId + : existing.lastSeenTurnId, + lastThreadSnapshotSequence: + link.lastThreadSnapshotSequence !== undefined + ? normalized.lastThreadSnapshotSequence + : existing.lastThreadSnapshotSequence, + lastDeliveredSequence: + link.lastDeliveredSequence !== undefined + ? normalized.lastDeliveredSequence + : existing.lastDeliveredSequence, + streamDiscordMessageIds: + link.streamDiscordMessageIds !== undefined + ? normalized.streamDiscordMessageIds + : existing.streamDiscordMessageIds, + taskDiscordMessageId: + link.taskDiscordMessageId !== undefined + ? normalized.taskDiscordMessageId + : existing.taskDiscordMessageId, + threadTalkMode: + link.threadTalkMode !== undefined + ? normalized.threadTalkMode + : existing.threadTalkMode, + sentDiscordUserMessageIds: + link.sentDiscordUserMessageIds !== undefined + ? normalized.sentDiscordUserMessageIds + : existing.sentDiscordUserMessageIds, + jiraIssueKeys: + link.jiraIssueKeys !== undefined + ? normalized.jiraIssueKeys + : existing.jiraIssueKeys, + prUrls: link.prUrls !== undefined ? normalized.prUrls : existing.prUrls, + infoDiscordMessageId: + link.infoDiscordMessageId !== undefined + ? normalized.infoDiscordMessageId + : existing.infoDiscordMessageId, + initialModelLine: + link.initialModelLine !== undefined + ? normalized.initialModelLine + : existing.initialModelLine, + currentModelLine: + link.currentModelLine !== undefined + ? normalized.currentModelLine + : existing.currentModelLine, + modelSinceAt: + link.modelSinceAt !== undefined + ? normalized.modelSinceAt + : existing.modelSinceAt, + lastActivityAt: normalized.lastActivityAt, + updatedAt: nowIso(), + }); + const copy = new Map(map); + copy.set(merged.discordThreadId, merged); + return copy; + }); + yield* persist(next); + }), + ), + + touch: (discordThreadId, at) => { + const when = at ?? nowIso(); + return updateLink(discordThreadId, (current) => ({ + ...current, + lastActivityAt: when, + updatedAt: when, + })); + }, + + tombstone: (discordThreadId) => { + const when = nowIso(); + return updateLink(discordThreadId, (current) => ({ + ...current, + status: "tombstone", + updatedAt: when, + })); + }, + + updateBridgeHints: (discordThreadId, partial) => { + const when = nowIso(); + return updateLink(discordThreadId, (current) => ({ + ...current, + updatedAt: when, + lastActivityAt: when, + ...(partial.lastSeenTurnId !== undefined + ? { lastSeenTurnId: partial.lastSeenTurnId } + : {}), + ...(partial.lastFinalizedAssistantId !== undefined + ? { lastFinalizedAssistantId: partial.lastFinalizedAssistantId } + : {}), + ...(partial.lastThreadSnapshotSequence !== undefined + ? { lastThreadSnapshotSequence: partial.lastThreadSnapshotSequence } + : {}), + ...(partial.lastDeliveredSequence !== undefined + ? { lastDeliveredSequence: partial.lastDeliveredSequence } + : {}), + ...(partial.streamDiscordMessageIds !== undefined + ? { + streamDiscordMessageIds: + partial.streamDiscordMessageIds.length > 0 + ? [...new Set(partial.streamDiscordMessageIds.filter((id) => id.trim() !== ""))] + : undefined, + } + : {}), + })); + }, + + setThreadTalkMode: (discordThreadId, mode) => + updateLink(discordThreadId, (existing) => ({ + ...existing, + threadTalkMode: mode ?? undefined, + updatedAt: nowIso(), + })).pipe(Effect.asVoid), + + setTaskDiscordMessageId: (discordThreadId, taskDiscordMessageId) => + updateLink(discordThreadId, (existing) => ({ + ...existing, + taskDiscordMessageId: taskDiscordMessageId ?? undefined, + updatedAt: nowIso(), + })).pipe(Effect.asVoid), + + setStreamDiscordMessageIds: (discordThreadId, streamDiscordMessageIds) => { + const normalizedIds = [ + ...new Set(streamDiscordMessageIds.filter((id) => id.trim() !== "")), + ]; + return updateLink(discordThreadId, (existing) => ({ + ...existing, + streamDiscordMessageIds: normalizedIds.length > 0 ? normalizedIds : undefined, + updatedAt: nowIso(), + lastActivityAt: nowIso(), + })).pipe(Effect.asVoid); + }, + + setSentDiscordUserMessageIds: (discordThreadId, sentDiscordUserMessageIds) => { + const normalizedIds = [ + ...new Set(sentDiscordUserMessageIds.filter((id) => id.trim() !== "")), + ]; + return updateLink(discordThreadId, (existing) => ({ + ...existing, + sentDiscordUserMessageIds: normalizedIds.length > 0 ? normalizedIds : undefined, + updatedAt: nowIso(), + })).pipe(Effect.asVoid); + }, + + appendJiraIssueKeys: (discordThreadId, jiraIssueKeys) => + updateLink(discordThreadId, (existing) => { + const merged = mergeJiraKeysOrdered(existing.jiraIssueKeys, jiraIssueKeys); + if ( + merged.length === (existing.jiraIssueKeys?.length ?? 0) && + merged.every((key, index) => key === existing.jiraIssueKeys?.[index]) + ) { + return existing; + } + return { + ...existing, + jiraIssueKeys: merged.length > 0 ? merged : undefined, + updatedAt: nowIso(), + }; + }), + + setJiraIssueKeys: (discordThreadId, jiraIssueKeys) => + updateLink(discordThreadId, (existing) => { + const merged = mergeJiraKeysOrdered([], jiraIssueKeys); + return { + ...existing, + jiraIssueKeys: merged.length > 0 ? merged : undefined, + updatedAt: nowIso(), + }; + }), + + appendPrUrls: (discordThreadId, prUrls) => + updateLink(discordThreadId, (existing) => { + const merged = mergePrUrlsOrdered(existing.prUrls, prUrls); + if ( + merged.length === (existing.prUrls?.length ?? 0) && + merged.every((url, index) => url === existing.prUrls?.[index]) + ) { + return existing; + } + return { + ...existing, + prUrls: merged.length > 0 ? merged : undefined, + updatedAt: nowIso(), + }; + }), + + setPrUrls: (discordThreadId, prUrls) => + updateLink(discordThreadId, (existing) => { + const merged = mergePrUrlsOrdered([], prUrls); + return { + ...existing, + prUrls: merged.length > 0 ? merged : undefined, + updatedAt: nowIso(), + }; + }), + + setInfoDiscordMessageId: (discordThreadId, infoDiscordMessageId) => + updateLink(discordThreadId, (existing) => ({ + ...existing, + infoDiscordMessageId: infoDiscordMessageId ?? undefined, + updatedAt: nowIso(), + })).pipe(Effect.asVoid), + + setModelHistory: (discordThreadId, history) => + updateLink(discordThreadId, (existing) => ({ + ...existing, + initialModelLine: + history.initialModelLine === undefined + ? existing.initialModelLine + : (history.initialModelLine ?? undefined), + currentModelLine: + history.currentModelLine === undefined + ? existing.currentModelLine + : (history.currentModelLine ?? undefined), + modelSinceAt: + history.modelSinceAt === undefined + ? existing.modelSinceAt + : (history.modelSinceAt ?? undefined), + updatedAt: nowIso(), + })), + + list: () => Ref.get(state).pipe(Effect.map((map) => [...map.values()])), + }); + }); + +export const layer = (dataDir: string) => + Layer.effect(ThreadLinkStore, makeThreadLinkStore(dataDir)); diff --git a/apps/discord-bot/src/store/ThreadWarmCacheStore.test.ts b/apps/discord-bot/src/store/ThreadWarmCacheStore.test.ts new file mode 100644 index 00000000000..e481e335e7d --- /dev/null +++ b/apps/discord-bot/src/store/ThreadWarmCacheStore.test.ts @@ -0,0 +1,94 @@ +// @effect-diagnostics nodeBuiltinImport:off +/* oxlint-disable t3code/no-manual-effect-runtime-in-tests -- Legacy filesystem fixture uses a manually scoped runtime. */ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Effect from "effect/Effect"; +import { describe, expect, it as vitestIt } from "vite-plus/test"; + +import { + canResumeFromWarmThreadCache, + makeThreadWarmCacheStore, + parseWarmThreadCacheDocument, +} from "./ThreadWarmCacheStore.ts"; + +const sampleThread = { + id: "thread-1", + projectId: "project-1", + title: "Warm cache", + modelSelection: { instanceId: "grok", model: "grok-4.5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-07-21T00:00:00.000Z", + updatedAt: "2026-07-21T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [ + { + id: "a1", + role: "assistant", + text: "hello", + turnId: null, + streaming: false, + createdAt: "2026-07-21T00:00:00.000Z", + updatedAt: "2026-07-21T00:00:00.000Z", + }, + ], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, +}; + +describe("parseWarmThreadCacheDocument / canResumeFromWarmThreadCache", () => { + vitestIt("parses a valid document", () => { + const entry = parseWarmThreadCacheDocument({ + version: 1, + threadId: "thread-1", + snapshotSequence: 42, + lastFinalizedAssistantId: "a1", + updatedAt: "2026-07-21T00:00:00.000Z", + thread: sampleThread, + }); + expect(entry?.snapshotSequence).toBe(42); + expect(entry?.thread.messages).toHaveLength(1); + expect(canResumeFromWarmThreadCache(entry)).toBe(true); + }); + + vitestIt("rejects corrupt payloads", () => { + expect(parseWarmThreadCacheDocument(null)).toBeNull(); + expect(parseWarmThreadCacheDocument({ version: 1, threadId: "x" })).toBeNull(); + expect(canResumeFromWarmThreadCache(null)).toBe(false); + }); +}); + +describe("makeThreadWarmCacheStore", () => { + vitestIt("round-trips save / load / remove", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bot-warm-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const store = yield* makeThreadWarmCacheStore(dir); + expect(yield* store.load("thread-1")).toBeNull(); + yield* store.save({ + threadId: "thread-1", + snapshotSequence: 99, + thread: sampleThread as never, + lastFinalizedAssistantId: "a1", + }); + const loaded = yield* store.load("thread-1"); + expect(loaded?.snapshotSequence).toBe(99); + expect(loaded?.lastFinalizedAssistantId).toBe("a1"); + expect(loaded?.thread.messages[0]?.id).toBe("a1"); + yield* store.remove("thread-1"); + expect(yield* store.load("thread-1")).toBeNull(); + }) as Effect.Effect, + ); + } finally { + await NodeFSP.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/discord-bot/src/store/ThreadWarmCacheStore.ts b/apps/discord-bot/src/store/ThreadWarmCacheStore.ts new file mode 100644 index 00000000000..a0c92b800e6 --- /dev/null +++ b/apps/discord-bot/src/store/ThreadWarmCacheStore.ts @@ -0,0 +1,165 @@ +// @effect-diagnostics globalErrorInEffectCatch:off globalErrorInEffectFailure:off preferSchemaOverJson:off tryCatchInEffectGen:off missingEffectError:off nodeBuiltinImport:off globalDate:off globalRandom:off globalDateInEffect:off +/** + * Durable trimmed warm base for Discord bridge resume. + * + * Mirrors web/desktop EnvironmentCacheStore thread snapshots: keep a reduced + * OrchestrationThread + snapshotSequence on disk so restart can + * `subscribeThread({ afterSequence })` without re-downloading the full tip over HTTP. + * + * Storage: `$dataDir/thread-cache/.json` (atomic write), same dataDir as links.json. + */ +import type { OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +export const THREAD_WARM_CACHE_VERSION = 1 as const; + +export const WarmThreadCacheDocument = Schema.Struct({ + version: Schema.Literal(THREAD_WARM_CACHE_VERSION), + threadId: Schema.String, + snapshotSequence: Schema.Number, + lastFinalizedAssistantId: Schema.NullOr(Schema.String), + /** ISO timestamp of last successful write. */ + updatedAt: Schema.String, + /** + * Full OrchestrationThread JSON. Intentionally not Schema-validated field-by-field + * (contracts evolve); structural checks happen in parseWarmThreadCacheDocument. + */ + thread: Schema.Unknown, +}); +export type WarmThreadCacheDocument = typeof WarmThreadCacheDocument.Type; + +export type WarmThreadCacheEntry = { + readonly threadId: ThreadId; + readonly snapshotSequence: number; + readonly lastFinalizedAssistantId: string | null; + readonly updatedAt: string; + readonly thread: OrchestrationThread; +}; + +const decodeDocument = Schema.decodeUnknownSync(WarmThreadCacheDocument); + +function expandHome(path: string): string { + if (path === "~") return NodeOS.homedir(); + if (path.startsWith("~/")) return NodePath.join(NodeOS.homedir(), path.slice(2)); + return path; +} + +function safeThreadFileName(threadId: string): string { + // Thread ids are UUIDs / opaque tokens; strip path separators just in case. + return `${threadId.replaceAll(/[/\\]/gu, "_")}.json`; +} + +export function parseWarmThreadCacheDocument(raw: unknown): WarmThreadCacheEntry | null { + try { + const doc = decodeDocument(raw); + const thread = doc.thread as OrchestrationThread | null; + if (thread === null || typeof thread !== "object") return null; + if (typeof (thread as { id?: unknown }).id !== "string") return null; + if (!Array.isArray((thread as { messages?: unknown }).messages)) return null; + if (!Number.isFinite(doc.snapshotSequence) || doc.snapshotSequence < 0) return null; + return { + threadId: doc.threadId as ThreadId, + snapshotSequence: doc.snapshotSequence, + lastFinalizedAssistantId: doc.lastFinalizedAssistantId, + updatedAt: doc.updatedAt, + thread, + }; + } catch { + return null; + } +} + +/** + * Whether a warm cache entry can seed subscribe without HTTP. + * Requires a finite sequence (same bar as dual-cursor afterSequence). + */ +export function canResumeFromWarmThreadCache(entry: WarmThreadCacheEntry | null): boolean { + return entry !== null && Number.isFinite(entry.snapshotSequence) && entry.snapshotSequence >= 0; +} + +async function atomicWriteFile(filePath: string, contents: string): Promise { + const dir = NodePath.dirname(filePath); + await NodeFSP.mkdir(dir, { recursive: true, mode: 0o700 }); + const tempPath = NodePath.join( + dir, + `.${NodePath.basename(filePath)}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`, + ); + await NodeFSP.writeFile(tempPath, contents, { mode: 0o600 }); + await NodeFSP.rename(tempPath, filePath); +} + +export interface ThreadWarmCacheStoreService { + readonly load: (threadId: ThreadId | string) => Effect.Effect; + readonly save: (input: { + readonly threadId: ThreadId | string; + readonly snapshotSequence: number; + readonly thread: OrchestrationThread; + readonly lastFinalizedAssistantId?: string | null; + }) => Effect.Effect; + readonly remove: (threadId: ThreadId | string) => Effect.Effect; +} + +export class ThreadWarmCacheStore extends Context.Service< + ThreadWarmCacheStore, + ThreadWarmCacheStoreService +>()("@t3tools/discord-bot/store/ThreadWarmCacheStore") {} + +export const makeThreadWarmCacheStore = (dataDirRaw: string) => + Effect.gen(function* () { + const dataDir = expandHome(dataDirRaw); + const cacheDir = NodePath.join(dataDir, "thread-cache"); + yield* Effect.promise(() => NodeFSP.mkdir(cacheDir, { recursive: true, mode: 0o700 })); + const writeLock = yield* Semaphore.make(1); + + const pathFor = (threadId: string) => NodePath.join(cacheDir, safeThreadFileName(threadId)); + + return ThreadWarmCacheStore.of({ + load: (threadId) => + Effect.tryPromise({ + try: async () => { + const raw = await NodeFSP.readFile(pathFor(String(threadId)), "utf8"); + return parseWarmThreadCacheDocument(JSON.parse(raw) as unknown); + }, + catch: () => null as WarmThreadCacheEntry | null, + }).pipe(Effect.orElseSucceed(() => null)), + + save: (input) => + writeLock.withPermit( + Effect.gen(function* () { + if (!Number.isFinite(input.snapshotSequence) || input.snapshotSequence < 0) { + return; + } + const doc: WarmThreadCacheDocument = { + version: THREAD_WARM_CACHE_VERSION, + threadId: String(input.threadId), + snapshotSequence: input.snapshotSequence, + lastFinalizedAssistantId: input.lastFinalizedAssistantId ?? null, + updatedAt: new Date().toISOString(), + thread: input.thread, + }; + const body = `${JSON.stringify(doc)}\n`; + yield* Effect.promise(() => atomicWriteFile(pathFor(String(input.threadId)), body)); + }), + ), + + remove: (threadId) => + Effect.tryPromise({ + try: () => NodeFSP.unlink(pathFor(String(threadId))), + catch: () => undefined, + }).pipe( + Effect.asVoid, + Effect.orElseSucceed(() => undefined), + Effect.asVoid, + ), + }); + }); + +export const layer = (dataDir: string) => + Layer.effect(ThreadWarmCacheStore, makeThreadWarmCacheStore(dataDir)); diff --git a/apps/discord-bot/src/t3/DiscordThreadFollower.test.ts b/apps/discord-bot/src/t3/DiscordThreadFollower.test.ts new file mode 100644 index 00000000000..e4134f5efa0 --- /dev/null +++ b/apps/discord-bot/src/t3/DiscordThreadFollower.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { OrchestrationThread, OrchestrationThreadStreamItem } from "@t3tools/contracts"; + +import { + applyDiscordThreadStreamItem, + initialDiscordThreadFollowerState, + planThreadFollowerReconnectSeed, +} from "./DiscordThreadFollower.ts"; + +const baseThread = (overrides?: Partial): OrchestrationThread => + ({ + id: "thread-1", + projectId: "project-1", + title: "t", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + latestTurn: null, + session: null, + worktreePath: null, + modelSelection: null, + ...overrides, + }) as OrchestrationThread; + +describe("planThreadFollowerReconnectSeed", () => { + it("replays warm tip only on the first seed this process", () => { + expect(planThreadFollowerReconnectSeed({ lastAppliedSequence: -1, hasWarmSeed: true })).toBe( + "replay-warm", + ); + expect(planThreadFollowerReconnectSeed({ lastAppliedSequence: -1, hasWarmSeed: false })).toBe( + "http-or-cold", + ); + }); + + it("resumes after disconnect without replaying warm tip (no old finals at tip)", () => { + // Production: SocketClose re-ran deliver(warmSeed) with a stale in-memory tip and + // re-finalized Done.PR #156 after newer Discord turns. + expect( + planThreadFollowerReconnectSeed({ lastAppliedSequence: 104286, hasWarmSeed: true }), + ).toBe("resume-after"); + expect(planThreadFollowerReconnectSeed({ lastAppliedSequence: 0, hasWarmSeed: false })).toBe( + "resume-after", + ); + }); +}); + +describe("applyDiscordThreadStreamItem (client-runtime parity)", () => { + it("applies embedded snapshots and advances sequence", () => { + const thread = baseThread({ title: "from-snapshot" }); + const item: OrchestrationThreadStreamItem = { + kind: "snapshot", + snapshot: { snapshotSequence: 10, thread }, + }; + const result = applyDiscordThreadStreamItem(initialDiscordThreadFollowerState(), item); + expect(result._tag).toBe("deliver"); + if (result._tag === "deliver") { + expect(result.sequence).toBe(10); + expect(result.thread.title).toBe("from-snapshot"); + expect(result.state.lastSequence).toBe(10); + } + }); + + it("drops duplicate / older sequences", () => { + const thread = baseThread(); + const state = initialDiscordThreadFollowerState({ + current: thread, + lastSequence: 5, + }); + const item: OrchestrationThreadStreamItem = { + kind: "event", + event: { + type: "thread.title-updated", + sequence: 5, + threadId: "thread-1", + title: "nope", + } as OrchestrationThreadStreamItem extends { kind: "event"; event: infer E } ? E : never, + }; + const result = applyDiscordThreadStreamItem(state, item); + expect(result._tag).toBe("none"); + expect(result.state.lastSequence).toBe(5); + }); + + it("requests reload when an event arrives without a transcript", () => { + const item: OrchestrationThreadStreamItem = { + kind: "event", + event: { + type: "thread.title-updated", + sequence: 1, + threadId: "thread-1", + title: "x", + } as never, + }; + const result = applyDiscordThreadStreamItem(initialDiscordThreadFollowerState(), item); + expect(result._tag).toBe("reload-required"); + }); + + it("applies thread.deleted via client-runtime reducer", () => { + const state = initialDiscordThreadFollowerState({ + current: baseThread(), + lastSequence: 1, + }); + const item: OrchestrationThreadStreamItem = { + kind: "event", + event: { + type: "thread.deleted", + sequence: 2, + occurredAt: "2026-04-01T02:00:00.000Z", + aggregateKind: "thread", + aggregateId: "thread-1", + payload: { + threadId: "thread-1", + deletedAt: "2026-04-01T02:00:00.000Z", + }, + } as never, + }; + const result = applyDiscordThreadStreamItem(state, item); + expect(result._tag).toBe("deleted"); + if (result._tag === "deleted") { + expect(result.state.current).toBeNull(); + expect(result.state.lastSequence).toBe(2); + } + }); +}); diff --git a/apps/discord-bot/src/t3/DiscordThreadFollower.ts b/apps/discord-bot/src/t3/DiscordThreadFollower.ts new file mode 100644 index 00000000000..f236a0f1bf0 --- /dev/null +++ b/apps/discord-bot/src/t3/DiscordThreadFollower.ts @@ -0,0 +1,390 @@ +// @effect-diagnostics anyUnknownInErrorContext:off missingEffectError:off +/** + * Headless orchestration-thread follower for the Discord bot. + * + * Intentionally mirrors packages/client-runtime EnvironmentThreadState apply/reload + * semantics (sequence cursor, snapshot seed, reload-required → HTTP) without pulling + * in EnvironmentSupervisor / Atom / React. Core packages stay untouched; Discord only + * adapts the same reducer + transport patterns clients already use. + * + * Source of truth for event application: `applyThreadDetailEvent` from + * `@t3tools/client-runtime/state/threads` (re-export of threadReducer). + * + * Discord-specific projection (ResponseBridge tips / finalize) sits *on top* of this + * follower — same split as web: runtime holds OrchestrationThread, UI/surface paints it. + */ + +import type { + OrchestrationThread, + OrchestrationThreadDetailSnapshot, + OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { applyThreadDetailEvent } from "@t3tools/client-runtime/state/threads"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; + +import { formatAlertCause } from "../features/Alerts.ts"; + +/** Same short retry clients use for expected stream failures (`retryExpectedFailureAfter`). */ +export const DISCORD_THREAD_SUBSCRIBE_RETRY_DELAY = "250 millis" as const; + +/** Cap reconnect backoff when the whole subscription dies (session drop, etc.). */ +export const DISCORD_THREAD_SUBSCRIBE_MAX_BACKOFF = "30 seconds" as const; + +/** + * How to seed the follower after a transport death. + * + * - `resume-after` — we already applied a tip this process; only WS after lastSequence + * (do **not** re-deliver a stale in-memory warm tip — that re-finalized old Discord posts). + * - `replay-warm` — first seed this process; paint durable warm tip once. + * - `http-or-cold` — no warm tip; HTTP snapshot or wait for stream. + */ +export type ThreadFollowerReconnectSeedPlan = "resume-after" | "replay-warm" | "http-or-cold"; + +export function planThreadFollowerReconnectSeed(input: { + /** Last sequence successfully applied this process (−1 = never). */ + readonly lastAppliedSequence: number; + readonly hasWarmSeed: boolean; +}): ThreadFollowerReconnectSeedPlan { + if (Number.isFinite(input.lastAppliedSequence) && input.lastAppliedSequence >= 0) { + return "resume-after"; + } + if (input.hasWarmSeed) return "replay-warm"; + return "http-or-cold"; +} + +export type DiscordThreadFollowerState = { + readonly current: OrchestrationThread | null; + /** Last applied snapshot/event sequence (−1 = no base yet). */ + readonly lastSequence: number; +}; + +export type DiscordThreadFollowerApplyResult = + | { + readonly _tag: "deliver"; + readonly state: DiscordThreadFollowerState; + readonly thread: OrchestrationThread; + readonly sequence: number; + } + | { + readonly _tag: "none"; + readonly state: DiscordThreadFollowerState; + } + | { + readonly _tag: "deleted"; + readonly state: DiscordThreadFollowerState; + readonly sequence: number; + } + | { + readonly _tag: "reload-required"; + readonly state: DiscordThreadFollowerState; + readonly sequence: number; + readonly eventType: string; + }; + +export function initialDiscordThreadFollowerState( + seed?: Partial, +): DiscordThreadFollowerState { + return { + current: seed?.current ?? null, + lastSequence: seed?.lastSequence ?? -1, + }; +} + +/** + * Pure apply step — parity with EnvironmentThreadState.applyItem + * (packages/client-runtime/src/state/threads.ts). + */ +export function applyDiscordThreadStreamItem( + state: DiscordThreadFollowerState, + item: OrchestrationThreadStreamItem, +): DiscordThreadFollowerApplyResult { + if (item.kind === "snapshot") { + const sequence = item.snapshot.snapshotSequence; + const thread = item.snapshot.thread; + return { + _tag: "deliver", + state: { current: thread, lastSequence: sequence }, + thread, + sequence, + }; + } + + if (item.kind === "synchronized") { + return { _tag: "none", state }; + } + + const sequence = item.event.sequence; + if (sequence <= state.lastSequence) { + return { _tag: "none", state }; + } + + if (state.current === null) { + // Event before we hold a transcript — caller must HTTP-seed (same as client). + return { + _tag: "reload-required", + state: { ...state, lastSequence: sequence }, + sequence, + eventType: item.event.type, + }; + } + + const result = applyThreadDetailEvent(state.current, item.event); + if (result.kind === "updated") { + return { + _tag: "deliver", + state: { current: result.thread, lastSequence: sequence }, + thread: result.thread, + sequence, + }; + } + if (result.kind === "deleted") { + return { + _tag: "deleted", + state: { current: null, lastSequence: sequence }, + sequence, + }; + } + if (result.kind === "reload-required") { + return { + _tag: "reload-required", + state: { ...state, lastSequence: sequence }, + sequence, + eventType: item.event.type, + }; + } + // unchanged — advance sequence so we do not re-apply + return { + _tag: "none", + state: { ...state, lastSequence: sequence }, + }; +} + +export type FollowOrchestrationThreadInput = { + readonly threadId: string; + /** + * Open a WS subscribeThread stream. May end with failure (transport drop); + * the follower retries. + */ + readonly openStream: (input: { + readonly afterSequence: number | undefined; + }) => Stream.Stream; + /** HTTP full snapshot — used for resume seed + reload-required when warm seed missing. */ + readonly fetchSnapshot: () => Effect.Effect; + readonly onThread: (thread: OrchestrationThread) => Effect.Effect; + /** + * Durable cursor. With no warmSeed: HTTP-seed then WS afterSequence (cold/HTTP path). + * With warmSeed: ignored for seed base; warmSeed.snapshotSequence drives afterSequence. + */ + readonly afterSequence?: number | null; + /** + * Durable trimmed tip (web/desktop EnvironmentCacheStore-style). When set, skips HTTP + * full-tip download and resumes via afterSequence from this base. + */ + readonly warmSeed?: { + readonly snapshotSequence: number; + readonly thread: OrchestrationThread; + } | null; + /** + * Optional projection applied before onThread and retained as the apply base + * (e.g. drop Discord-finalized messages beyond a small buffer). + */ + readonly projectThread?: (thread: OrchestrationThread) => OrchestrationThread; + readonly onSequence?: (sequence: number) => Effect.Effect; + /** + * When true (default), reconnect forever with backoff — matches client durable + * subscription intent. Set false for tests / one-shot. + */ + readonly retryForever?: boolean; +}; + +/** + * Follow a thread the way other T3 clients do: seed snapshot, apply events in order, + * reload on reload-required, retry the subscription on transport death. + */ +export function followOrchestrationThread( + input: FollowOrchestrationThreadInput, +): Effect.Effect { + const retryForever = input.retryForever !== false; + const noteSequence = (sequence: number) => + input.onSequence === undefined + ? Effect.void + : input.onSequence(sequence).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to persist thread sequence marker", { + threadId: input.threadId, + sequence, + cause: formatAlertCause(cause, 300), + }), + ), + ); + + const projectThread = input.projectThread; + const warmSeed = input.warmSeed ?? null; + const hasWarmSeed = + warmSeed !== null && + Number.isFinite(warmSeed.snapshotSequence) && + warmSeed.snapshotSequence >= 0; + + const deliver = (thread: OrchestrationThread, sequence: number) => + Effect.gen(function* () { + // Project first so the retained apply base matches what Discord keeps in memory. + const projected = projectThread !== undefined ? projectThread(thread) : thread; + yield* noteSequence(sequence); + yield* input.onThread(projected).pipe( + Effect.catchCause((cause) => + Effect.logError("Thread follower onThread failed", { + threadId: input.threadId, + cause: formatAlertCause(cause), + }), + ), + ); + return projected; + }); + + // Retained across SocketClose retries so we never re-paint Discord from a stale + // in-memory warmSeed after the tip has already been applied this process. + let resumeState: DiscordThreadFollowerState | null = null; + + const runOnce = Effect.gen(function* () { + const seedPlan = planThreadFollowerReconnectSeed({ + lastAppliedSequence: resumeState?.lastSequence ?? -1, + hasWarmSeed, + }); + + let state = + seedPlan === "resume-after" && resumeState !== null + ? resumeState + : initialDiscordThreadFollowerState(); + let subscribeAfter: number | undefined = + seedPlan === "resume-after" && resumeState !== null && resumeState.lastSequence >= 0 + ? resumeState.lastSequence + : input.afterSequence !== undefined && + input.afterSequence !== null && + Number.isFinite(input.afterSequence) && + input.afterSequence >= 0 + ? input.afterSequence + : undefined; + + if (seedPlan === "replay-warm" && warmSeed !== null) { + // First seed this process only — durable tip + afterSequence, no HTTP. + const projected = yield* deliver(warmSeed.thread, warmSeed.snapshotSequence); + state = { + current: projected, + lastSequence: warmSeed.snapshotSequence, + }; + resumeState = state; + subscribeAfter = warmSeed.snapshotSequence; + yield* Effect.logInfo("Thread follower seeding from durable warm cache", { + threadId: input.threadId, + snapshotSequence: warmSeed.snapshotSequence, + messageCount: projected.messages.length, + }); + } else if (seedPlan === "resume-after") { + yield* Effect.logInfo("Thread follower resuming after disconnect (skip warm re-seed)", { + threadId: input.threadId, + afterSequence: subscribeAfter ?? null, + lastSequence: resumeState?.lastSequence ?? null, + }); + } else if (seedPlan === "http-or-cold" && subscribeAfter !== undefined) { + // HTTP base snapshot, then events after that sequence. + const seed = yield* input.fetchSnapshot(); + if (seed !== null) { + const projected = yield* deliver(seed.thread, seed.snapshotSequence); + state = { + current: projected, + lastSequence: seed.snapshotSequence, + }; + resumeState = state; + subscribeAfter = seed.snapshotSequence; + } else { + subscribeAfter = undefined; + state = initialDiscordThreadFollowerState(); + } + } + + yield* input.openStream({ afterSequence: subscribeAfter }).pipe( + Stream.runForEach((item) => + Effect.gen(function* () { + let applied = applyDiscordThreadStreamItem(state, item); + + if (applied._tag === "reload-required") { + yield* Effect.logWarning( + "Thread event requires snapshot reload; fetching HTTP thread snapshot", + { + threadId: input.threadId, + eventType: applied.eventType, + sequence: applied.sequence, + }, + ); + const fresh = yield* input.fetchSnapshot(); + if (fresh === null) { + // Keep last known current; skip corrupt apply (client leaves state in place). + state = applied.state; + resumeState = state; + return; + } + const sequence = Math.max(fresh.snapshotSequence, applied.sequence); + const projected = yield* deliver(fresh.thread, sequence); + state = { + current: projected, + lastSequence: sequence, + }; + resumeState = state; + return; + } + + if (applied._tag === "deliver") { + const projected = yield* deliver(applied.thread, applied.sequence); + state = { + current: projected, + lastSequence: applied.sequence, + }; + resumeState = state; + } else if (applied._tag === "deleted") { + state = applied.state; + resumeState = state; + yield* noteSequence(applied.sequence); + } else { + state = applied.state; + resumeState = state; + } + }), + ), + ); + }); + + if (!retryForever) { + // One-shot / test path. The retry branch below inspects failures via + // `Effect.result` and loops; the one-shot path has no retry loop, so surface a + // terminal stream failure as a defect rather than swallowing it. + return runOnce.pipe(Effect.orDie); + } + + return Effect.gen(function* () { + let attempt = 0; + while (true) { + attempt += 1; + const outcome = yield* runOnce.pipe(Effect.result); + if (Result.isSuccess(outcome)) { + return; + } + const pretty = formatAlertCause(outcome.failure); + yield* Effect.logError("Orchestration thread subscription ended; resubscribing", { + threadId: input.threadId, + attempt, + cause: pretty, + resumeAfterSequence: resumeState?.lastSequence ?? null, + seedPlan: planThreadFollowerReconnectSeed({ + lastAppliedSequence: resumeState?.lastSequence ?? -1, + hasWarmSeed, + }), + }); + // Do not re-deliver warm/HTTP tips between attempts when we already applied a + // sequence — runOnce will resume WS only. Cold path still HTTP-seeds on next runOnce. + const delayMs = Math.min(30_000, 250 * 2 ** Math.min(attempt - 1, 7)); + yield* Effect.sleep(`${delayMs} millis`); + } + }); +} diff --git a/apps/discord-bot/src/t3/T3Session.test.ts b/apps/discord-bot/src/t3/T3Session.test.ts new file mode 100644 index 00000000000..31d23f5ca90 --- /dev/null +++ b/apps/discord-bot/src/t3/T3Session.test.ts @@ -0,0 +1,69 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isT3TransportError, + shouldPersistThreadModelSelectionForNextTurn, + T3_STILL_CONNECTING_MESSAGE, +} from "./T3Session.ts"; + +describe("isT3TransportError", () => { + it("matches SocketCloseError and ConnectionTransientError", () => { + expect(isT3TransportError(new Error("SocketCloseError: 1005"))).toBe(true); + expect(isT3TransportError(new Error("ConnectionTransientError: closed"))).toBe(true); + expect(isT3TransportError(new Error("SocketError: reset"))).toBe(true); + }); + + it("matches common network errno strings", () => { + expect(isT3TransportError(new Error("connect ECONNREFUSED 127.0.0.1:3773"))).toBe(true); + expect(isT3TransportError(new Error("read ECONNRESET"))).toBe(true); + }); + + it("does not match ordinary application errors", () => { + expect(isT3TransportError(new Error("No T3 project registered at /tmp/x"))).toBe(false); + expect(isT3TransportError(new Error(T3_STILL_CONNECTING_MESSAGE))).toBe(false); + }); +}); + +describe("shouldPersistThreadModelSelectionForNextTurn", () => { + it("returns false when no explicit model selection is provided", () => { + expect( + shouldPersistThreadModelSelectionForNextTurn({ + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + }), + ).toBe(false); + }); + + it("returns true when the model changes", () => { + expect( + shouldPersistThreadModelSelectionForNextTurn({ + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.5", + }, + }), + ).toBe(true); + }); + + it("returns false when the model selection is unchanged", () => { + expect( + shouldPersistThreadModelSelectionForNextTurn({ + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.5", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.5", + }, + }), + ).toBe(false); + }); +}); diff --git a/apps/discord-bot/src/t3/T3Session.ts b/apps/discord-bot/src/t3/T3Session.ts new file mode 100644 index 00000000000..12939ca5685 --- /dev/null +++ b/apps/discord-bot/src/t3/T3Session.ts @@ -0,0 +1,1070 @@ +// @effect-diagnostics globalDate:off globalFetch:off globalFetchInEffect:off globalTimers:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off anyUnknownInErrorContext:off missingEffectContext:off missingEffectError:off preferSchemaOverJson:off tryCatchInEffectGen:off deterministicKeys:off +import { + ApprovalRequestId, + DEFAULT_RUNTIME_MODE, + EnvironmentId, + ORCHESTRATION_WS_METHODS, + PRIMARY_LOCAL_ENVIRONMENT_ID, + ProjectId, + WS_METHODS, + type ClientOrchestrationCommand, + type MessageId, + type ModelSelection, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadShell, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderUserInputAnswers, + type RuntimeMode, + type ServerConfig, + type ServerProvider, + type ThreadId, + type UploadChatAttachment, + type VcsResolveBranchChangeRequestResult, + type VcsStatusStreamEvent, +} from "@t3tools/contracts"; +import { type DiscordClientSourceHint, withTurnSourceHint } from "./sourceHint.ts"; +import { + PrimaryConnectionTarget, + type PreparedConnection, +} from "@t3tools/client-runtime/connection"; +import { + remoteHttpClientLayer, + RpcSessionFactory, + rpcSessionFactoryLayer, + type RpcSession, +} from "@t3tools/client-runtime/rpc"; +import { + bootstrapRemoteBearerSession, + resolveRemoteWebSocketConnectionUrl, +} from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { applyShellStreamEvent } from "@t3tools/client-runtime/state/shell"; +import { appendOmegentT3ProductHandshake } from "@t3tools/shared/productFamily"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Socket from "effect/unstable/socket/Socket"; + +import type { DiscordBotConfig } from "../config.ts"; +import { preferredModelSelection } from "../config.ts"; +import { BrowserAutomationHost } from "../browser/BrowserAutomationHost.ts"; +import { formatThreadTitle } from "../presentation/messages.ts"; +import { normalizeWorkspacePath } from "../presentation/mentions.ts"; +import { followOrchestrationThread } from "./DiscordThreadFollower.ts"; +import { newCommandId, newMessageId, newThreadId, shortId } from "./ids.ts"; + +function wsBaseUrl(httpBaseUrl: string): string { + const url = new URL(httpBaseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function localSocketUrl(httpBaseUrl: string): string { + const url = new URL(wsBaseUrl(httpBaseUrl)); + url.pathname = "/ws"; + url.search = ""; + url.hash = ""; + return appendOmegentT3ProductHandshake(url.toString()); +} + +function messageFromCause(cause: unknown): string { + if (cause instanceof Error && cause.message.trim() !== "") return cause.message; + return String(cause); +} + +/** + * Detect dead/transient socket failures that should force a reconnect when + * `RpcSession.closed` did not fire (or fired too late). Used for soft recovery + * on dispatch errors — we do **not** auto-retry the command (double-start risk). + */ +export function isT3TransportError(cause: unknown): boolean { + const msg = messageFromCause(cause); + if (/SocketCloseError|ConnectionTransientError|SocketError/i.test(msg)) return true; + if (/ECONNREFUSED|ECONNRESET|ENOTFOUND|EPIPE|ETIMEDOUT/i.test(msg)) return true; + if (/websocket/i.test(msg) && /close|closed|reset|refused|not connected/i.test(msg)) return true; + return false; +} + +/** User-facing copy when the bot is online but T3 is still (re)connecting. */ +export const T3_STILL_CONNECTING_MESSAGE = + "T3 is still connecting after a server restart (or first boot). Try again in a few seconds."; + +export function shouldPersistThreadModelSelectionForNextTurn(input: { + readonly currentModelSelection?: ModelSelection; + readonly nextModelSelection?: ModelSelection; +}): boolean { + const next = input.nextModelSelection; + if (next === undefined) return false; + const current = input.currentModelSelection; + if (current === undefined) return true; + return ( + next.model !== current.model || + next.instanceId !== current.instanceId || + JSON.stringify(next.options ?? null) !== JSON.stringify(current.options ?? null) + ); +} + +export class T3SessionError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "T3SessionError"; + } +} + +export interface T3SessionService { + readonly connect: () => Effect.Effect; + /** + * Keep retrying {@link connect} with the same backoff as mid-life reconnect + * until the shell snapshot is live. Used at boot so the process does not exit + * when Discord comes up before T3 (guest restart race). + */ + readonly connectUntilReady: () => Effect.Effect; + /** + * Register a callback invoked after a successful automatic reconnect + * (socket drop → reconnectLoop). Used to rehydrate Discord bridges. + */ + readonly setOnReconnected: (handler: (() => Promise) | null) => void; + /** + * True when a live RpcSession exists (may still be waiting for shell on a + * race; prefer {@link isReady} before project lookups). + */ + readonly isConnected: () => Effect.Effect; + /** True when connected and the orchestration shell snapshot has arrived. */ + readonly isReady: () => Effect.Effect; + readonly shell: () => Effect.Effect; + readonly serverConfig: () => Effect.Effect; + readonly findProjectByWorkspaceRoot: ( + workspaceRoot: string, + ) => Effect.Effect; + readonly getProjectShell: ( + projectId: ProjectId, + ) => Effect.Effect; + readonly startTurnWithWorktree: (input: { + readonly project: OrchestrationProjectShell; + readonly prompt: string; + readonly titleSeed?: string; + readonly modelSelection: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly baseBranch: string; + readonly local: boolean; + /** User images from Discord (same shape as web composer uploads). */ + readonly attachments?: ReadonlyArray; + /** + * Discord human who triggered the turn. Server identity map resolves this + * into personId/username when the identity overlay is composed. + */ + readonly sourceHint?: DiscordClientSourceHint; + }) => Effect.Effect<{ readonly threadId: ThreadId; readonly messageId: string }, T3SessionError>; + readonly startTurn: (input: { + readonly threadId: ThreadId; + readonly prompt: string; + readonly messageId?: MessageId; + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly attachments?: ReadonlyArray; + readonly sourceHint?: DiscordClientSourceHint; + }) => Effect.Effect<{ readonly messageId: string }, T3SessionError>; + /** + * Inject a server-queued follow-up into the active turn (or start a turn if + * idle). Used after `startTurn` queues a mid-turn Discord message so the bot + * keeps historical steer-by-default behavior. + */ + readonly steerQueuedMessage: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect; + readonly removeQueuedMessage: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect; + /** + * Subscribe to thread events until disconnect/interrupt. + * Runs `onThread` in the caller fiber context (must not be forked onto a bare T3 runtime). + * + * @param options.afterSequence - when set, only events after this sequence are streamed + * (no embedded snapshot unless warmSeed is missing). Prefer warm seed or HTTP tip first. + * @param options.onSequence - durable marker callback after each applied snapshot/event. + * @param options.warmSeed - durable trimmed tip (web/desktop-style cache). When set with a + * finite sequence, skips HTTP full-tip fetch and resumes via afterSequence from that base. + * @param options.projectThread - optional projection before onThread / retained apply base + * (e.g. drop Discord-finalized history beyond a small buffer). + */ + readonly subscribeThread: ( + threadId: ThreadId, + onThread: (thread: OrchestrationThread) => Effect.Effect, + options?: { + readonly afterSequence?: number; + readonly onSequence?: (sequence: number) => Effect.Effect; + readonly warmSeed?: { + readonly snapshotSequence: number; + readonly thread: OrchestrationThread; + } | null; + readonly projectThread?: (thread: OrchestrationThread) => OrchestrationThread; + }, + ) => Effect.Effect; + readonly respondToApproval: ( + threadId: ThreadId, + requestId: string, + decision: ProviderApprovalDecision, + ) => Effect.Effect; + readonly respondToUserInput: ( + threadId: ThreadId, + requestId: string, + answers: ProviderUserInputAnswers, + ) => Effect.Effect; + readonly interrupt: (threadId: ThreadId) => Effect.Effect; + readonly compact: (threadId: ThreadId) => Effect.Effect; + readonly resolveModelSelection: (input: { + readonly project?: OrchestrationProjectShell | null; + readonly stickyModelSelection?: ModelSelection | null; + readonly overrideInstanceId?: string; + readonly overrideModel?: string; + }) => Effect.Effect; + readonly getThreadShell: (threadId: ThreadId) => Effect.Effect; + /** + * HTTP snapshot of a thread (full transcript + sequence). Used by Discord bridges to + * reconcile when the WS event stream stalls mid-turn so Discord is not stuck on Working.. + * while the T3 client still shows progress. + */ + readonly fetchThreadDetail: ( + threadId: ThreadId, + ) => Effect.Effect; + readonly resolveBranchChangeRequest: (input: { + readonly cwd: string; + readonly refName: string; + }) => Effect.Effect; + readonly subscribeVcsStatus: ( + cwd: string, + onStatus: (event: VcsStatusStreamEvent) => Effect.Effect, + ) => Effect.Effect; + readonly refreshVcsStatus: (cwd: string) => Effect.Effect; + /** + * Resolve a signed absolute HTTP URL for a chat attachment (image) stored on the T3 server. + * Used so Discord can download and re-upload the file as a message attachment. + */ + readonly createAttachmentUrl: (attachmentId: string) => Effect.Effect; + /** + * Resolve a signed absolute HTTP URL for a workspace (or absolute host) file via assets.createUrl. + * Used for Codex `generated_images` paths that appear as markdown embeds, when local disk + * is not readable from the bot process. + */ + readonly createWorkspaceFileUrl: (input: { + readonly threadId: ThreadId; + readonly path: string; + }) => Effect.Effect; +} + +export class T3Session extends Context.Service()( + "@t3tools/discord-bot/t3/T3Session", +) {} + +/** + * Same backoff as EnvironmentSupervisor (client-runtime connection/supervisor.ts), so + * the bot behaves like the web and mobile clients rather than inventing its own policy. + */ +const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; + +/** How long to wait for the first shell snapshot after the WS is up (ms poll × attempts). */ +const SHELL_SNAPSHOT_POLL_MS = 100; +const SHELL_SNAPSHOT_MAX_ATTEMPTS = 150; // 15s — guest T3 can lag Discord on restart + +/** Brief wait for shell when a mention races reconnect completion. */ +const PROJECT_LOOKUP_SHELL_WAIT_ATTEMPTS = 30; // 3s + +export const makeT3Session = (botConfig: DiscordBotConfig) => + Effect.sync(() => { + const runtime = ManagedRuntime.make( + Layer.merge( + rpcSessionFactoryLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), + remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), + ), + ); + + let scope: Scope.Closeable | null = null; + let session: RpcSession | null = null; + let httpBaseUrl: string | null = null; + /** Bearer used for HTTP snapshot reloads (same token as WS connect). */ + let httpBearerToken: string | null = null; + let shell: OrchestrationShellSnapshot | null = null; + let serverConfig: ServerConfig | null = null; + let shellFiber: Fiber.Fiber | null = null; + let browserFiber: Fiber.Fiber | null = null; + let browserHost: BrowserAutomationHost | null = null; + let reconnecting = false; + let onReconnected: (() => Promise) | null = null; + const threadFibers = new Map>(); + + const requireSession = (): RpcSession => { + if (session === null) throw new T3SessionError(T3_STILL_CONNECTING_MESSAGE); + return session; + }; + + /** + * Tear the connection down so the next connect() rebuilds it. + * + * Without this the bot keeps a dead socket forever: connectPrepared() early-returns + * while `session` is non-null, so every dispatch after a server restart failed with + * `SocketCloseError: 1005` until the bot was manually restarted. + */ + const teardown = async () => { + const previousScope = scope; + const previousFibers = [shellFiber, browserFiber, ...threadFibers.values()]; + const previousBrowserHost = browserHost; + scope = null; + session = null; + httpBaseUrl = null; + httpBearerToken = null; + shell = null; + serverConfig = null; + shellFiber = null; + browserFiber = null; + browserHost = null; + // Drop thread subscriptions so subscribeThread() re-subscribes on the new session + // instead of holding fibers that will never emit again. + threadFibers.clear(); + + // These are forked on the ManagedRuntime, not on the connection scope, so closing + // the scope does NOT stop them -- they would sit on a dead socket forever. + // Interrupt explicitly, matching subscribeThread's existing replace-fiber pattern. + for (const fiber of previousFibers) { + if (fiber !== null) { + await runtime.runPromise(Fiber.interrupt(fiber)).catch(() => {}); + } + } + if (previousScope !== null) { + await runtime.runPromise(Scope.close(previousScope, Exit.void)).catch(() => {}); + } + if (previousBrowserHost !== null) { + await previousBrowserHost.close().catch(() => {}); + } + }; + + /** + * When the socket is dead but `closed` never fired (or dispatch saw the failure + * first), tear down and enter reconnectLoop. Does not re-run the failed command. + */ + const scheduleReconnectFromTransportError = (cause: unknown) => { + if (reconnecting) return; + void (async () => { + await runtime + .runPromise( + Effect.logWarning(`T3 transport error; forcing reconnect: ${messageFromCause(cause)}`), + ) + .catch(() => {}); + await teardown(); + await reconnectLoop(); + })(); + }; + + /** + * Reconnect with backoff, mirroring EnvironmentSupervisor's schedule + * (packages/client-runtime/src/connection/supervisor.ts) which web/mobile already + * use. Retries indefinitely: a server restart is routine here, and the bot has + * nothing useful to do while disconnected. + */ + const reconnectLoop = async () => { + if (reconnecting) return; + reconnecting = true; + try { + for (let attempt = 0; ; attempt += 1) { + const delay = + RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)] ?? 16_000; + await runtime.runPromise(Effect.sleep(Duration.millis(delay))).catch(() => {}); + try { + await runtime.runPromise(connect()); + await runtime.runPromise(Effect.logInfo("Reconnected to T3")); + const handler = onReconnected; + if (handler !== null) { + try { + await handler(); + } catch (cause) { + await runtime + .runPromise( + Effect.logError(`T3 reconnect rehydrate failed: ${messageFromCause(cause)}`), + ) + .catch(() => {}); + } + } + return; + } catch (cause) { + await runtime + .runPromise( + Effect.logWarning( + `T3 reconnect attempt ${attempt + 1} failed: ${messageFromCause(cause)}`, + ), + ) + .catch(() => {}); + } + } + } finally { + reconnecting = false; + } + }; + + /** + * `RpcSession.closed` fails with ConnectionTransientError when the socket drops. + * Watching it means we notice a server restart immediately, instead of discovering + * it on the next user-visible dispatch and reporting a raw socket error to Discord. + */ + // Not stored: this fiber ends when `closed` fires, and it is the thing that calls + // teardown() -- holding a handle would only tempt us into interrupting it from + // inside itself. + const superviseClose = (connected: RpcSession) => { + runtime.runFork( + connected.closed.pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + // Another fiber may already have torn down and started reconnect + // (e.g. soft recovery from a transport error on dispatch). + if (session !== connected) return; + yield* Effect.logWarning(`T3 connection lost: ${messageFromCause(cause)}`); + yield* Effect.promise(() => teardown()); + yield* Effect.promise(() => reconnectLoop()); + }), + ), + Effect.asVoid, + ), + ); + }; + + const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); + + const dispatch = (command: ClientOrchestrationCommand) => + Effect.tryPromise({ + try: () => + runtime.runPromise( + requireSession().client[ORCHESTRATION_WS_METHODS.dispatchCommand](command), + ), + catch: (cause) => { + if (isT3TransportError(cause)) { + scheduleReconnectFromTransportError(cause); + } + return new T3SessionError(`dispatch failed: ${messageFromCause(cause)}`, { cause }); + }, + }).pipe(Effect.asVoid); + + const claimBrowserHost = (threadId: ThreadId) => + Effect.gen(function* () { + const claim = browserHost?.claim(threadId); + if (claim === null || claim === undefined) return; + yield* requireSession().client[WS_METHODS.previewAutomationFocusHost](claim); + yield* Effect.logInfo("Claimed Discord browser host for thread", { threadId }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not claim Discord browser host for thread", { + threadId, + cause, + }), + ), + ); + + // Parameter must not be named `httpBaseUrl` — that shadows the outer + // connection-state binding, so `httpBaseUrl = normalizedBaseUrl` would only + // mutate the parameter and leave outer state null forever. steernow and + // bridge HTTP reseed then always see "snapshot unavailable". + const connectPrepared = (baseUrl: string, bearerToken?: string) => + Effect.tryPromise({ + try: async () => { + const normalizedBaseUrl = new URL(baseUrl).toString(); + // Half-open: session without shell must never short-circuit (stuck forever). + if (session !== null && shell !== null) return; + if (session !== null && shell === null) { + await teardown(); + } + + let environmentId = EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID); + let socketUrl = localSocketUrl(normalizedBaseUrl); + let label = "T3 Code"; + if (bearerToken !== undefined && bearerToken !== "") { + const descriptor = await runtime.runPromise( + fetchRemoteEnvironmentDescriptor({ httpBaseUrl: normalizedBaseUrl }), + ); + socketUrl = await runtime.runPromise( + resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: wsBaseUrl(normalizedBaseUrl), + httpBaseUrl: normalizedBaseUrl, + bearerToken, + }), + ); + label = descriptor.label; + environmentId = descriptor.environmentId; + } + + const target = new PrimaryConnectionTarget({ + environmentId, + label, + httpBaseUrl: normalizedBaseUrl, + wsBaseUrl: wsBaseUrl(normalizedBaseUrl), + }); + const prepared: PreparedConnection = { + environmentId, + label, + httpBaseUrl: normalizedBaseUrl, + socketUrl, + httpAuthorization: + bearerToken === undefined || bearerToken === "" + ? null + : { _tag: "Bearer", token: bearerToken }, + target, + }; + + const nextScope = await runtime.runPromise(Scope.make()); + try { + const connected = await runtime.runPromise( + Effect.gen(function* () { + const factory = yield* RpcSessionFactory; + const result = yield* factory.connect(prepared); + yield* result.ready; + return result; + }).pipe(Scope.provide(nextScope)), + ); + scope = nextScope; + session = connected; + httpBaseUrl = normalizedBaseUrl; + httpBearerToken = bearerToken !== undefined && bearerToken !== "" ? bearerToken : null; + serverConfig = await runtime.runPromise(connected.initialConfig); + superviseClose(connected); + + const stream = connected.client[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe( + Stream.runForEach((item) => + Effect.sync(() => { + if (item.kind === "snapshot") shell = item.snapshot; + else if (item.kind === "synchronized") { + // Status-only marker (parity with EnvironmentShellState.applyItem); + // no shell snapshot mutation in the headless bridge. + } else if (shell !== null) shell = applyShellStreamEvent(shell, item); + }), + ), + ); + shellFiber = runtime.runFork(stream); + + if (botConfig.browserEnabled) { + try { + const host = await BrowserAutomationHost.launch(botConfig, environmentId); + browserHost = host; + browserFiber = runtime.runFork( + connected.client[WS_METHODS.previewAutomationConnect](host.registration()).pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + const startedAt = yield* Clock.currentTimeMillis; + const response = yield* Effect.tryPromise({ + try: () => host.consume(event), + catch: (cause) => + new T3SessionError( + `Browser operation failed: ${messageFromCause(cause)}`, + { cause }, + ), + }); + if (event.type === "request") { + yield* Effect.logInfo("Discord browser operation completed", { + operation: event.request.operation, + requestId: event.request.requestId, + tabId: event.request.tabId ?? null, + elapsedMs: (yield* Clock.currentTimeMillis) - startedAt, + ok: response?.ok ?? false, + errorTag: response?.error?._tag ?? null, + }); + } + return response; + }).pipe( + Effect.flatMap((response) => + response === null + ? Effect.void + : connected.client[WS_METHODS.previewAutomationRespond](response), + ), + ), + ), + Effect.catchCause((cause) => + Effect.sync(() => { + if (browserHost === host) browserHost = null; + }).pipe( + Effect.andThen(Effect.promise(() => host.close())), + Effect.ignore, + Effect.andThen( + Effect.logError( + `Discord browser automation host stopped: ${messageFromCause(cause)}`, + ), + ), + ), + ), + Effect.asVoid, + ), + ); + await runtime.runPromise( + Effect.logInfo("Discord browser automation host active", { + profile: botConfig.browserProfile, + }), + ); + } catch (cause) { + await runtime.runPromise( + Effect.logError( + `Discord browser automation unavailable: ${messageFromCause(cause)}`, + ), + ); + } + } + + // `shell` is updated by the concurrently running subscription fiber. + for (let attempt = 0; attempt < SHELL_SNAPSHOT_MAX_ATTEMPTS; attempt += 1) { + // eslint-disable-next-line no-unmodified-loop-condition -- shell is set by shellFiber + if (shell !== null) break; + await Effect.runPromise(Effect.sleep(Duration.millis(SHELL_SNAPSHOT_POLL_MS))); + } + if (shell === null) { + throw new T3SessionError( + "Connected to T3 but the orchestration shell snapshot did not arrive in time", + ); + } + } catch (cause) { + // Full teardown is required: a partial connect leaves session non-null and + // the next connectPrepared early-returns forever (stuck bot after restart). + await teardown(); + // nextScope may not have been published to `scope` yet (fail before assign). + await runtime.runPromise(Scope.close(nextScope, Exit.void)).catch(() => {}); + throw cause instanceof T3SessionError + ? cause + : new T3SessionError(`Could not connect to T3 Code: ${messageFromCause(cause)}`, { + cause, + }); + } + }, + catch: (cause) => + cause instanceof T3SessionError + ? cause + : new T3SessionError(messageFromCause(cause), { cause }), + }); + + const connect = () => + Effect.gen(function* () { + if (botConfig.t3BearerToken !== undefined && botConfig.t3BearerToken !== "") { + yield* connectPrepared(botConfig.t3HttpBaseUrl, botConfig.t3BearerToken); + return; + } + if ( + botConfig.t3BootstrapCredential !== undefined && + botConfig.t3BootstrapCredential !== "" + ) { + const tokenSession = yield* Effect.tryPromise({ + try: () => + runtime.runPromise( + bootstrapRemoteBearerSession({ + httpBaseUrl: botConfig.t3HttpBaseUrl, + credential: botConfig.t3BootstrapCredential!, + clientMetadata: { label: "T3 Discord Bot", deviceType: "bot" }, + }), + ), + catch: (cause) => + new T3SessionError(`Bootstrap failed: ${messageFromCause(cause)}`, { cause }), + }); + yield* connectPrepared(botConfig.t3HttpBaseUrl, tokenSession.access_token); + return; + } + yield* connectPrepared(botConfig.t3HttpBaseUrl); + }); + + /** + * Boot path: Discord may start before guest T3 is listening. Retry forever + * with the same schedule as mid-life reconnect instead of exiting the process. + */ + const connectUntilReady = () => + Effect.gen(function* () { + for (let attempt = 0; ; attempt += 1) { + const outcome = yield* Effect.result(connect()); + if (Result.isSuccess(outcome)) return; + const delay = + RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)] ?? 16_000; + yield* Effect.logWarning( + `T3 boot connect attempt ${attempt + 1} failed: ${messageFromCause(outcome.failure)}; retrying in ${delay}ms`, + ); + yield* Effect.sleep(Duration.millis(delay)); + } + }); + + const providersForSelection = (): ReadonlyArray => + serverConfig?.providers ?? []; + + const fetchThreadDetailHttp = (threadId: ThreadId) => + Effect.tryPromise({ + try: async (): Promise => { + const base = httpBaseUrl; + if (base === null) return null; + const url = new URL( + `/api/orchestration/threads/${encodeURIComponent(threadId)}`, + base, + ).toString(); + const headers: Record = { Accept: "application/json" }; + if (httpBearerToken !== null) { + headers.Authorization = `Bearer ${httpBearerToken}`; + } + const response = await globalThis.fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} loading thread snapshot`); + } + return (await response.json()) as OrchestrationThreadDetailSnapshot; + }, + catch: (cause) => + new T3SessionError(`Thread snapshot fetch failed: ${messageFromCause(cause)}`, { + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not fetch thread snapshot over HTTP", { + threadId, + error: String(error), + }).pipe(Effect.as(null)), + ), + ); + + return T3Session.of({ + connect, + connectUntilReady, + setOnReconnected: (handler) => { + onReconnected = handler; + }, + isConnected: () => Effect.sync(() => session !== null), + isReady: () => Effect.sync(() => session !== null && shell !== null), + shell: () => Effect.succeed(shell), + serverConfig: () => Effect.succeed(serverConfig), + findProjectByWorkspaceRoot: (workspaceRoot) => + Effect.gen(function* () { + // Mentions can land while shell is still filling after reconnect. + for (let attempt = 0; attempt < PROJECT_LOOKUP_SHELL_WAIT_ATTEMPTS; attempt += 1) { + // shell/session are mutated by connect/teardown/shellFiber, not this loop body. + if (shell !== null || session === null) break; + yield* Effect.sleep(Duration.millis(SHELL_SNAPSHOT_POLL_MS)); + } + if (shell === null) return null; + const target = normalizeWorkspacePath(workspaceRoot); + return ( + shell.projects.find( + (project) => normalizeWorkspacePath(project.workspaceRoot) === target, + ) ?? null + ); + }), + getProjectShell: (projectId) => + Effect.sync(() => shell?.projects.find((project) => project.id === projectId) ?? null), + resolveModelSelection: ({ + project, + stickyModelSelection, + overrideInstanceId, + overrideModel, + }) => + Effect.sync(() => + preferredModelSelection({ + config: botConfig, + providers: providersForSelection(), + projectDefault: project?.defaultModelSelection ?? null, + ...(stickyModelSelection === undefined ? {} : { stickyModelSelection }), + ...(overrideInstanceId === undefined ? {} : { overrideInstanceId }), + ...(overrideModel === undefined ? {} : { overrideModel }), + }), + ), + getThreadShell: (threadId) => + Effect.sync(() => shell?.threads.find((thread) => thread.id === threadId) ?? null), + fetchThreadDetail: (threadId) => fetchThreadDetailHttp(threadId), + resolveBranchChangeRequest: (input) => + Effect.tryPromise({ + try: () => + runtime.runPromise( + requireSession().client[WS_METHODS.vcsResolveBranchChangeRequest](input), + ), + catch: (cause) => + new T3SessionError( + `Could not resolve branch change request for ${input.refName}: ${messageFromCause(cause)}`, + { cause }, + ), + }), + startTurnWithWorktree: (input) => + Effect.gen(function* () { + const threadId = newThreadId(); + const messageId = newMessageId(); + const createdAt = nowIso(); + const title = formatThreadTitle(input.titleSeed ?? input.prompt, 72, "Discord thread"); + const interactionMode = input.interactionMode ?? "default"; + const runtimeMode = input.runtimeMode ?? botConfig.t3DefaultRuntimeMode; + const worktreeBranch = `t3-discord/${shortId()}`; + + yield* claimBrowserHost(threadId); + const createTurn = { + type: "thread.turn.start" as const, + commandId: newCommandId(), + threadId, + message: { + messageId, + role: "user" as const, + text: input.prompt, + attachments: (input.attachments ?? []) as ReadonlyArray, + }, + modelSelection: input.modelSelection, + titleSeed: title, + runtimeMode, + interactionMode, + bootstrap: { + createThread: { + projectId: input.project.id, + title, + modelSelection: input.modelSelection, + runtimeMode, + interactionMode, + branch: null, + worktreePath: null, + createdAt, + }, + ...(input.local + ? {} + : { + prepareWorktree: { + projectCwd: input.project.workspaceRoot, + baseBranch: input.baseBranch, + branch: worktreeBranch, + startFromOrigin: true, + }, + runSetupScript: true, + }), + }, + createdAt, + }; + yield* dispatch(withTurnSourceHint(createTurn, input.sourceHint)); + + return { threadId, messageId }; + }), + startTurn: (input) => + Effect.gen(function* () { + const thread = shell?.threads.find((entry) => entry.id === input.threadId); + const messageId = input.messageId ?? newMessageId(); + // Sticky model on continue: never re-apply bot defaults (codex/gpt-5.4). + // Explicit overrides come only from Discord --provider/--model flags. + // modelSelection is optional on thread.turn.start; omit when unknown so the + // server keeps the thread's existing selection (Grok refuses mid-thread switches). + const modelSelection = input.modelSelection ?? thread?.modelSelection; + yield* claimBrowserHost(input.threadId); + if ( + shouldPersistThreadModelSelectionForNextTurn({ + ...(thread?.modelSelection === undefined + ? {} + : { currentModelSelection: thread.modelSelection }), + ...(modelSelection === undefined ? {} : { nextModelSelection: modelSelection }), + }) + ) { + yield* dispatch({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: input.threadId, + modelSelection, + }); + } + const continueTurn = { + type: "thread.turn.start" as const, + commandId: newCommandId(), + threadId: input.threadId, + message: { + messageId, + role: "user" as const, + text: input.prompt, + attachments: (input.attachments ?? []) as ReadonlyArray, + }, + ...(modelSelection === undefined ? {} : { modelSelection }), + titleSeed: input.prompt.trim().slice(0, 80) || "Continue", + runtimeMode: input.runtimeMode ?? thread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: input.interactionMode ?? thread?.interactionMode ?? "default", + createdAt: nowIso(), + }; + yield* dispatch(withTurnSourceHint(continueTurn, input.sourceHint)); + return { messageId }; + }), + steerQueuedMessage: (input) => + dispatch({ + type: "thread.queue.steer", + commandId: newCommandId(), + threadId: input.threadId, + messageId: input.messageId, + createdAt: nowIso(), + }), + removeQueuedMessage: (input) => + dispatch({ + type: "thread.queue.remove", + commandId: newCommandId(), + threadId: input.threadId, + messageId: input.messageId, + createdAt: nowIso(), + }), + /** + * Subscribe to thread events and run `onThread` in the **caller's** Effect context. + * + * Must NOT use `runtime.runFork` — that runs on the T3 ManagedRuntime without + * DiscordREST, so every Discord post fails silently ("nothing happens"). + * Run the stream with `yield*` so the Discord bridge fiber provides services. + * + * Client-aligned via {@link followOrchestrationThread} (same apply/reload/retry + * semantics as EnvironmentThreadState; no core package edits). + */ + subscribeThread: (threadId, onThread, options) => + Effect.gen(function* () { + const active = requireSession(); + yield* followOrchestrationThread({ + threadId, + afterSequence: options?.afterSequence ?? null, + ...(options?.onSequence !== undefined ? { onSequence: options.onSequence } : {}), + warmSeed: options?.warmSeed ?? null, + ...(options?.projectThread !== undefined + ? { projectThread: options.projectThread } + : {}), + onThread, + fetchSnapshot: () => fetchThreadDetailHttp(threadId), + openStream: ({ afterSequence }) => + active.client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId, + ...(afterSequence !== undefined ? { afterSequence } : {}), + }).pipe( + Stream.mapError( + (cause) => + new T3SessionError(`Thread subscription failed: ${messageFromCause(cause)}`, { + cause, + }), + ), + ), + retryForever: true, + }); + }), + subscribeVcsStatus: (cwd, onStatus) => + Effect.gen(function* () { + const active = requireSession(); + + yield* active.client[WS_METHODS.subscribeVcsStatus]({ + cwd, + }).pipe( + Stream.runForEach((event) => + onStatus(event).pipe( + Effect.catchCause((cause) => + Effect.logError("Thread bridge onVcsStatus failed", { cwd, cause }), + ), + ), + ), + Effect.mapError( + (cause) => + new T3SessionError(`VCS status subscription failed: ${messageFromCause(cause)}`, { + cause, + }), + ), + ); + }), + refreshVcsStatus: (cwd) => + Effect.tryPromise({ + try: () => + runtime.runPromise( + requireSession().client[WS_METHODS.vcsRefreshStatus]({ + cwd, + }), + ), + catch: (cause) => + new T3SessionError( + `Could not refresh VCS status for ${cwd}: ${messageFromCause(cause)}`, + { + cause, + }, + ), + }).pipe(Effect.asVoid), + respondToApproval: (threadId, requestId, decision) => + dispatch({ + type: "thread.approval.respond", + commandId: newCommandId(), + threadId, + requestId: ApprovalRequestId.make(requestId), + decision, + createdAt: nowIso(), + }), + respondToUserInput: (threadId, requestId, answers) => + dispatch({ + type: "thread.user-input.respond", + commandId: newCommandId(), + threadId, + requestId: ApprovalRequestId.make(requestId), + answers, + createdAt: nowIso(), + }), + interrupt: (threadId) => + Effect.gen(function* () { + const threadShell = shell?.threads.find((entry) => entry.id === threadId); + yield* dispatch({ + type: "thread.turn.interrupt", + commandId: newCommandId(), + threadId, + ...(threadShell?.latestTurn?.turnId === undefined + ? {} + : { turnId: threadShell.latestTurn.turnId }), + createdAt: nowIso(), + }); + }), + compact: (threadId) => + dispatch({ + type: "thread.context.compact", + commandId: newCommandId(), + threadId, + createdAt: nowIso(), + }), + createAttachmentUrl: (attachmentId) => + Effect.tryPromise({ + try: async () => { + const active = requireSession(); + const base = httpBaseUrl; + if (base === null) { + throw new T3SessionError("T3 Code is not connected."); + } + const result = await runtime.runPromise( + active.client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId }, + }), + ); + return new URL(result.relativeUrl, base).toString(); + }, + catch: (cause) => + cause instanceof T3SessionError + ? cause + : new T3SessionError( + `Could not create attachment URL for ${attachmentId}: ${messageFromCause(cause)}`, + { cause }, + ), + }), + createWorkspaceFileUrl: ({ threadId, path }) => + Effect.tryPromise({ + try: async () => { + const active = requireSession(); + const base = httpBaseUrl; + if (base === null) { + throw new T3SessionError("T3 Code is not connected."); + } + const result = await runtime.runPromise( + active.client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "workspace-file", threadId, path }, + }), + ); + return new URL(result.relativeUrl, base).toString(); + }, + catch: (cause) => + cause instanceof T3SessionError + ? cause + : new T3SessionError( + `Could not create workspace file URL for ${path}: ${messageFromCause(cause)}`, + { cause }, + ), + }), + }); + }); + +export const layer = (botConfig: DiscordBotConfig) => + Layer.effect(T3Session, makeT3Session(botConfig)); diff --git a/apps/discord-bot/src/t3/ids.ts b/apps/discord-bot/src/t3/ids.ts new file mode 100644 index 00000000000..68bccc8ef77 --- /dev/null +++ b/apps/discord-bot/src/t3/ids.ts @@ -0,0 +1,18 @@ +import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; + +function randomUuid(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export const newCommandId = (): CommandId => CommandId.make(randomUuid()); +export const newMessageId = (): MessageId => MessageId.make(randomUuid()); +export const newProjectId = (): ProjectId => ProjectId.make(randomUuid()); +export const newThreadId = (): ThreadId => ThreadId.make(randomUuid()); + +export function shortId(length = 8): string { + return randomUuid().replaceAll("-", "").slice(0, length); +} diff --git a/apps/discord-bot/src/t3/sourceHint.test.ts b/apps/discord-bot/src/t3/sourceHint.test.ts new file mode 100644 index 00000000000..331788a5f30 --- /dev/null +++ b/apps/discord-bot/src/t3/sourceHint.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { discordSourceHint, withTurnSourceHint } from "./sourceHint.ts"; + +describe("discordSourceHint", () => { + it("builds a discord hint with actor + location for map resolution", () => { + expect( + discordSourceHint({ + authorId: "95218063095377920", + authorUsername: "patroza", + guildId: "guild-1", + channelId: "channel-1", + discordThreadId: "thread-1", + }), + ).toEqual({ + channel: "discord", + actor: { platformId: "95218063095377920", displayName: "patroza" }, + location: { + guildId: "guild-1", + channelId: "channel-1", + threadId: "thread-1", + }, + }); + }); + + it("always stamps channel discord even when author is missing", () => { + expect(discordSourceHint({})).toEqual({ channel: "discord" }); + }); +}); + +describe("withTurnSourceHint", () => { + it("attaches sourceHint to turn.start without mutating when undefined", () => { + const base = { + type: "thread.turn.start" as const, + commandId: "cmd-1", + }; + expect(withTurnSourceHint(base, undefined)).toBe(base); + expect( + withTurnSourceHint(base, { + channel: "discord", + actor: { platformId: "1" }, + }), + ).toEqual({ + type: "thread.turn.start", + commandId: "cmd-1", + sourceHint: { channel: "discord", actor: { platformId: "1" } }, + }); + }); +}); diff --git a/apps/discord-bot/src/t3/sourceHint.ts b/apps/discord-bot/src/t3/sourceHint.ts new file mode 100644 index 00000000000..53721a07483 --- /dev/null +++ b/apps/discord-bot/src/t3/sourceHint.ts @@ -0,0 +1,65 @@ +/** + * Platform provenance for bot-dispatched turn.start. + * + * Mirrors `@t3tools/contracts` ClientSourceHint so the Discord overlay can + * stamp actors without depending on the identity overlay at compile time. + * When fork/identity is composed, the server resolves platformId via the map + * into personId/username (e.g. patroza@discord). Without a hint, bot sessions + * fall back to bare `{ channel: "bot" }` — no person chip in the sidebar. + */ +export type DiscordClientSourceHint = { + readonly channel: "discord"; + readonly actor?: { + readonly platformId?: string; + readonly displayName?: string; + }; + readonly location?: { + readonly guildId?: string; + readonly channelId?: string; + readonly threadId?: string; + }; +}; + +export function discordSourceHint(input: { + readonly authorId?: string | null | undefined; + readonly authorUsername?: string | null | undefined; + readonly guildId?: string | null | undefined; + readonly channelId?: string | null | undefined; + readonly discordThreadId?: string | null | undefined; +}): DiscordClientSourceHint { + const platformId = input.authorId?.trim() ?? ""; + const displayName = input.authorUsername?.trim() ?? ""; + const guildId = input.guildId?.trim() ?? ""; + const channelId = input.channelId?.trim() ?? ""; + const threadId = input.discordThreadId?.trim() ?? ""; + + return { + channel: "discord", + ...(platformId.length > 0 || displayName.length > 0 + ? { + actor: { + ...(platformId.length > 0 ? { platformId } : {}), + ...(displayName.length > 0 ? { displayName } : {}), + }, + } + : {}), + ...(guildId.length > 0 || channelId.length > 0 || threadId.length > 0 + ? { + location: { + ...(guildId.length > 0 ? { guildId } : {}), + ...(channelId.length > 0 ? { channelId } : {}), + ...(threadId.length > 0 ? { threadId } : {}), + }, + } + : {}), + }; +} + +/** Attach sourceHint for servers that understand it (identity overlay). */ +export function withTurnSourceHint( + command: T, + sourceHint: DiscordClientSourceHint | undefined, +): T { + if (sourceHint === undefined) return command; + return { ...command, sourceHint } as T; +} diff --git a/apps/discord-bot/src/teams-main.ts b/apps/discord-bot/src/teams-main.ts new file mode 100644 index 00000000000..853cbba87fe --- /dev/null +++ b/apps/discord-bot/src/teams-main.ts @@ -0,0 +1,46 @@ +// @effect-diagnostics layerMergeAllWithDependencies:off missingEffectContext:off anyUnknownInErrorContext:off unsafeEffectTypeAssertion:off multipleEffectProvide:off +import { NodeRuntime } from "@effect/platform-node"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; + +import { DiscordBotConfig } from "./config.ts"; +import { runTeamsNativeApp } from "./features/TeamsNativeApp.ts"; +import { layerFromOptionalPath as projectAliasStoreLayer } from "./projectAliases.ts"; +import { layer as threadLinkStoreLayer } from "./store/ThreadLinkStore.ts"; +import { T3Session, layer as t3SessionLayer } from "./t3/T3Session.ts"; + +const TeamsMainLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* DiscordBotConfig; + return Layer.mergeAll( + t3SessionLayer(config), + threadLinkStoreLayer(config.dataDir), + projectAliasStoreLayer(config.projectAliasesPath), + ).pipe( + Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromEnv())), + Layer.provideMerge(Logger.layer([Logger.consolePretty()])), + ); + }), +).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv()))); + +const program = Effect.gen(function* () { + const config = yield* DiscordBotConfig; + if (!config.teamsNativeEnabled) { + return yield* Effect.die( + new Error("TEAMS_NATIVE_ENABLED=1 is required by the Teams-only entrypoint."), + ); + } + const t3 = yield* T3Session; + yield* t3.connectUntilReady(); + yield* runTeamsNativeApp(config); + return yield* Effect.never; +}); + +NodeRuntime.runMain( + program.pipe( + Effect.provide(TeamsMainLayer), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), + ) as Effect.Effect, +); diff --git a/apps/discord-bot/src/teams/attachments.ts b/apps/discord-bot/src/teams/attachments.ts new file mode 100644 index 00000000000..f5d9de9b3a1 --- /dev/null +++ b/apps/discord-bot/src/teams/attachments.ts @@ -0,0 +1,147 @@ +// @effect-diagnostics globalFetch:off +import type { UploadChatAttachment } from "@t3tools/contracts"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; + +import type { DiscordUploadFile } from "../presentation/discordFiles.ts"; +import type { TeamsMessage } from "./presentation.ts"; + +export interface TeamsImageDownloadResult { + readonly discordFiles: ReadonlyArray; + readonly t3Uploads: ReadonlyArray; + readonly skipped: ReadonlyArray<{ + readonly name: string; + readonly reason: string; + }>; +} + +function extensionFromName(name: string): string { + const match = /\.([a-z0-9]+)$/iu.exec(name.trim()); + return match?.[1]?.toLowerCase() ?? ""; +} + +function guessImageMimeType(input: { + readonly name: string; + readonly contentType?: string | undefined; +}): string | null { + const contentType = input.contentType?.trim().toLowerCase(); + if (contentType?.startsWith("image/")) return contentType; + + return ( + { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + svg: "image/svg+xml", + heic: "image/heic", + heif: "image/heif", + }[extensionFromName(input.name)] ?? null + ); +} + +function base64DataUrl(bytes: Uint8Array, mimeType: string): string { + return `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`; +} + +async function fetchBytes(input: { + readonly url: string; + readonly accessToken?: string | undefined; +}): Promise { + const withAuth = input.accessToken + ? ({ + authorization: `Bearer ${input.accessToken}`, + } satisfies HeadersInit) + : undefined; + const response = await globalThis.fetch(input.url, withAuth ? { headers: withAuth } : undefined); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +export async function downloadTeamsMessageImages(input: { + readonly message: TeamsMessage; + readonly accessToken: string; + readonly hostedContentEntries: ReadonlyArray<{ + readonly id: string; + readonly contentType?: string | undefined; + readonly valueUrl: string; + }>; +}): Promise { + const discordFiles: DiscordUploadFile[] = []; + const t3Uploads: UploadChatAttachment[] = []; + const skipped: Array<{ readonly name: string; readonly reason: string }> = []; + + const pushImage = (name: string, mimeType: string, bytes: Uint8Array) => { + discordFiles.push({ + name, + mimeType, + data: bytes, + }); + if (bytes.byteLength <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + t3Uploads.push({ + type: "image", + name, + mimeType, + sizeBytes: bytes.byteLength, + dataUrl: base64DataUrl(bytes, mimeType), + }); + } else { + skipped.push({ + name, + reason: `too large for T3 upload (${bytes.byteLength} bytes)`, + }); + } + }; + + for (const attachment of input.message.attachments ?? []) { + const name = attachment.name?.trim() || "teams-image"; + const mimeType = guessImageMimeType({ + name, + contentType: attachment.contentType, + }); + const url = attachment.contentUrl ?? attachment.thumbnailUrl ?? ""; + if (mimeType === null || url.trim() === "") continue; + + try { + const bytes = await fetchBytes({ + url, + accessToken: input.accessToken, + }); + pushImage(name, mimeType, bytes); + } catch (error) { + skipped.push({ + name, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + for (const hostedContent of input.hostedContentEntries) { + const mimeType = hostedContent.contentType?.trim().toLowerCase(); + if (!mimeType?.startsWith("image/")) continue; + + const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9]+/giu, "") || "png"; + const name = `teams-inline-${hostedContent.id}.${extension}`; + try { + const bytes = await fetchBytes({ + url: hostedContent.valueUrl, + accessToken: input.accessToken, + }); + pushImage(name, mimeType, bytes); + } catch (error) { + skipped.push({ + name, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + discordFiles, + t3Uploads, + skipped, + }; +} diff --git a/apps/discord-bot/src/teams/config.test.ts b/apps/discord-bot/src/teams/config.test.ts new file mode 100644 index 00000000000..50ebca1ed6a --- /dev/null +++ b/apps/discord-bot/src/teams/config.test.ts @@ -0,0 +1,80 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { loadTeamsChannelConfigsFromFileSync } from "./config.ts"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => NodeFSP.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("loadTeamsChannelConfigsFromFileSync", () => { + it("loads root channels arrays", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "teams-config-")); + tempDirs.push(dir); + const filePath = NodePath.join(dir, "teams.json"); + await NodeFSP.writeFile( + filePath, + JSON.stringify([ + { + teamId: "team-1", + channelId: "channel-1", + channelName: "Prod", + projectShortName: "MACS-SCANNER", + discordChannelId: "123", + company: "Acme", + environment: "prod", + companyKeywords: ["acme"], + environmentKeywords: ["prod"], + automaticAssessmentEnabled: false, + internalUserIds: [" user-1 ", "user-2"], + reactionTriggerTypes: [" Eyes ", "🚨"], + messageTagTriggers: [" #Investigate ", "#triage"], + }, + ]), + "utf8", + ); + + const configs = loadTeamsChannelConfigsFromFileSync(filePath); + expect(configs).toHaveLength(1); + expect(configs[0]?.projectShortName).toBe("macs-scanner"); + expect(configs[0]?.automaticAssessmentEnabled).toBe(false); + expect(configs[0]?.internalUserIds).toEqual(["user-1", "user-2"]); + expect(configs[0]?.reactionTriggerTypes).toEqual(["eyes", "🚨"]); + expect(configs[0]?.messageTagTriggers).toEqual(["#investigate", "#triage"]); + expect(configs[0]?.deliveryMode).toBe("discord"); + }); + + it("supports Discord-free native channel mappings", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "teams-config-")); + tempDirs.push(dir); + const filePath = NodePath.join(dir, "teams.json"); + await NodeFSP.writeFile( + filePath, + JSON.stringify([ + { + teamId: "team-1", + channelId: "channel-1", + channelName: "Prod", + projectShortName: "scanner", + deliveryMode: "native", + company: "Acme", + environment: "prod", + companyKeywords: ["acme"], + environmentKeywords: ["prod"], + }, + ]), + "utf8", + ); + + const configs = loadTeamsChannelConfigsFromFileSync(filePath); + expect(configs[0]?.deliveryMode).toBe("native"); + expect(configs[0]?.discordChannelId).toBeUndefined(); + }); +}); diff --git a/apps/discord-bot/src/teams/config.ts b/apps/discord-bot/src/teams/config.ts new file mode 100644 index 00000000000..7ceaf9a7b4d --- /dev/null +++ b/apps/discord-bot/src/teams/config.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off tryCatchInEffectGen:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { expandHomePath } from "../projectAliases.ts"; + +export interface TeamsChannelConfig { + readonly teamId: string; + readonly channelId: string; + readonly channelName: string; + readonly projectShortName: string; + readonly discordChannelId?: string | undefined; + /** + * discord: mirror into a Discord thread (legacy compatibility). + * t3-only: start T3 directly and acknowledge through the optional Teams workflow webhook. + * native: native Teams activities own replies; Graph polling only supplies ambient triggers. + */ + readonly deliveryMode?: "discord" | "t3-only" | "native" | undefined; + readonly company: string; + readonly environment: string; + readonly companyKeywords: ReadonlyArray; + readonly environmentKeywords: ReadonlyArray; + readonly problemKeywords?: ReadonlyArray | undefined; + readonly automaticAssessmentEnabled?: boolean | undefined; + readonly internalUserIds?: ReadonlyArray | undefined; + readonly reactionTriggerTypes?: ReadonlyArray | undefined; + readonly messageTagTriggers?: ReadonlyArray | undefined; + readonly respondToMentions?: boolean | undefined; + readonly teamsIncomingWebhookUrl?: string | undefined; +} + +function isStringArray(value: unknown): value is ReadonlyArray { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isChannelConfig(value: unknown): value is TeamsChannelConfig { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + typeof candidate.teamId === "string" && + typeof candidate.channelId === "string" && + typeof candidate.channelName === "string" && + typeof candidate.projectShortName === "string" && + (candidate.discordChannelId === undefined || typeof candidate.discordChannelId === "string") && + (candidate.deliveryMode === undefined || + candidate.deliveryMode === "discord" || + candidate.deliveryMode === "t3-only" || + candidate.deliveryMode === "native") && + typeof candidate.company === "string" && + typeof candidate.environment === "string" && + isStringArray(candidate.companyKeywords) && + isStringArray(candidate.environmentKeywords) && + (candidate.problemKeywords === undefined || isStringArray(candidate.problemKeywords)) && + (candidate.automaticAssessmentEnabled === undefined || + typeof candidate.automaticAssessmentEnabled === "boolean") && + (candidate.internalUserIds === undefined || isStringArray(candidate.internalUserIds)) && + (candidate.reactionTriggerTypes === undefined || + isStringArray(candidate.reactionTriggerTypes)) && + (candidate.messageTagTriggers === undefined || isStringArray(candidate.messageTagTriggers)) && + (candidate.respondToMentions === undefined || + typeof candidate.respondToMentions === "boolean") && + (candidate.teamsIncomingWebhookUrl === undefined || + typeof candidate.teamsIncomingWebhookUrl === "string") + ); +} + +export function loadTeamsChannelConfigsFromFileSync( + filePath: string, +): ReadonlyArray { + const resolvedPath = NodePath.resolve(expandHomePath(filePath.trim())); + const raw = NodeFS.readFileSync(resolvedPath, "utf8").trim(); + if (raw.length === 0) return []; + + const parsed = JSON.parse(raw) as unknown; + const channels = Array.isArray(parsed) + ? parsed + : typeof parsed === "object" && + parsed !== null && + "channels" in parsed && + Array.isArray((parsed as { channels?: unknown }).channels) + ? (parsed as { channels: ReadonlyArray }).channels + : null; + if (channels === null || !channels.every(isChannelConfig)) { + throw new Error("Teams channel config file must be an array of valid channel objects."); + } + + return channels.map((channel) => ({ + ...channel, + deliveryMode: + channel.deliveryMode ?? (channel.discordChannelId === undefined ? "t3-only" : "discord"), + projectShortName: channel.projectShortName.trim().toLowerCase(), + companyKeywords: channel.companyKeywords.map((value: string) => value.trim()).filter(Boolean), + environmentKeywords: channel.environmentKeywords + .map((value: string) => value.trim()) + .filter(Boolean), + ...(channel.problemKeywords === undefined + ? {} + : { + problemKeywords: channel.problemKeywords + .map((value: string) => value.trim()) + .filter(Boolean), + }), + ...(channel.automaticAssessmentEnabled === undefined + ? {} + : { automaticAssessmentEnabled: channel.automaticAssessmentEnabled }), + ...(channel.internalUserIds === undefined + ? {} + : { + internalUserIds: channel.internalUserIds + .map((value: string) => value.trim()) + .filter(Boolean), + }), + ...(channel.reactionTriggerTypes === undefined + ? {} + : { + reactionTriggerTypes: channel.reactionTriggerTypes + .map((value: string) => value.trim().toLowerCase()) + .filter(Boolean), + }), + ...(channel.messageTagTriggers === undefined + ? {} + : { + messageTagTriggers: channel.messageTagTriggers + .map((value: string) => value.trim().toLowerCase()) + .filter(Boolean), + }), + })); +} diff --git a/apps/discord-bot/src/teams/presentation.test.ts b/apps/discord-bot/src/teams/presentation.test.ts new file mode 100644 index 00000000000..9bd37b0f710 --- /dev/null +++ b/apps/discord-bot/src/teams/presentation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + hasAllowlistedReaction, + hasInternalTagTrigger, + looksLikeGermanProblemReport, + teamsMessageText, +} from "./presentation.ts"; + +describe("teamsMessageText", () => { + it("strips html", () => { + expect( + teamsMessageText({ + id: "1", + body: { content: "
Hallo Welt & mehr
" }, + }), + ).toBe("Hallo Welt & mehr"); + }); +}); + +describe("looksLikeGermanProblemReport", () => { + it("matches german production incidents", () => { + expect( + looksLikeGermanProblemReport({ + message: { + id: "1", + body: { content: "Beim Kunden Acme gibt es seit heute einen Fehler in Produktion." }, + }, + companyKeywords: ["Acme"], + environmentKeywords: ["Produktion"], + }), + ).toBe(true); + }); +}); + +describe("hasAllowlistedReaction", () => { + it("matches configured reactions from allowlisted users", () => { + expect( + hasAllowlistedReaction({ + message: { + id: "1", + reactions: [ + { + reactionType: "eyes", + user: { + user: { + id: "internal-user", + }, + }, + }, + ], + }, + allowlistedUserIds: ["internal-user"], + reactionTriggerTypes: ["eyes"], + }), + ).toBe(true); + }); +}); + +describe("hasInternalTagTrigger", () => { + it("matches tag commands from allowlisted users", () => { + expect( + hasInternalTagTrigger({ + message: { + id: "1", + from: { + user: { + id: "internal-user", + }, + }, + body: { + content: "Please take this one #investigate", + }, + }, + allowlistedUserIds: ["internal-user"], + messageTagTriggers: ["#investigate"], + }), + ).toBe(true); + }); +}); diff --git a/apps/discord-bot/src/teams/presentation.ts b/apps/discord-bot/src/teams/presentation.ts new file mode 100644 index 00000000000..0b559d23011 --- /dev/null +++ b/apps/discord-bot/src/teams/presentation.ts @@ -0,0 +1,338 @@ +export interface TeamsMessageAuthor { + readonly id?: string | undefined; + readonly displayName?: string | undefined; +} + +export interface TeamsMessage { + readonly id: string; + readonly replyToId?: string | null | undefined; + readonly createdDateTime?: string | undefined; + readonly subject?: string | null | undefined; + readonly summary?: string | null | undefined; + readonly webUrl?: string | undefined; + readonly messageType?: string | undefined; + readonly from?: + | { + readonly user?: TeamsMessageAuthor | null | undefined; + readonly application?: { readonly displayName?: string | undefined } | null | undefined; + } + | null + | undefined; + readonly body?: + | { + readonly contentType?: string | undefined; + readonly content?: string | null | undefined; + } + | null + | undefined; + readonly attachments?: + | ReadonlyArray<{ + readonly id?: string | undefined; + readonly contentType?: string | undefined; + readonly contentUrl?: string | undefined; + readonly name?: string | undefined; + readonly thumbnailUrl?: string | undefined; + }> + | undefined; + readonly reactions?: + | ReadonlyArray<{ + readonly reactionType?: string | undefined; + readonly displayName?: string | undefined; + readonly user?: + | { + readonly user?: + | { + readonly id?: string | undefined; + readonly displayName?: string | undefined; + } + | null + | undefined; + } + | null + | undefined; + }> + | undefined; + readonly mentions?: + | ReadonlyArray<{ + readonly mentionText?: string | undefined; + readonly mentioned?: + | { + readonly user?: TeamsMessageAuthor | null | undefined; + readonly application?: + | { readonly displayName?: string | undefined } + | null + | undefined; + } + | null + | undefined; + }> + | undefined; +} + +const DEFAULT_GERMAN_PROBLEM_KEYWORDS = [ + "fehler", + "störung", + "stoerung", + "problem", + "kaputt", + "funktioniert nicht", + "geht nicht", + "ausfall", + "dringend", + "hilfe", + "betroffen", + "unterbrochen", + "broken", + "incident", +] as const; + +const GERMAN_MARKERS = [ + "der", + "die", + "das", + "nicht", + "bitte", + "kann", + "können", + "koennen", + "seit", + "heute", + "gestern", + "kunde", + "umgebung", + "produktion", +] as const; + +export function teamsMessageText(message: TeamsMessage): string { + const content = message.body?.content ?? ""; + return decodeHtmlEntities( + content + .replace(//giu, " ") + .replace(//giu, " ") + .replace(/<[^>]+>/gu, " ") + .replace(/\s+/gu, " ") + .trim(), + ) + .replace(/\s+/gu, " ") + .trim(); +} + +function normalizedText(value: string | undefined | null): string { + return (value ?? "").trim().toLowerCase(); +} + +function decodeHtmlEntities(value: string): string { + return value + .replaceAll(" ", " ") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'"); +} + +export function rootTeamsMessageId(message: TeamsMessage): string { + return message.replyToId ?? message.id; +} + +export function teamsMessageTimestamp(message: TeamsMessage): string { + return message.createdDateTime ?? ""; +} + +export function isHumanTeamsMessage(message: TeamsMessage): boolean { + if (message.messageType && message.messageType !== "message") return false; + if (message.from?.application) return false; + return teamsMessageText(message).trim().length > 0; +} + +export function mentionsTeamsBot(message: TeamsMessage, displayName: string | undefined): boolean { + const normalizedDisplayName = normalizedText(displayName); + if ( + normalizedDisplayName.length > 0 && + message.mentions?.some((mention) => { + const names = [ + mention.mentionText, + mention.mentioned?.application?.displayName, + mention.mentioned?.user?.displayName, + ] + .filter((value): value is string => typeof value === "string") + .map((value) => normalizedText(value)); + return names.includes(normalizedDisplayName); + }) + ) { + return true; + } + + const text = normalizedText(teamsMessageText(message)); + return normalizedDisplayName.length > 0 ? text.includes(normalizedDisplayName) : false; +} + +export function looksLikeGermanProblemReport(input: { + readonly message: TeamsMessage; + readonly companyKeywords: ReadonlyArray; + readonly environmentKeywords: ReadonlyArray; + readonly problemKeywords?: ReadonlyArray | undefined; +}): boolean { + const text = teamsMessageText(input.message).toLowerCase(); + if (text.length === 0) return false; + + const germanMarkerHits = GERMAN_MARKERS.filter((marker) => text.includes(marker)).length; + const umlautHint = /[äöüß]/iu.test(text); + const germanish = umlautHint || germanMarkerHits >= 2; + if (!germanish) return false; + + const companyHit = input.companyKeywords.some((keyword) => text.includes(keyword.toLowerCase())); + const environmentHit = input.environmentKeywords.some((keyword) => + text.includes(keyword.toLowerCase()), + ); + const problemHit = [...DEFAULT_GERMAN_PROBLEM_KEYWORDS, ...(input.problemKeywords ?? [])].some( + (keyword) => text.includes(keyword.toLowerCase()), + ); + + return problemHit && (companyHit || environmentHit); +} + +export function isAllowlistedInternalUser( + message: TeamsMessage, + allowlistedUserIds: ReadonlyArray | undefined, +): boolean { + const authorId = normalizedText(message.from?.user?.id); + return ( + authorId.length > 0 && (allowlistedUserIds ?? []).some((id) => normalizedText(id) === authorId) + ); +} + +export function hasAllowlistedReaction(input: { + readonly message: TeamsMessage; + readonly allowlistedUserIds?: ReadonlyArray | undefined; + readonly reactionTriggerTypes?: ReadonlyArray | undefined; +}): boolean { + if ((input.reactionTriggerTypes ?? []).length === 0) return false; + + return (input.message.reactions ?? []).some((reaction) => { + const reactionType = normalizedText(reaction.reactionType); + const reactingUserId = normalizedText(reaction.user?.user?.id); + if (reactionType.length === 0 || reactingUserId.length === 0) return false; + const typeMatch = (input.reactionTriggerTypes ?? []).some( + (trigger) => normalizedText(trigger) === reactionType, + ); + const userMatch = (input.allowlistedUserIds ?? []).some( + (userId) => normalizedText(userId) === reactingUserId, + ); + return typeMatch && userMatch; + }); +} + +export function hasInternalTagTrigger(input: { + readonly message: TeamsMessage; + readonly allowlistedUserIds?: ReadonlyArray | undefined; + readonly messageTagTriggers?: ReadonlyArray | undefined; +}): boolean { + if (!isAllowlistedInternalUser(input.message, input.allowlistedUserIds)) return false; + const text = normalizedText(teamsMessageText(input.message)); + return (input.messageTagTriggers ?? []).some((tag) => { + const normalizedTag = normalizedText(tag); + return normalizedTag.length > 0 && text.includes(normalizedTag); + }); +} + +export function buildTeamsIncidentTitle(input: { + readonly company: string; + readonly environment: string; + readonly message: TeamsMessage; +}): string { + const text = teamsMessageText(input.message).replace(/\s+/gu, " ").trim(); + const summary = text.length > 96 ? `${text.slice(0, 95).trimEnd()}…` : text; + return `${input.company} ${input.environment}: ${summary || "Teams incident"}`; +} + +export function buildTeamsSeedMessage(input: { + readonly company: string; + readonly environment: string; + readonly channelName: string; + readonly message: TeamsMessage; + readonly reason: "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag"; +}): string { + const author = input.message.from?.user?.displayName ?? "unknown"; + const text = teamsMessageText(input.message); + const triggerLabel = + input.reason === "mention" + ? "@mention" + : input.reason === "german-problem" + ? "German problem report" + : input.reason === "allowlisted-reaction" + ? "allowlisted reaction" + : "allowlisted internal tag"; + return [ + `**Teams intake** from **${input.channelName}**`, + `Company: **${input.company}**`, + `Environment: **${input.environment}**`, + `Trigger: ${triggerLabel}`, + `Author: ${author}`, + input.message.webUrl ? `Source: ${input.message.webUrl}` : null, + "", + text, + ] + .filter((line): line is string => line !== null) + .join("\n"); +} + +export function buildTeamsPrompt(input: { + readonly channelName: string; + readonly company: string; + readonly environment: string; + readonly projectShortName: string; + readonly reason: "mention" | "german-problem" | "allowlisted-reaction" | "internal-tag"; + readonly message: TeamsMessage; + readonly triggerMessage?: TeamsMessage | undefined; + readonly history?: ReadonlyArray | undefined; +}): string { + const text = teamsMessageText(input.message); + const author = input.message.from?.user?.displayName ?? "unknown"; + const triggerText = + input.reason === "mention" + ? "explicit @mention" + : input.reason === "german-problem" + ? "German problem report detection" + : input.reason === "allowlisted-reaction" + ? "allowlisted reaction trigger" + : "allowlisted internal tag trigger"; + const history = (input.history ?? []) + .map((message) => { + const entryAuthor = message.from?.user?.displayName ?? "unknown"; + const when = message.createdDateTime ?? "unknown time"; + const body = teamsMessageText(message); + return `[${when}] ${entryAuthor}: ${body}`; + }) + .join("\n"); + + return `## Microsoft Teams intake + +Project short name: ${input.projectShortName} +Company: ${input.company} +Environment: ${input.environment} +Teams channel: ${input.channelName} +Trigger: ${triggerText} +Author: ${author} +Message id: ${input.message.id} +${input.message.replyToId ? `Reply to: ${input.message.replyToId}` : "Root message: yes"} +${input.message.webUrl ? `Source URL: ${input.message.webUrl}` : "Source URL: unavailable"} + +${ + input.triggerMessage && input.triggerMessage.id !== input.message.id + ? `Trigger message id: ${input.triggerMessage.id} +Trigger message author: ${input.triggerMessage.from?.user?.displayName ?? "unknown"} +Trigger message text: ${teamsMessageText(input.triggerMessage)}` + : "" +} + +## Original Teams message +${text} + +## Recent channel history +${history.length > 0 ? history : "(no recent preceding messages in scope)"} + +## Task +Treat this as an incident intake from Teams. Infer the likely user problem from the German text, investigate it, and post the analysis into this Discord thread. +If details are ambiguous, say so explicitly instead of inventing specifics.`; +} diff --git a/apps/discord-bot/teams-app/README.md b/apps/discord-bot/teams-app/README.md new file mode 100644 index 00000000000..b3db5c7da3d --- /dev/null +++ b/apps/discord-bot/teams-app/README.md @@ -0,0 +1,11 @@ +# Teams app package + +Before zipping this directory: + +1. Replace `${TEAMS_APP_ID}` and `${TEAMS_CLIENT_ID}` with the app/client ID. +2. Replace `${PUBLIC_BASE_URL}` and `${PUBLIC_HOST}` with the public HTTPS origin and host. +3. Add a transparent 32×32 `outline.png` and a full-color 192×192 `color.png`. +4. Zip `manifest.json`, `outline.png`, and `color.png` at the archive root. + +The app sends activities to the Azure Bot messaging endpoint configured separately as +`https:///api/messages`. diff --git a/apps/discord-bot/teams-app/manifest.json b/apps/discord-bot/teams-app/manifest.json new file mode 100644 index 00000000000..14b6f570bfa --- /dev/null +++ b/apps/discord-bot/teams-app/manifest.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.23/MicrosoftTeams.schema.json", + "manifestVersion": "1.23", + "version": "1.0.0", + "id": "${TEAMS_APP_ID}", + "developer": { + "name": "T3 Code", + "websiteUrl": "${PUBLIC_BASE_URL}", + "privacyUrl": "${PUBLIC_BASE_URL}/privacy", + "termsOfUseUrl": "${PUBLIC_BASE_URL}/terms" + }, + "name": { + "short": "T3 Code", + "full": "T3 Code engineering agent" + }, + "description": { + "short": "Start and continue T3 Code work from Microsoft Teams.", + "full": "Runs T3 Code engineering sessions from Teams conversations or from the Start T3 investigation message action." + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#5B5FC7", + "bots": [ + { + "botId": "${TEAMS_CLIENT_ID}", + "scopes": ["personal", "team", "groupChat"], + "supportsFiles": true, + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "commandLists": [ + { + "scopes": ["personal", "team", "groupChat"], + "commands": [ + { + "title": "stop", + "description": "Stop the active T3 turn in this conversation." + }, + { + "title": "approve", + "description": "Approve a pending T3 request by request id." + }, + { + "title": "deny", + "description": "Deny a pending T3 request by request id." + }, + { + "title": "answer", + "description": "Answer a T3 user-input request with a JSON object." + } + ] + } + ] + } + ], + "composeExtensions": [ + { + "botId": "${TEAMS_CLIENT_ID}", + "commands": [ + { + "id": "startT3Investigation", + "type": "action", + "title": "Start T3 investigation", + "description": "Start a T3 investigation from the selected message.", + "initialRun": false, + "fetchTask": true, + "context": ["message"] + } + ] + } + ], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": ["${PUBLIC_HOST}"] +} diff --git a/apps/discord-bot/teams.channels.example.json b/apps/discord-bot/teams.channels.example.json new file mode 100644 index 00000000000..4d7d8f163a0 --- /dev/null +++ b/apps/discord-bot/teams.channels.example.json @@ -0,0 +1,22 @@ +{ + "channels": [ + { + "teamId": "00000000-0000-0000-0000-000000000000", + "channelId": "19:examplechannel@thread.tacv2", + "channelName": "Acme Prod Incidents", + "projectShortName": "macs-scanner", + "deliveryMode": "native", + "company": "Acme", + "environment": "prod", + "companyKeywords": ["acme", "kunde acme"], + "environmentKeywords": ["prod", "produktion", "live"], + "problemKeywords": ["fehler", "störung", "ausfall", "geht nicht"], + "automaticAssessmentEnabled": true, + "internalUserIds": ["11111111-1111-1111-1111-111111111111"], + "reactionTriggerTypes": ["eyes", "🚨"], + "messageTagTriggers": ["#investigate", "#triage"], + "respondToMentions": true, + "teamsIncomingWebhookUrl": "https://example.webhook.office.com/webhookb2/..." + } + ] +} diff --git a/apps/discord-bot/tsconfig.json b/apps/discord-bot/tsconfig.json new file mode 100644 index 00000000000..d3081eb428a --- /dev/null +++ b/apps/discord-bot/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/apps/discord-bot/vite.config.ts b/apps/discord-bot/vite.config.ts new file mode 100644 index 00000000000..74055a558f6 --- /dev/null +++ b/apps/discord-bot/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/apps/server/docs/t3-agent-rules.md b/apps/server/docs/t3-agent-rules.md new file mode 100644 index 00000000000..a9bc1ba98f5 --- /dev/null +++ b/apps/server/docs/t3-agent-rules.md @@ -0,0 +1,18 @@ +# T3 agent rules (global — all surfaces) + +**Layer:** product-wide base rules for every T3 session (web, desktop, mobile, +Discord, GitHub, Jira). Project `AGENTS.md` / `CLAUDE.md` still own repo-local +conventions. + +Delivery: the T3 server injects a **file pointer** to this document on the first +turn of each provider session, and again after context compaction. Read the file; +do not expect the body inline in the prompt. + +Client overlays (e.g. Discord `apps/discord-bot/docs/agent-turn-rules.md`) add +surface-specific policy only. Put shared policy **here**. + +**Links:** always markdown hyperlinks `[label](url)` — never bare `https://…` — +in Jira/Confluence comments, PR bodies, handoff notes, and any reply where a URL +should be clickable. Prefer short labels (`[scanner#2036](…)`, `[SA-49](…)`). +Jira API Markdown→ADF often leaves bare URLs as plain text; explicit link syntax +becomes a real hyperlink. diff --git a/apps/server/src/agentRules/T3AgentRules.test.ts b/apps/server/src/agentRules/T3AgentRules.test.ts new file mode 100644 index 00000000000..0a228e7b9e0 --- /dev/null +++ b/apps/server/src/agentRules/T3AgentRules.test.ts @@ -0,0 +1,47 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import { describe, it } from "vite-plus/test"; + +import { + ensureT3AgentRulesInput, + readT3AgentRulesInjected, + resolveT3AgentRulesPath, + T3_AGENT_RULES_INJECTED_KEY, + withT3AgentRules, +} from "./T3AgentRules.ts"; + +describe("resolveT3AgentRulesPath", () => { + it("points at an existing markdown file", () => { + const path = resolveT3AgentRulesPath(); + NodeAssert.ok(path.endsWith("t3-agent-rules.md")); + NodeAssert.ok(NodeFS.existsSync(path), `rules file missing: ${path}`); + NodeAssert.ok(NodeFS.readFileSync(path, "utf8").includes("always markdown hyperlinks")); + }); +}); + +describe("session inject helpers", () => { + it("prepends a file pointer, not the body", () => { + const path = resolveT3AgentRulesPath(); + const result = withT3AgentRules("do the thing"); + NodeAssert.ok(result?.includes(`rules: ${path}`)); + NodeAssert.ok(result?.includes("do the thing")); + NodeAssert.equal(result?.includes("always markdown hyperlinks"), false); + }); + + it("skips re-inject when already injected this session", () => { + NodeAssert.equal(ensureT3AgentRulesInput("hello", false, true), "hello"); + }); + + it("injects when session needs rules", () => { + const result = ensureT3AgentRulesInput("hello", false, false); + NodeAssert.ok(result?.includes("## Agent rules")); + NodeAssert.ok(result?.includes("hello")); + }); + + it("reads the injected flag from runtime payload", () => { + NodeAssert.equal(readT3AgentRulesInjected(null), false); + NodeAssert.equal(readT3AgentRulesInjected({ [T3_AGENT_RULES_INJECTED_KEY]: true }), true); + NodeAssert.equal(readT3AgentRulesInjected({ [T3_AGENT_RULES_INJECTED_KEY]: false }), false); + }); +}); diff --git a/apps/server/src/agentRules/T3AgentRules.ts b/apps/server/src/agentRules/T3AgentRules.ts new file mode 100644 index 00000000000..7e3d50dd84d --- /dev/null +++ b/apps/server/src/agentRules/T3AgentRules.ts @@ -0,0 +1,138 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Product-wide agent rules (source of truth + helpers). + * + * Single delivery mechanism (all harnesses, all surfaces): + * - Inject `## Agent rules` **file pointers** on the first provider turn of a session + * - Clear the inject flag on context compaction, re-inject on the next turn + * + * Not project AGENTS.md. Not harness-home mutation. Bodies are never embedded; + * agents open the rules file path. + * + * Discord overlay is surface-only (`agent-turn-rules.md` via Discord turn context). + */ + +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { + AGENT_RULES_HEADER, + ensureAgentRulesPaths, + formatAgentRulesPointers, +} from "@t3tools/shared/agentRulesPointer"; + +/** Fallback body when the package docs file is missing (bundled `dist/` binary). */ +const T3_AGENT_RULES_FALLBACK_MARKDOWN = `# T3 agent rules (all surfaces) + +Product rules for every T3 turn (web, desktop, mobile, Discord, GitHub, Jira). +Project \`AGENTS.md\` still owns repo-local conventions. + +**Links:** always markdown hyperlinks \`[label](url)\` — never bare \`https://…\` — +in Jira/Confluence comments, PR bodies, handoff notes, and any reply where a URL +should be clickable. Prefer short labels (\`[scanner#2036](…)\`, \`[SA-49](…)\`). +Jira API Markdown→ADF often leaves bare URLs as plain text; explicit link syntax +becomes a real hyperlink. +`; + +/** runtimePayload key: rules pointer already sent for this provider session. */ +export const T3_AGENT_RULES_INJECTED_KEY = "t3AgentRulesInjected"; + +export { AGENT_RULES_HEADER }; + +function serverPackageRoot(): string { + return NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../.."); +} + +/** + * Absolute path to the product-wide rules markdown file. + * Prefer the packaged/source docs path; materialize a temp copy if missing. + */ +export function resolveT3AgentRulesPath(): string { + const root = serverPackageRoot(); + const candidates = [ + NodePath.resolve(root, "docs/t3-agent-rules.md"), + NodePath.resolve(root, "src/docs/t3-agent-rules.md"), + NodePath.resolve(root, "t3-agent-rules.md"), + ]; + for (const candidate of candidates) { + if (NodeFS.existsSync(candidate)) { + return candidate; + } + } + + const materializeDir = NodePath.join(NodeOS.tmpdir(), "t3-code"); + const materialized = NodePath.join(materializeDir, "t3-agent-rules.md"); + if (!NodeFS.existsSync(materialized)) { + NodeFS.mkdirSync(materializeDir, { recursive: true }); + NodeFS.writeFileSync(materialized, T3_AGENT_RULES_FALLBACK_MARKDOWN, "utf8"); + } + return materialized; +} + +/** Discord client overlay when monorepo layout is present. */ +export function resolveDiscordAgentRulesPath(): string | null { + const candidate = NodePath.resolve( + serverPackageRoot(), + "../discord-bot/docs/agent-turn-rules.md", + ); + return NodeFS.existsSync(candidate) ? candidate : null; +} + +export function isDiscordOriginatedTurnText(text: string | undefined): boolean { + if (text === undefined) return false; + return text.includes("## Discord conversation context"); +} + +/** Paths for this turn: global always; Discord overlay when Discord-originated. */ +export function resolveT3AgentRulesPathsForTurn(providerInput: string | undefined): string[] { + const paths = [resolveT3AgentRulesPath()]; + if (isDiscordOriginatedTurnText(providerInput)) { + const overlay = resolveDiscordAgentRulesPath(); + if (overlay !== null) paths.push(overlay); + } + return paths; +} + +export function formatT3AgentRulesPointer(rulesPath: string = resolveT3AgentRulesPath()): string { + return formatAgentRulesPointers([rulesPath]); +} + +export function readT3AgentRulesInjected(runtimePayload: unknown): boolean { + if ( + runtimePayload === null || + typeof runtimePayload !== "object" || + Array.isArray(runtimePayload) + ) { + return false; + } + return (runtimePayload as Record)[T3_AGENT_RULES_INJECTED_KEY] === true; +} + +export function withT3AgentRules(providerInput: string | undefined): string | undefined { + if (providerInput === undefined) { + return undefined; + } + return ensureAgentRulesPaths(providerInput, resolveT3AgentRulesPathsForTurn(providerInput)); +} + +/** + * Ensure turn input includes rules pointers when this session still needs them. + * When `alreadyInjectedThisSession` is true, leave text unchanged until compaction + * clears the flag. + */ +export function ensureT3AgentRulesInput( + providerInput: string | undefined, + hasAttachments: boolean, + alreadyInjectedThisSession = false, +): string | undefined { + if (alreadyInjectedThisSession) { + return providerInput ?? (hasAttachments ? "" : undefined); + } + const wrapped = withT3AgentRules(providerInput ?? (hasAttachments ? "" : undefined)); + if (wrapped !== undefined && wrapped.trim().length > 0) { + return wrapped; + } + return providerInput; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 4214d57b953..578696a9386 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,6 +23,10 @@ "types": "./src/agentAwareness.ts", "import": "./src/agentAwareness.ts" }, + "./agentRulesPointer": { + "types": "./src/agentRulesPointer.ts", + "import": "./src/agentRulesPointer.ts" + }, "./sessionWake": { "types": "./src/sessionWake.ts", "import": "./src/sessionWake.ts" diff --git a/packages/shared/src/agentRulesPointer.test.ts b/packages/shared/src/agentRulesPointer.test.ts new file mode 100644 index 00000000000..2bcd4a3d450 --- /dev/null +++ b/packages/shared/src/agentRulesPointer.test.ts @@ -0,0 +1,101 @@ +import * as NodeAssert from "node:assert/strict"; +import { describe, it } from "vite-plus/test"; + +import { + AGENT_RULES_HEADER, + dedupeAgentRulesPaths, + ensureAgentRulesPaths, + extractAgentRulesPaths, + formatAgentRulesPointer, + formatAgentRulesPointers, + stripAgentRulesBlock, +} from "./agentRulesPointer.ts"; + +describe("formatAgentRulesPointers", () => { + it("formats global + overlay under one header", () => { + NodeAssert.equal( + formatAgentRulesPointers([ + "/opt/t3/docs/t3-agent-rules.md", + "/opt/t3/discord/agent-turn-rules.md", + ]), + `## Agent rules +rules: /opt/t3/docs/t3-agent-rules.md +rules: /opt/t3/discord/agent-turn-rules.md`, + ); + }); + + it("dedupes paths", () => { + NodeAssert.equal( + formatAgentRulesPointers(["/a.md", "/a.md", "/b.md"]), + `## Agent rules +rules: /a.md +rules: /b.md`, + ); + }); + + it("returns empty for no paths", () => { + NodeAssert.equal(formatAgentRulesPointers([]), ""); + }); +}); + +describe("formatAgentRulesPointer", () => { + it("still supports a custom single-path header", () => { + NodeAssert.equal( + formatAgentRulesPointer("/tmp/rules.md", "## Discord conversation context"), + `## Discord conversation context +rules: /tmp/rules.md`, + ); + }); +}); + +describe("extract / strip / ensure", () => { + const sample = `## Agent rules +rules: /global.md +rules: /discord.md + +## Discord conversation context +req: 1@user +## User request +hello`; + + it("extracts rules paths from the agent-rules block", () => { + NodeAssert.deepEqual(extractAgentRulesPaths(sample), ["/global.md", "/discord.md"]); + }); + + it("strips the agent-rules block", () => { + const stripped = stripAgentRulesBlock(sample); + NodeAssert.equal(stripped.includes(AGENT_RULES_HEADER), false); + NodeAssert.equal(stripped.includes("## Discord conversation context"), true); + NodeAssert.equal(stripped.includes("hello"), true); + }); + + it("merges required paths first and dedupes", () => { + const merged = ensureAgentRulesPaths(sample, ["/global.md", "/extra.md"]); + NodeAssert.equal( + merged, + `## Agent rules +rules: /global.md +rules: /extra.md +rules: /discord.md + +## Discord conversation context +req: 1@user +## User request +hello`, + ); + }); + + it("adds a block when missing", () => { + NodeAssert.equal( + ensureAgentRulesPaths("just text", ["/global.md"]), + `## Agent rules +rules: /global.md + +just text`, + ); + }); + + it("dedupeAgentRulesPaths preserves order", () => { + NodeAssert.deepEqual(dedupeAgentRulesPaths(["/b", "/a", "/b", ""]), ["/b", "/a"]); + }); +}); diff --git a/packages/shared/src/agentRulesPointer.ts b/packages/shared/src/agentRulesPointer.ts new file mode 100644 index 00000000000..5ef46d79e31 --- /dev/null +++ b/packages/shared/src/agentRulesPointer.ts @@ -0,0 +1,119 @@ +/** + * Shared shape for agent policy file pointers. + * + * Product policy is layered: + * 1. Global rules file (all surfaces) — always first + * 2. Optional client overlay (Discord, …) — additional `rules:` lines + * + * Injection shape (never paste rule bodies into the prompt): + * + * ``` + * ## Agent rules + * rules: /absolute/path/to/t3-agent-rules.md + * rules: /absolute/path/to/agent-turn-rules.md + * ``` + */ + +/** Canonical header for the unified rules block (global + overlays). */ +export const AGENT_RULES_HEADER = "## Agent rules"; + +const RULES_LINE = /^rules:\s+(\S+)\s*$/u; + +function normalizePath(path: string): string { + return path.trim(); +} + +/** Stable de-dupe preserving first-seen order. */ +export function dedupeAgentRulesPaths(paths: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of paths) { + const path = normalizePath(raw); + if (path.length === 0 || seen.has(path)) continue; + seen.add(path); + out.push(path); + } + return out; +} + +/** + * Format one or more rules-file pointers under a single header. + * Empty input → empty string. + */ +export function formatAgentRulesPointers( + rulesPaths: readonly string[], + header: string = AGENT_RULES_HEADER, +): string { + const unique = dedupeAgentRulesPaths(rulesPaths); + if (unique.length === 0) return ""; + return `${header}\n${unique.map((path) => `rules: ${path}`).join("\n")}`; +} + +/** @deprecated Prefer formatAgentRulesPointers — kept for single-path call sites. */ +export function formatAgentRulesPointer(rulesPath: string, header: string): string { + return formatAgentRulesPointers([rulesPath], header); +} + +/** + * Collect `rules: ` lines from a `## Agent rules` (or custom header) block. + * Stops at the next markdown H2. + */ +export function extractAgentRulesPaths( + text: string, + header: string = AGENT_RULES_HEADER, +): string[] { + const lines = text.split("\n"); + const headerIndex = lines.findIndex((line) => line.trim() === header); + if (headerIndex < 0) return []; + + const paths: string[] = []; + for (let i = headerIndex + 1; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (/^##\s+/u.test(line)) break; + const match = RULES_LINE.exec(line); + if (match?.[1]) paths.push(match[1]); + } + return dedupeAgentRulesPaths(paths); +} + +/** + * Strip an existing agent-rules block (header + following `rules:` lines / blanks + * until the next H2 or non-rules content that is not blank). + */ +export function stripAgentRulesBlock(text: string, header: string = AGENT_RULES_HEADER): string { + const lines = text.split("\n"); + const headerIndex = lines.findIndex((line) => line.trim() === header); + if (headerIndex < 0) return text; + + let end = headerIndex + 1; + while (end < lines.length) { + const line = lines[end] ?? ""; + if (/^##\s+/u.test(line)) break; + if (line.trim() === "" || RULES_LINE.test(line)) { + end += 1; + continue; + } + break; + } + + const next = [...lines.slice(0, headerIndex), ...lines.slice(end)].join("\n"); + return next.replace(/^\n+/u, "").replace(/\n{3,}/gu, "\n\n"); +} + +/** + * Ensure `requiredPaths` appear in the unified agent-rules block (global first). + * Replaces any existing block with a merged, de-duplicated list. + */ +export function ensureAgentRulesPaths( + text: string, + requiredPaths: readonly string[], + header: string = AGENT_RULES_HEADER, +): string { + const existing = extractAgentRulesPaths(text, header); + const merged = dedupeAgentRulesPaths([...requiredPaths, ...existing]); + const without = stripAgentRulesBlock(text, header).trimEnd(); + const block = formatAgentRulesPointers(merged, header); + if (block.length === 0) return without; + if (without.length === 0) return block; + return `${block}\n\n${without}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 228ba405695..04d15ae78f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@typescript/native-preview': specifier: 7.0.0-dev.20260604.1 version: 7.0.0-dev.20260604.1 + dfx: + specifier: 1.0.15 + version: 1.0.15 jose: specifier: 6.2.2 version: 6.2.2 @@ -106,7 +109,7 @@ importers: version: 7.0.0-dev.20260604.1 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -170,7 +173,50 @@ importers: version: 4.3.0 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + + apps/discord-bot: + dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@microsoft/teams.apps': + specifier: 2.0.14 + version: 2.0.14 + '@t3tools/client-runtime': + specifier: workspace:* + version: link:../../packages/client-runtime + '@t3tools/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../../packages/shared + dfx: + specifier: 'catalog:' + version: 1.0.15(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + effect: + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) + playwright-core: + specifier: 1.60.0 + version: 1.60.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + tsx: + specifier: ^4.20.5 + version: 4.23.1 + vite-plus: + specifier: 'catalog:' + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -179,7 +225,7 @@ importers: version: link:../../packages/shared astro: specifier: ^7.0.3 - version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) devDependencies: '@astrojs/check': specifier: ^0.9.7 @@ -506,7 +552,7 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -627,13 +673,13 @@ importers: version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@rolldown/plugin-babel': specifier: ^0.2.0 - version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) + version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + version: 4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.161.0 - version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -648,7 +694,7 @@ importers: version: 0.3.0 '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0) + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -660,10 +706,10 @@ importers: version: 4.3.0 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -690,7 +736,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266) + version: 2.0.0-beta.65(acd28b8724bf091ed96dcbfc0141ce8e) drizzle-orm: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -715,10 +761,10 @@ importers: version: 1.0.0-rc.4 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: @@ -737,7 +783,7 @@ importers: version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -762,7 +808,7 @@ importers: version: 19.2.6 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -775,7 +821,7 @@ importers: version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -797,7 +843,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -819,7 +865,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -853,7 +899,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/ssh: dependencies: @@ -878,7 +924,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -900,7 +946,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -934,7 +980,7 @@ importers: version: 6.0.5 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -1207,6 +1253,14 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@azure/msal-common@16.11.3': + resolution: {integrity: sha512-VeXOW+t3Rdd9XGX6lVyIg3DhtjMR1JD8ARKcsnGbJFUWwAmF3sHL7GwZc/ZjEUfHESResAonETRYCuG06OBT7A==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.4.3': + resolution: {integrity: sha512-tumCMmzrRhKmTbYQg/7OlfbrIKcKaf8Ed0Fw3suUpRT3owFYljznVgxcfHe8RycQXY9uyROGiLD1GjhpF45AwA==} + engines: {node: '>=20'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -3132,6 +3186,26 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@microsoft/teams.api@2.0.14': + resolution: {integrity: sha512-mEHBgs7yklydQWTlfGFok5i7np2WxOKS3j6aMgRH0HVfiLqenPjGm0NAWKR/Hg29n9ODtGZZeuIXNCwIYQ8syA==} + engines: {node: '>=20'} + + '@microsoft/teams.apps@2.0.14': + resolution: {integrity: sha512-7re8a3pnAUSD5rAGlWbKQd07XtQJwyuZ4rI7EWyqBQT2iLhqUU1r4jtq3jQFpfov7xhADVV+oExCc7IbNyYuGw==} + engines: {node: '>=20'} + + '@microsoft/teams.cards@2.0.14': + resolution: {integrity: sha512-qrWnjJVgMPO8vvbLEiTeBLrrRLsr4rletQnuP9fPMXTTcrNx+Dh0gYWtQAOBvpoAuFo5tTXR/waNFNUm8IZtBw==} + engines: {node: '>=20'} + + '@microsoft/teams.common@2.0.14': + resolution: {integrity: sha512-cjf3b/2sr5RqqiOaDuogJQ9fUNRZjDSh9eBEAv6NqkigNNLLjDgEgEl5lLoJsPp4usvc3drwFkx6HayuEbT7vA==} + engines: {node: '>=20'} + + '@microsoft/teams.graph@2.0.14': + resolution: {integrity: sha512-PufhROXKhsC/h6ZKuquPzvNBB1Q9lHHu//vZGfRj3yD+hgtQkEz2YzXZDYC3h0bDN4ej2KtvkDGrsovqrnU0cQ==} + engines: {node: '>=20'} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -4803,6 +4877,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/bun@1.3.14': resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} @@ -4812,6 +4889,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4827,6 +4907,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.9': + resolution: {integrity: sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -4839,6 +4925,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -4851,12 +4940,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -4869,6 +4964,12 @@ packages: '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -4883,6 +4984,15 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -5265,6 +5375,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -5479,6 +5593,9 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -5642,6 +5759,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6118,6 +6238,11 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfx@1.0.15: + resolution: {integrity: sha512-LqHeOZLcOJB5D0+BUiy6XkthunolbJoSAAIOjXEiux5P6CzXPbJl0EkaWSDXTbkj/F/pdW9lk1zCC4XAl8yG+w==} + peerDependencies: + effect: 4.0.0-beta.102 + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -6129,6 +6254,13 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + discord-api-types@0.38.52: + resolution: {integrity: sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==} + + discord-verify@1.2.0: + resolution: {integrity: sha512-8qlrMROW8DhpzWWzgNq9kpeLDxKanWa4EDVoj/ASVv2nr+dSr4JPmu2tFSydf3hAGI/OIJTnZyD0JulMYIxx4w==} + engines: {node: '>=16'} + dmg-builder@26.15.6: resolution: {integrity: sha512-nr5vQxEhM0REomp1qiHbc6V99yrfBZy+wUU56VXADfSOlLj8PdLqsHiRe7b+FbqKesiyv4ax+k1GVwGonYKuCg==} @@ -6327,6 +6459,9 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -6954,6 +7089,15 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} @@ -6968,6 +7112,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7220,6 +7368,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -7454,6 +7606,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -7527,9 +7682,27 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7796,20 +7969,47 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -7852,6 +8052,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -8850,6 +9053,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -9142,6 +9349,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reftools@1.1.9: resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} @@ -9823,6 +10033,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -10935,6 +11150,13 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@azure/msal-common@16.11.3': {} + + '@azure/msal-node@5.4.3': + dependencies: + '@azure/msal-common': 16.11.3 + jsonwebtoken: 9.0.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -11694,14 +11916,14 @@ snapshots: '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) - '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': + '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1) magic-string: 0.30.21 unenv: 2.0.0-rc.24 optionalDependencies: rolldown: 1.1.5 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - workerd @@ -11715,13 +11937,13 @@ snapshots: '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(554b80dcfb9268a9eeb72fedc0ae62a5)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(8bc8fe0971ee9fb9aed27b3a3ef0f12a)': dependencies: '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -13245,6 +13467,49 @@ snapshots: transitivePeerDependencies: - supports-color + '@microsoft/teams.api@2.0.14': + dependencies: + '@microsoft/teams.cards': 2.0.14 + '@microsoft/teams.common': 2.0.14 + jwt-decode: 4.0.0 + qs: 6.15.3 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.apps@2.0.14': + dependencies: + '@azure/msal-node': 5.4.3 + '@microsoft/teams.api': 2.0.14 + '@microsoft/teams.common': 2.0.14 + '@microsoft/teams.graph': 2.0.14 + axios: 1.19.0 + cors: 2.8.6 + express: 5.2.1 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + reflect-metadata: 0.2.2 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.cards@2.0.14': {} + + '@microsoft/teams.common@2.0.14': + dependencies: + axios: 1.19.0 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.graph@2.0.14': + dependencies: + '@microsoft/teams.common': 2.0.14 + qs: 6.15.3 + transitivePeerDependencies: + - debug + - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) @@ -14409,7 +14674,7 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)': dependencies: '@babel/core': 7.29.7 picomatch: 4.0.4 @@ -14417,7 +14682,7 @@ snapshots: optionalDependencies: '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) '@babel/runtime': 7.29.7 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@rolldown/pluginutils@1.0.0-rc.17': optional: true @@ -14787,12 +15052,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@tanstack/devtools-event-client@0.4.3': {} @@ -14855,7 +15120,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -14872,7 +15137,7 @@ snapshots: zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - supports-color @@ -14951,6 +15216,12 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.12.4 + optional: true + '@types/bun@1.3.14': dependencies: bun-types: 1.3.14 @@ -14967,6 +15238,11 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.4 + optional: true + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -14981,6 +15257,22 @@ snapshots: '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.9': + dependencies: + '@types/node': 24.12.4 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + optional: true + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.9 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + optional: true + '@types/fs-extra@9.0.13': dependencies: '@types/node': 24.12.4 @@ -14993,6 +15285,9 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': + optional: true + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -15005,6 +15300,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.4 + '@types/keyv@3.1.4': dependencies: '@types/node': 24.12.4 @@ -15013,6 +15313,9 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mime@1.3.5': + optional: true + '@types/ms@2.1.0': {} '@types/nlcst@2.0.3': @@ -15027,6 +15330,12 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/qs@6.15.1': + optional: true + + '@types/range-parser@1.2.7': + optional: true + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -15043,6 +15352,24 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 24.12.4 + optional: true + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.12.4 + optional: true + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.12.4 + '@types/send': 0.17.6 + optional: true + '@types/statuses@2.0.6': {} '@types/unist@2.0.11': {} @@ -15116,36 +15443,36 @@ snapshots: optionalDependencies: ajv: 6.15.0 - '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)': + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -15162,14 +15489,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': + '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@vitest/pretty-format@4.1.9': dependencies: @@ -15195,7 +15522,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.138.0 '@oxc-project/types': 0.138.0 @@ -15207,6 +15534,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 + tsx: 4.23.1 typescript: 6.0.3 unrun: 0.2.39 yaml: 2.9.0 @@ -15350,6 +15678,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} agent-install@0.0.5: @@ -15393,7 +15727,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266): + alchemy@2.0.0-beta.65(acd28b8724bf091ed96dcbfc0141ce8e): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 @@ -15401,9 +15735,9 @@ snapshots: '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(554b80dcfb9268a9eeb72fedc0ae62a5) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(8bc8fe0971ee9fb9aed27b3a3ef0f12a) '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -15439,7 +15773,7 @@ snapshots: '@effect/sql-pg': 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) drizzle-kit: 1.0.0-rc.4 drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@mongodb-js/zstd' @@ -15574,7 +15908,7 @@ snapshots: assertion-error@2.0.1: {} - astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): + astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@astrojs/internal-helpers': 0.10.0 @@ -15627,8 +15961,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0) vfile: 6.0.3 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -15695,6 +16029,16 @@ snapshots: aws4fetch@1.0.20: {} + axios@1.19.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-dead-code-elimination@1.0.12: @@ -15952,6 +16296,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bufferutil@4.1.0: @@ -16399,6 +16745,13 @@ snapshots: dependencies: dequal: 2.0.3 + dfx@1.0.15(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)): + dependencies: + discord-api-types: 0.38.52 + effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) + optionalDependencies: + discord-verify: 1.2.0 + diff@8.0.3: {} diff@9.0.0: {} @@ -16408,6 +16761,13 @@ snapshots: minimatch: 3.1.5 p-limit: 3.1.0 + discord-api-types@0.38.52: {} + + discord-verify@1.2.0: + dependencies: + '@types/express': 4.17.25 + optional: true + dmg-builder@26.15.6(electron-builder-squirrel-windows@26.15.6): dependencies: app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6) @@ -16487,6 +16847,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488): @@ -17515,6 +17879,8 @@ snapshots: flow-enums-runtime@0.0.6: {} + follow-redirects@1.16.0: {} + fontace@0.4.1: dependencies: fontkitten: 1.0.3 @@ -17533,6 +17899,14 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -17892,6 +18266,13 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -18107,6 +18488,8 @@ snapshots: jiti@2.7.0: {} + jose@4.15.9: {} + jose@6.2.2: {} jose@6.2.3: {} @@ -18164,6 +18547,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -18171,6 +18567,29 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwks-rsa@3.2.2: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -18368,17 +18787,35 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + limiter@1.1.5: {} + locate-path@3.0.0: dependencies: p-locate: 3.0.0 path-exists: 3.0.0 + lodash.clonedeep@4.5.0: {} + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash.throttle@4.1.1: {} lodash@4.18.1: {} @@ -18414,6 +18851,11 @@ snapshots: dependencies: yallist: 4.0.0 + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + lru.min@1.1.4: {} lru_map@0.4.1: {} @@ -19356,7 +19798,7 @@ snapshots: outvariant@1.4.3: {} - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -19379,7 +19821,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@0.24.0: optionalDependencies: @@ -19390,7 +19832,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.24.0 '@oxlint-tsgolint/win32-x64': 0.24.0 - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -19412,7 +19854,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -19714,6 +20156,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -20211,6 +20655,8 @@ snapshots: dependencies: redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} + reftools@1.1.9: {} regenerate-unicode-properties@10.2.2: @@ -21068,6 +21514,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-fest@0.13.1: optional: true @@ -21353,25 +21805,25 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -21411,14 +21863,14 @@ snapshots: - utf-8-validate - yaml - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -21435,11 +21887,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) transitivePeerDependencies: - msw