Skip to content

Commit 133f318

Browse files
omegent-app[bot]patroza
authored andcommitted
fix(discord-bot): resolve worktree-relative files for Discord attach (#269)
Agents emit paths like `.plans/note.md` relative to the thread worktree. The bot process cwd is the package root, so disk reads failed and the asset-URL fallback rejected `.md` (preview types only). Resolve against the thread worktree first so local file links upload again. Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
1 parent f1043e5 commit 133f318

3 files changed

Lines changed: 103 additions & 5 deletions

File tree

apps/discord-bot/src/features/ResponseBridge.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off globalFetchInEffect:off unknownInEffectCatch:off nodeBuiltinImport:off
22
import * as NodeFSP from "node:fs/promises";
3+
import * as NodePath from "node:path";
34
import type {
45
ChatImageAttachment,
56
OrchestrationThread,
@@ -48,6 +49,7 @@ import {
4849
guessFileMimeType,
4950
isLocalFileSrc,
5051
replaceMarkdownLocalFileLinks,
52+
resolveLocalFilePathOnDisk,
5153
stripMarkdownLocalFileLinks,
5254
type MarkdownLocalFileRef,
5355
} from "../presentation/markdownFiles.ts";
@@ -2878,6 +2880,7 @@ export const runBridge = (
28782880
refs: ReadonlyArray<MarkdownLocalFileRef>,
28792881
alreadyPosted: ReadonlyArray<string>,
28802882
maxFiles: number,
2883+
worktreePath: string | null,
28812884
) =>
28822885
Effect.gen(function* () {
28832886
const posted = new Set(alreadyPosted);
@@ -2891,13 +2894,20 @@ export const runBridge = (
28912894
const files: DiscordUploadFile[] = [];
28922895
const loadedSrcs: string[] = [];
28932896
for (const ref of pending) {
2894-
const filePath = assertFilesystemFilePath(ref.src);
2897+
// Agents write worktree-relative paths (e.g. `.plans/note.md`). Resolve
2898+
// against the thread worktree before reading — bot cwd is not the project.
2899+
const resolved =
2900+
resolveLocalFilePathOnDisk(ref.src, worktreePath) ??
2901+
resolveLocalFilePathOnDisk(ref.rawSrc, worktreePath) ??
2902+
assertFilesystemFilePath(ref.src);
2903+
const filePath = resolved;
28952904
const name = fileNameForLocalFileRef(ref);
28962905
const mime = guessFileMimeType(filePath);
28972906

28982907
yield* Effect.logInfo("Loading markdown file for Discord multipart", {
28992908
rawSrc: ref.rawSrc,
29002909
filePath,
2910+
worktreePath,
29012911
name,
29022912
});
29032913

@@ -2920,19 +2930,31 @@ export const runBridge = (
29202930
continue;
29212931
}
29222932

2933+
// Asset URL preview rejects most text types (.md). Prefer a worktree-relative
2934+
// path for the RPC so the server can resolve under the project root.
2935+
const assetPathForUrl = (() => {
2936+
const raw = assertFilesystemFilePath(ref.src);
2937+
if (!NodePath.isAbsolute(raw)) return raw.replace(/^\.\//u, "");
2938+
const root = worktreePath?.trim() ?? "";
2939+
if (root !== "" && raw.startsWith(root)) {
2940+
const rel = raw.slice(root.length).replace(/^[/\\]+/u, "");
2941+
if (rel !== "") return rel;
2942+
}
2943+
return raw;
2944+
})();
2945+
29232946
const fromAsset = yield* Effect.gen(function* () {
2924-
const assetPath = assertFilesystemFilePath(ref.src);
29252947
const url = yield* t3.createWorkspaceFileUrl({
29262948
threadId: input.t3ThreadId as ThreadId,
2927-
path: assetPath,
2949+
path: assetPathForUrl,
29282950
});
29292951
const response = yield* Effect.tryPromise({
29302952
try: () => globalThis.fetch(url),
29312953
catch: (cause) => cause,
29322954
});
29332955
if (!response.ok) {
29342956
yield* Effect.logWarning(
2935-
`Asset file download failed (${response.status}) for ${assetPath}`,
2957+
`Asset file download failed (${response.status}) for ${assetPathForUrl}`,
29362958
);
29372959
return null as DiscordUploadFile | null;
29382960
}
@@ -2949,7 +2971,7 @@ export const runBridge = (
29492971
Effect.catchCause((cause) =>
29502972
Effect.gen(function* () {
29512973
yield* Effect.logWarning(
2952-
`Could not load markdown file for Discord (disk+asset): raw=${ref.rawSrc} path=${filePath}`,
2974+
`Could not load markdown file for Discord (disk+asset): raw=${ref.rawSrc} path=${filePath} worktree=${worktreePath ?? "null"}`,
29532975
);
29542976
yield* Effect.logError(cause);
29552977
return null as DiscordUploadFile | null;
@@ -3593,6 +3615,7 @@ export const runBridge = (
35933615
pendingMarkdownFiles,
35943616
state.postedMarkdownFileSrcs,
35953617
fileSlotsLeft,
3618+
worktreePath,
35963619
);
35973620
const files = [...imageFiles, ...mdLoaded.files, ...linkedFilesLoaded.files];
35983621
if (files.length === 0) return;
@@ -3675,6 +3698,7 @@ export const runBridge = (
36753698
pendingMarkdownFiles,
36763699
state.postedMarkdownFileSrcs,
36773700
slots,
3701+
worktreePath,
36783702
);
36793703
files.push(...linkedFilesLoaded.files);
36803704

apps/discord-bot/src/presentation/markdownFiles.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
2+
import * as NodeFS from "node:fs";
3+
import * as NodeOS from "node:os";
4+
import * as NodePath from "node:path";
15
import { describe, expect, it } from "vite-plus/test";
26

37
import {
@@ -6,6 +10,7 @@ import {
610
guessFileMimeType,
711
isLocalFileSrc,
812
replaceMarkdownLocalFileLinks,
13+
resolveLocalFilePathOnDisk,
914
stripMarkdownLocalFileLinks,
1015
} from "./markdownFiles.ts";
1116

@@ -85,3 +90,29 @@ describe("local file helpers", () => {
8590
expect(guessFileMimeType("/tmp/voice.mp3")).toBe("audio/mpeg");
8691
});
8792
});
93+
94+
describe("resolveLocalFilePathOnDisk", () => {
95+
it("resolves worktree-relative plan paths that agents emit", () => {
96+
const tempRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-discord-md-file-"));
97+
try {
98+
const plansDir = NodePath.join(tempRoot, ".plans");
99+
const relativeFile = ".plans/abas-markisen-agent-collisions.md";
100+
const absoluteFile = NodePath.join(tempRoot, relativeFile);
101+
NodeFS.mkdirSync(plansDir, { recursive: true });
102+
NodeFS.writeFileSync(absoluteFile, "# note\n", "utf8");
103+
104+
expect(resolveLocalFilePathOnDisk(relativeFile, tempRoot)).toBe(
105+
NodePath.normalize(absoluteFile),
106+
);
107+
expect(resolveLocalFilePathOnDisk(`./${relativeFile}`, tempRoot)).toBe(
108+
NodePath.normalize(absoluteFile),
109+
);
110+
// Absolute path still works when present.
111+
expect(resolveLocalFilePathOnDisk(absoluteFile, null)).toBe(NodePath.normalize(absoluteFile));
112+
// Without worktree, bot cwd cannot see the project-relative path.
113+
expect(resolveLocalFilePathOnDisk(relativeFile, null)).toBeNull();
114+
} finally {
115+
NodeFS.rmSync(tempRoot, { recursive: true, force: true });
116+
}
117+
});
118+
});

apps/discord-bot/src/presentation/markdownFiles.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
2+
import * as NodeFS from "node:fs";
3+
import * as NodePath from "node:path";
4+
15
import {
26
assertFilesystemPath,
37
guessImageMimeType,
@@ -145,6 +149,45 @@ export function fileNameForLocalFileRef(ref: MarkdownLocalFileRef): string {
145149
return base;
146150
}
147151

152+
/**
153+
* Resolve a local markdown-linked file to an absolute path on disk.
154+
*
155+
* Agents usually write worktree-relative targets (`.plans/note.md`, `./out.csv`).
156+
* The Discord bot process cwd is the bot package — resolve against `worktreePath`
157+
* first so relative embeds actually upload.
158+
*/
159+
export function resolveLocalFilePathOnDisk(
160+
path: string,
161+
worktreePath?: string | null,
162+
): string | null {
163+
const normalized = assertFilesystemPath(path);
164+
if (normalized === "" || /^https?:\/\//i.test(normalized) || /^data:/i.test(normalized)) {
165+
return null;
166+
}
167+
168+
if (NodePath.isAbsolute(normalized)) {
169+
return NodeFS.existsSync(normalized) ? NodePath.normalize(normalized) : null;
170+
}
171+
172+
const rel = normalized.replace(/^\.\//u, "");
173+
const roots: string[] = [];
174+
const worktree = worktreePath?.trim() ?? "";
175+
if (worktree !== "") {
176+
roots.push(worktree);
177+
}
178+
// Fallbacks when worktree is unknown (local / no-worktree threads).
179+
roots.push(process.cwd());
180+
181+
for (const root of roots) {
182+
const candidate = NodePath.join(root, rel);
183+
if (NodeFS.existsSync(candidate) && NodeFS.statSync(candidate).isFile()) {
184+
return NodePath.normalize(candidate);
185+
}
186+
}
187+
188+
return null;
189+
}
190+
148191
export function guessFileMimeType(filePath: string): string {
149192
const lower = filePath.toLowerCase();
150193
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(lower)) {

0 commit comments

Comments
 (0)