diff --git a/apps/discord-bot/src/features/ResponseBridge.ts b/apps/discord-bot/src/features/ResponseBridge.ts index 82a421a4dbf8..f7716ca3dc3f 100644 --- a/apps/discord-bot/src/features/ResponseBridge.ts +++ b/apps/discord-bot/src/features/ResponseBridge.ts @@ -1,5 +1,6 @@ // @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, @@ -48,6 +49,7 @@ import { guessFileMimeType, isLocalFileSrc, replaceMarkdownLocalFileLinks, + resolveLocalFilePathOnDisk, stripMarkdownLocalFileLinks, type MarkdownLocalFileRef, } from "../presentation/markdownFiles.ts"; @@ -2878,6 +2880,7 @@ export const runBridge = ( refs: ReadonlyArray, alreadyPosted: ReadonlyArray, maxFiles: number, + worktreePath: string | null, ) => Effect.gen(function* () { const posted = new Set(alreadyPosted); @@ -2891,13 +2894,20 @@ export const runBridge = ( const files: DiscordUploadFile[] = []; const loadedSrcs: string[] = []; for (const ref of pending) { - const filePath = assertFilesystemFilePath(ref.src); + // 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, }); @@ -2920,11 +2930,23 @@ export const runBridge = ( 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 assetPath = assertFilesystemFilePath(ref.src); const url = yield* t3.createWorkspaceFileUrl({ threadId: input.t3ThreadId as ThreadId, - path: assetPath, + path: assetPathForUrl, }); const response = yield* Effect.tryPromise({ try: () => globalThis.fetch(url), @@ -2932,7 +2954,7 @@ export const runBridge = ( }); if (!response.ok) { yield* Effect.logWarning( - `Asset file download failed (${response.status}) for ${assetPath}`, + `Asset file download failed (${response.status}) for ${assetPathForUrl}`, ); return null as DiscordUploadFile | null; } @@ -2949,7 +2971,7 @@ export const runBridge = ( Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logWarning( - `Could not load markdown file for Discord (disk+asset): raw=${ref.rawSrc} path=${filePath}`, + `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; @@ -3593,6 +3615,7 @@ export const runBridge = ( pendingMarkdownFiles, state.postedMarkdownFileSrcs, fileSlotsLeft, + worktreePath, ); const files = [...imageFiles, ...mdLoaded.files, ...linkedFilesLoaded.files]; if (files.length === 0) return; @@ -3675,6 +3698,7 @@ export const runBridge = ( pendingMarkdownFiles, state.postedMarkdownFileSrcs, slots, + worktreePath, ); files.push(...linkedFilesLoaded.files); diff --git a/apps/discord-bot/src/presentation/markdownFiles.test.ts b/apps/discord-bot/src/presentation/markdownFiles.test.ts index 6594bf263d25..7b6700cd2a5a 100644 --- a/apps/discord-bot/src/presentation/markdownFiles.test.ts +++ b/apps/discord-bot/src/presentation/markdownFiles.test.ts @@ -1,3 +1,7 @@ +// @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 { @@ -6,6 +10,7 @@ import { guessFileMimeType, isLocalFileSrc, replaceMarkdownLocalFileLinks, + resolveLocalFilePathOnDisk, stripMarkdownLocalFileLinks, } from "./markdownFiles.ts"; @@ -85,3 +90,29 @@ describe("local file helpers", () => { 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 index 99cce986295c..430b31f8410c 100644 --- a/apps/discord-bot/src/presentation/markdownFiles.ts +++ b/apps/discord-bot/src/presentation/markdownFiles.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + import { assertFilesystemPath, guessImageMimeType, @@ -145,6 +149,45 @@ export function fileNameForLocalFileRef(ref: MarkdownLocalFileRef): string { 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)) {