Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions apps/discord-bot/src/features/ResponseBridge.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -48,6 +49,7 @@ import {
guessFileMimeType,
isLocalFileSrc,
replaceMarkdownLocalFileLinks,
resolveLocalFilePathOnDisk,
stripMarkdownLocalFileLinks,
type MarkdownLocalFileRef,
} from "../presentation/markdownFiles.ts";
Expand Down Expand Up @@ -2878,6 +2880,7 @@ export const runBridge = (
refs: ReadonlyArray<MarkdownLocalFileRef>,
alreadyPosted: ReadonlyArray<string>,
maxFiles: number,
worktreePath: string | null,
) =>
Effect.gen(function* () {
const posted = new Set(alreadyPosted);
Expand All @@ -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,
});

Expand All @@ -2920,19 +2930,31 @@ 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),
catch: (cause) => cause,
});
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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -3593,6 +3615,7 @@ export const runBridge = (
pendingMarkdownFiles,
state.postedMarkdownFileSrcs,
fileSlotsLeft,
worktreePath,
);
const files = [...imageFiles, ...mdLoaded.files, ...linkedFilesLoaded.files];
if (files.length === 0) return;
Expand Down Expand Up @@ -3675,6 +3698,7 @@ export const runBridge = (
pendingMarkdownFiles,
state.postedMarkdownFileSrcs,
slots,
worktreePath,
);
files.push(...linkedFilesLoaded.files);

Expand Down
31 changes: 31 additions & 0 deletions apps/discord-bot/src/presentation/markdownFiles.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -6,6 +10,7 @@ import {
guessFileMimeType,
isLocalFileSrc,
replaceMarkdownLocalFileLinks,
resolveLocalFilePathOnDisk,
stripMarkdownLocalFileLinks,
} from "./markdownFiles.ts";

Expand Down Expand Up @@ -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 });
}
});
});
43 changes: 43 additions & 0 deletions apps/discord-bot/src/presentation/markdownFiles.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";

import {
assertFilesystemPath,
guessImageMimeType,
Expand Down Expand Up @@ -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)) {
Expand Down
Loading