feat(web,server,core): attach files to a message from the composer (#121)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,25 @@
|
||||
* images -- data URL images are written to the session scratchpad and their paths appended to the
|
||||
* user text; http(s) URLs are referenced as-is; image messages are removed from the input;
|
||||
* image-free input is returned unchanged; images that fail to parse are replaced with an explanatory line.
|
||||
*
|
||||
* Plus appendAttachmentLines, the placement rule both attachment-line producers share (the
|
||||
* images above and the server's `[attached file: …]` uploads).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { imagesToScratchpadPaths } from "../src/internal/session-support.js";
|
||||
import { imageUrlMessage, userText } from "../src/omnimessage/index.js";
|
||||
import { appendAttachmentLines, imagesToScratchpadPaths } from "../src/internal/session-support.js";
|
||||
import {
|
||||
buildHandoffMessage,
|
||||
buildModelSwitchMessage,
|
||||
buildScheduledMessage,
|
||||
buildSkillsMessage,
|
||||
imageUrlMessage,
|
||||
parseHandoffMessage,
|
||||
parseModelSwitchMessage,
|
||||
userText,
|
||||
} from "../src/omnimessage/index.js";
|
||||
import type { TextPayload } from "../src/omnimessage/index.js";
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
@@ -80,3 +92,71 @@ describe("imagesToScratchpadPaths", () => {
|
||||
expect(p.text).toContain("could not be saved");
|
||||
});
|
||||
});
|
||||
|
||||
/** Text of the message at `index` of an appendAttachmentLines result. */
|
||||
const textAt = (out: ReturnType<typeof appendAttachmentLines>, index: number): string =>
|
||||
(out[index]!.payload as TextPayload).text;
|
||||
|
||||
describe("appendAttachmentLines placement", () => {
|
||||
const LINE = "[attached file: /d/scratchpad/s1/report.pdf]";
|
||||
|
||||
it("appends to the last user text message", () => {
|
||||
const out = appendAttachmentLines([userText("first"), userText("second")], [LINE]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(textAt(out, 0)).toBe("first");
|
||||
expect(textAt(out, 1)).toBe(`second\n\n${LINE}`);
|
||||
});
|
||||
|
||||
it("no user text at all: the lines become a message of their own", () => {
|
||||
const out = appendAttachmentLines([], [LINE]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(textAt(out, 0)).toBe(LINE);
|
||||
});
|
||||
|
||||
it("a whole-message origin block is left alone; the lines get their own message", () => {
|
||||
// The composer's files-only-plus-staged-handoff shape: the only text message is the origin
|
||||
// block, and both of these parsers require the block to be the WHOLE message — appending to
|
||||
// it would render the raw marker in a user bubble instead of a one-line banner.
|
||||
for (const block of [
|
||||
buildHandoffMessage({ agentId: "alpha", agentName: "Alpha", sessionId: "s0" }),
|
||||
buildModelSwitchMessage({ sessionId: "s0", prevModelId: "m0" }),
|
||||
]) {
|
||||
const out = appendAttachmentLines([userText(block)], [LINE]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(textAt(out, 0)).toBe(block);
|
||||
expect(textAt(out, 1)).toBe(LINE);
|
||||
}
|
||||
// …and the blocks still parse afterwards, which is the property that actually matters.
|
||||
const handoff = appendAttachmentLines(
|
||||
[userText(buildHandoffMessage({ agentId: "alpha", sessionId: "s0" }))],
|
||||
[LINE],
|
||||
);
|
||||
expect(parseHandoffMessage(textAt(handoff, 0))?.agentId).toBe("alpha");
|
||||
const switched = appendAttachmentLines(
|
||||
[userText(buildModelSwitchMessage({ sessionId: "s0" }))],
|
||||
[LINE],
|
||||
);
|
||||
expect(parseModelSwitchMessage(textAt(switched, 0))?.sessionId).toBe("s0");
|
||||
});
|
||||
|
||||
it("prefix blocks still take the lines after their body ([use_skills] / [scheduled_task])", () => {
|
||||
// The other direction: these two are parsed at index 0 and keep the body that follows, so a
|
||||
// trailing line is an ordinary footnote to the message and must NOT start a new one.
|
||||
for (const message of [
|
||||
buildSkillsMessage(["web-design"], "fix the layout"),
|
||||
buildScheduledMessage("nightly", "2026-07-29T02:00:00Z", "run the report"),
|
||||
]) {
|
||||
const out = appendAttachmentLines([userText(message)], [LINE]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(textAt(out, 0)).toBe(`${message}\n\n${LINE}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("an earlier ordinary message wins over a trailing origin block", () => {
|
||||
const block = buildHandoffMessage({ agentId: "alpha" });
|
||||
const out = appendAttachmentLines([userText("hello"), userText(block)], [LINE]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(textAt(out, 0)).toBe(`hello\n\n${LINE}`);
|
||||
expect(textAt(out, 1)).toBe(block);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user