Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
808 lines
24 KiB
TypeScript
808 lines
24 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { Editor, type Range } from "@tiptap/core";
|
|
import { Schema } from "@tiptap/pm/model";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import {
|
|
attrsToMentionItem,
|
|
createAgentFileMentionExtension,
|
|
createAgentSessionMarkdownLink,
|
|
createAgentSessionMentionHref,
|
|
expandRangeOverMentionPlaceholder,
|
|
formatAgentMentionMarkdown,
|
|
mentionItemToAttrs,
|
|
parseAgentMentionMarkdown,
|
|
parseMentionItemFromHref
|
|
} from "./agentFileMentionExtension";
|
|
import {
|
|
registerAgentCustomMentionKind,
|
|
resetAgentCustomMentionKindsForTests
|
|
} from "../../../shared/agentCustomMentionKinds";
|
|
import { createRichTextMentionHref } from "@tutti-os/ui-rich-text/core";
|
|
import {
|
|
agentComposerFileMentionReferences,
|
|
createAgentComposerFileMentionMarkdown,
|
|
createAgentSessionHandoffPrompt,
|
|
updateAgentComposerFileMentions
|
|
} from "./agentMentionMarkdown";
|
|
|
|
const placeholderSchema = new Schema({
|
|
nodes: {
|
|
doc: { content: "block+" },
|
|
paragraph: {
|
|
group: "block",
|
|
content: "inline*",
|
|
toDOM: () => ["p", 0],
|
|
parseDOM: [{ tag: "p" }]
|
|
},
|
|
// Leaf inline node standing in for an already-inserted mention chip.
|
|
chip: { group: "inline", inline: true, atom: true },
|
|
text: { group: "inline" }
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Build a fake editor whose paragraph is `text`, then locate the `@` and return
|
|
* an empty-query suggestion range over it — mirroring what the Suggestion plugin
|
|
* hands {@link expandRangeOverMentionPlaceholder} when a user types `@`.
|
|
*/
|
|
function editorForPlaceholder(text: string): { editor: Editor; range: Range } {
|
|
const doc = placeholderSchema.node("doc", null, [
|
|
placeholderSchema.node("paragraph", null, [placeholderSchema.text(text)])
|
|
]);
|
|
const editor = { state: { doc } } as unknown as Editor;
|
|
const atPos = text.indexOf("@") + 1; // +1 for the paragraph open token
|
|
return { editor, range: { from: atPos, to: atPos + 1 } };
|
|
}
|
|
|
|
describe("createAgentFileMentionExtension", () => {
|
|
it("notifies the external palette synchronously when an @ suggestion starts", () => {
|
|
const onSuggestionChange = vi.fn();
|
|
const editor = new Editor({
|
|
element: document.createElement("div"),
|
|
extensions: [
|
|
StarterKit,
|
|
createAgentFileMentionExtension({ onSuggestionChange })
|
|
],
|
|
content: "<p></p>"
|
|
});
|
|
|
|
editor.commands.insertContent("@");
|
|
|
|
expect(onSuggestionChange).toHaveBeenCalledOnce();
|
|
expect(onSuggestionChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({ query: "", text: "@" })
|
|
);
|
|
editor.destroy();
|
|
});
|
|
});
|
|
|
|
describe("parseAgentMentionMarkdown", () => {
|
|
it("round-trips composer file identity and upload status", () => {
|
|
const uploading = createAgentComposerFileMentionMarkdown({
|
|
id: "file-1",
|
|
name: "report.pdf",
|
|
status: "uploading"
|
|
});
|
|
|
|
expect(parseAgentMentionMarkdown(uploading)).toMatchObject({
|
|
item: {
|
|
kind: "file",
|
|
attachmentId: "file-1",
|
|
attachmentStatus: "uploading",
|
|
name: "report.pdf"
|
|
}
|
|
});
|
|
expect(
|
|
agentComposerFileMentionReferences(`before ${uploading} after`)
|
|
).toEqual([expect.objectContaining({ id: "file-1", status: "uploading" })]);
|
|
expect(
|
|
updateAgentComposerFileMentions(
|
|
uploading,
|
|
new Map([["file-1", { status: "ready" }]])
|
|
)
|
|
).toBe(
|
|
createAgentComposerFileMentionMarkdown({
|
|
id: "file-1",
|
|
name: "report.pdf",
|
|
status: "ready"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("preserves error codes while updating background composer files", () => {
|
|
const uploading = createAgentComposerFileMentionMarkdown({
|
|
id: "file-1",
|
|
name: "report.pdf",
|
|
status: "uploading"
|
|
});
|
|
const failed = updateAgentComposerFileMentions(
|
|
uploading,
|
|
new Map([
|
|
["file-1", { errorCode: "file_too_large", status: "error" as const }]
|
|
])
|
|
);
|
|
|
|
expect(agentComposerFileMentionReferences(failed)).toEqual([
|
|
expect.objectContaining({
|
|
errorCode: "file_too_large",
|
|
id: "file-1",
|
|
status: "error"
|
|
})
|
|
]);
|
|
expect(
|
|
updateAgentComposerFileMentions(
|
|
failed,
|
|
new Map([["file-1", { status: "ready" }]])
|
|
)
|
|
).toBe(
|
|
createAgentComposerFileMentionMarkdown({
|
|
id: "file-1",
|
|
name: "report.pdf",
|
|
status: "ready"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("accepts plain workspace file markdown links without an @ prefix", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown("[README.md](/workspace/docs/README.md)")
|
|
).toEqual({
|
|
item: {
|
|
kind: "file",
|
|
href: "/workspace/docs/README.md",
|
|
path: "/workspace/docs/README.md",
|
|
name: "README.md",
|
|
entryKind: "unknown",
|
|
directoryPath: "/workspace/docs"
|
|
},
|
|
end: 38
|
|
});
|
|
});
|
|
|
|
it("keeps trailing-slash local paths as directory mentions", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@superpowers](/Users/test/project/tutti/superpowers/)"
|
|
)
|
|
).toEqual({
|
|
item: {
|
|
kind: "file",
|
|
href: "/Users/test/project/tutti/superpowers/",
|
|
path: "/Users/test/project/tutti/superpowers/",
|
|
name: "superpowers",
|
|
entryKind: "directory",
|
|
directoryPath: "/Users/test/project/tutti"
|
|
},
|
|
end: 54
|
|
});
|
|
});
|
|
|
|
it("normalizes Windows separators when deriving a file directory", () => {
|
|
const href = String.raw`C:\Users\agent\workspace\report.md`;
|
|
expect(parseMentionItemFromHref({ name: "report.md", href })).toMatchObject(
|
|
{
|
|
kind: "file",
|
|
path: href,
|
|
directoryPath: "C:/Users/agent/workspace"
|
|
}
|
|
);
|
|
});
|
|
|
|
it("recognizes a Windows trailing separator as a local folder", () => {
|
|
const href = "C:\\Users\\agent\\workspace\\generated\\";
|
|
expect(parseMentionItemFromHref({ name: "generated", href })).toMatchObject(
|
|
{
|
|
kind: "file",
|
|
path: href,
|
|
entryKind: "directory",
|
|
directoryPath: "C:/Users/agent/workspace"
|
|
}
|
|
);
|
|
});
|
|
|
|
it("does not classify trailing-slash URLs as directory mentions", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown("[@OpenAI](https://openai.com/)")
|
|
).toMatchObject({
|
|
item: {
|
|
href: "https://openai.com/",
|
|
entryKind: "unknown"
|
|
}
|
|
});
|
|
});
|
|
|
|
it("accepts generic session mention hrefs", () => {
|
|
const parsed = parseAgentMentionMarkdown(
|
|
"[@Session](mention://agent-session/session-1?agentTargetId=local%3Aclaude-code&workspaceId=workspace-1)"
|
|
);
|
|
expect(parsed).toMatchObject({
|
|
item: {
|
|
kind: "session",
|
|
workspaceId: "workspace-1",
|
|
targetId: "session-1",
|
|
agentTargetId: "local:claude-code",
|
|
name: "Session"
|
|
}
|
|
});
|
|
expect(mentionItemToAttrs(parsed!.item).iconUrl).toBeUndefined();
|
|
});
|
|
|
|
it("does not invent presentation metadata for a pasted session mention", () => {
|
|
const parsed = parseAgentMentionMarkdown(
|
|
"[@Cursor session](mention://agent-session/session-1?agentTargetId=local%3Acursor&workspaceId=workspace-1)"
|
|
);
|
|
|
|
expect(mentionItemToAttrs(parsed!.item).iconUrl).toBeUndefined();
|
|
});
|
|
|
|
it("parses registered custom mention kinds into custom items", () => {
|
|
registerAgentCustomMentionKind({
|
|
kind: "external-note",
|
|
present: (mention) => ({
|
|
name: mention.label,
|
|
summary: mention.scope?.preview?.trim() || undefined,
|
|
workspaceId: mention.scope?.spaceId?.trim() || undefined
|
|
})
|
|
});
|
|
try {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@两条外部笔记](mention://external-note/note-a?ids=note-a%2Cnote-b&preview=hello&spaceId=space-1)"
|
|
)
|
|
).toMatchObject({
|
|
item: {
|
|
kind: "custom",
|
|
customKind: "external-note",
|
|
workspaceId: "space-1",
|
|
targetId: "note-a",
|
|
summary: "hello",
|
|
name: "两条外部笔记"
|
|
}
|
|
});
|
|
} finally {
|
|
resetAgentCustomMentionKindsForTests();
|
|
}
|
|
});
|
|
|
|
it("rejects unregistered custom mention kinds", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@两条外部笔记](mention://external-note/note-a?spaceId=space-1)"
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it("round-trips custom items through attrs", () => {
|
|
registerAgentCustomMentionKind({
|
|
kind: "external-note",
|
|
present: (mention) => ({
|
|
name: mention.label,
|
|
summary: mention.scope?.preview?.trim() || undefined,
|
|
workspaceId: mention.scope?.spaceId?.trim() || undefined
|
|
})
|
|
});
|
|
try {
|
|
const href =
|
|
"mention://external-note/note-a?ids=note-a%2Cnote-b&preview=hello&spaceId=space-1";
|
|
const parsed = parseMentionItemFromHref({
|
|
name: "两条外部笔记",
|
|
href
|
|
});
|
|
expect(parsed).not.toBeNull();
|
|
expect(attrsToMentionItem(mentionItemToAttrs(parsed!))).toEqual(parsed);
|
|
// markdown 序列化走 default 分支(canonical href round-trip 无损)。
|
|
expect(formatAgentMentionMarkdown(parsed!)).toContain(
|
|
"mention://external-note/note-a"
|
|
);
|
|
} finally {
|
|
resetAgentCustomMentionKindsForTests();
|
|
}
|
|
});
|
|
|
|
it("keeps the canonical custom mention label when chip presentation changes it", () => {
|
|
registerAgentCustomMentionKind({
|
|
kind: "browser-element",
|
|
present: (mention) => ({ name: `<${mention.label}>` })
|
|
});
|
|
try {
|
|
const prompt =
|
|
"[@a](mention://browser-element/browser-element:1?path=%2Ftmp%2Fa.txt&tag=a&workspaceId=workspace-1)";
|
|
const parsed = parseAgentMentionMarkdown(prompt);
|
|
|
|
expect(parsed?.item).toMatchObject({
|
|
kind: "custom",
|
|
name: "<a>",
|
|
sourceLabel: "a"
|
|
});
|
|
expect(formatAgentMentionMarkdown(parsed!.item)).toBe(prompt);
|
|
expect(
|
|
formatAgentMentionMarkdown(
|
|
attrsToMentionItem(mentionItemToAttrs(parsed!.item))
|
|
)
|
|
).toBe(prompt);
|
|
} finally {
|
|
resetAgentCustomMentionKindsForTests();
|
|
}
|
|
});
|
|
|
|
it("accepts workspace app mention hrefs without an @ prefix", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[任务管理](mention://workspace-app/issue-manager?workspaceId=workspace-1)"
|
|
)
|
|
).toMatchObject({
|
|
item: {
|
|
kind: "workspace-app",
|
|
workspaceId: "workspace-1",
|
|
targetId: "issue-manager",
|
|
appId: "issue-manager",
|
|
name: "任务管理"
|
|
},
|
|
end: 69
|
|
});
|
|
});
|
|
|
|
it("accepts agent target mention hrefs without an @ prefix", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[Codex](mention://agent-target/local:codex?workspaceId=workspace-1)"
|
|
)
|
|
).toMatchObject({
|
|
item: {
|
|
kind: "agent-target",
|
|
workspaceId: "workspace-1",
|
|
targetId: "local:codex",
|
|
name: "Codex"
|
|
}
|
|
});
|
|
});
|
|
|
|
it("accepts workspace issue mention hrefs without an @ prefix", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[做一个音乐app](mention://workspace-issue/issue-1?workspaceId=workspace-1&topicId=default)"
|
|
)
|
|
).toMatchObject({
|
|
item: {
|
|
kind: "workspace-issue",
|
|
workspaceId: "workspace-1",
|
|
targetId: "issue-1",
|
|
topicId: "default",
|
|
name: "做一个音乐app"
|
|
}
|
|
});
|
|
});
|
|
|
|
it("rejects legacy query-only provider mention hrefs", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@Session](mention://agent-session?workspaceId=workspace-1&id=session-1)"
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@Issue](mention://workspace-issue?workspaceId=workspace-1&id=issue-1)"
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@App](mention://workspace-app?workspaceId=workspace-1&appId=app-1)"
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects old serialized mention fields", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@App](mention://workspace-app/app-1?workspaceId=workspace-1&link=https%3A%2F%2Fexample.com)"
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@App](mention://workspace-app/app-1?workspaceId=workspace-1&meta.iconUrl=icon.png)"
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it("hydrates workspace app factory mentions with an entity path", () => {
|
|
expect(
|
|
parseAgentMentionMarkdown(
|
|
"[@Create App](mention://workspace-app-factory/create)"
|
|
)
|
|
).toMatchObject({
|
|
item: {
|
|
kind: "workspace-app-factory",
|
|
href: "mention://workspace-app-factory/create",
|
|
workspaceId: "",
|
|
targetId: "create",
|
|
jobId: "",
|
|
name: "Create App"
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("attrsToMentionItem", () => {
|
|
it("prefers workspaceId attrs for session mentions", () => {
|
|
expect(
|
|
attrsToMentionItem({
|
|
kind: "session",
|
|
href: "mention://agent-session/session-1?agentTargetId=local%3Aclaude-code&workspaceId=workspace-1",
|
|
workspaceId: "workspace-1",
|
|
targetId: "session-1",
|
|
name: "Session"
|
|
})
|
|
).toMatchObject({
|
|
kind: "session",
|
|
workspaceId: "workspace-1",
|
|
targetId: "session-1",
|
|
agentTargetId: "local:claude-code"
|
|
});
|
|
});
|
|
|
|
it("preserves an explicit session agent icon through attrs", () => {
|
|
const item = attrsToMentionItem(
|
|
mentionItemToAttrs({
|
|
kind: "session",
|
|
href: "mention://agent-session/session-1?agentTargetId=shared-agent%3Acursor&workspaceId=workspace-1",
|
|
workspaceId: "workspace-1",
|
|
targetId: "session-1",
|
|
agentTargetId: "shared-agent:cursor",
|
|
name: "Session",
|
|
title: "Session",
|
|
scope: "collab_sessions",
|
|
initiatorName: "",
|
|
agentName: "Cursor",
|
|
agentIconUrl: "tutti://agent/cursor.svg"
|
|
})
|
|
);
|
|
|
|
expect(item).toMatchObject({
|
|
kind: "session",
|
|
agentIconUrl: "tutti://agent/cursor.svg"
|
|
});
|
|
});
|
|
|
|
it("accepts workspace issue attrs", () => {
|
|
expect(
|
|
attrsToMentionItem({
|
|
kind: "workspace-issue",
|
|
href: "mention://workspace-issue/issue-1?workspaceId=workspace-1",
|
|
workspaceId: "workspace-1",
|
|
targetId: "issue-1",
|
|
name: "Issue"
|
|
})
|
|
).toMatchObject({
|
|
kind: "workspace-issue",
|
|
workspaceId: "workspace-1",
|
|
targetId: "issue-1"
|
|
});
|
|
});
|
|
|
|
it("accepts workspace app factory attrs", () => {
|
|
expect(
|
|
attrsToMentionItem({
|
|
kind: "workspace-app-factory",
|
|
href: "mention://workspace-app-factory/create",
|
|
name: "Create App"
|
|
})
|
|
).toMatchObject({
|
|
kind: "workspace-app-factory",
|
|
workspaceId: "",
|
|
targetId: "",
|
|
jobId: "",
|
|
href: "mention://workspace-app-factory/create"
|
|
});
|
|
});
|
|
|
|
it("round-trips file mention thumbnail attrs", () => {
|
|
expect(
|
|
attrsToMentionItem({
|
|
kind: "file",
|
|
name: "diagram.png",
|
|
href: "/workspace/diagram.png",
|
|
path: "/workspace/diagram.png",
|
|
entryKind: "file",
|
|
directoryPath: "/workspace",
|
|
thumbnailUrl: "data:image/png;base64,thumb"
|
|
})
|
|
).toMatchObject({
|
|
kind: "file",
|
|
name: "diagram.png",
|
|
thumbnailUrl: "data:image/png;base64,thumb"
|
|
});
|
|
});
|
|
|
|
it("parses workspace-reference attrs into a resolvable handle", () => {
|
|
expect(
|
|
attrsToMentionItem({
|
|
kind: "workspace-reference",
|
|
href: "mention://workspace-reference/topic-1?groupId=issue-1&source=task&workspaceId=ws-1",
|
|
name: "Design",
|
|
targetId: "topic-1",
|
|
source: "task",
|
|
groupId: "issue-1",
|
|
workspaceId: "ws-1",
|
|
fileCount: "3"
|
|
})
|
|
).toMatchObject({
|
|
kind: "workspace-reference",
|
|
source: "task",
|
|
targetId: "topic-1",
|
|
groupId: "issue-1",
|
|
workspaceId: "ws-1",
|
|
fileCount: 3
|
|
});
|
|
});
|
|
|
|
it("defaults a malformed fileCount to zero", () => {
|
|
expect(
|
|
attrsToMentionItem({
|
|
kind: "workspace-reference",
|
|
href: "mention://workspace-reference/app-1?source=app&workspaceId=ws-1",
|
|
name: "Design",
|
|
targetId: "app-1",
|
|
source: "app",
|
|
workspaceId: "ws-1",
|
|
fileCount: "nope"
|
|
})
|
|
).toMatchObject({
|
|
kind: "workspace-reference",
|
|
source: "app",
|
|
fileCount: 0
|
|
});
|
|
});
|
|
|
|
it("round-trips agent target attrs", () => {
|
|
expect(
|
|
attrsToMentionItem({
|
|
kind: "agent-target",
|
|
href: "mention://agent-target/local:claude-code?workspaceId=ws-1",
|
|
name: "Claude Code",
|
|
targetId: "local:claude-code",
|
|
workspaceId: "ws-1",
|
|
description: "Run Claude Code locally",
|
|
agentProviderId: "claude-code",
|
|
iconUrl: "tutti://agent/claude-code.svg"
|
|
})
|
|
).toMatchObject({
|
|
kind: "agent-target",
|
|
targetId: "local:claude-code",
|
|
workspaceId: "ws-1",
|
|
agentProviderId: "claude-code",
|
|
iconUrl: "tutti://agent/claude-code.svg"
|
|
});
|
|
});
|
|
|
|
it("round-trips workspace issue icon presentation without serializing it", () => {
|
|
const item = {
|
|
kind: "workspace-issue" as const,
|
|
href: "mention://workspace-issue/issue-1?workspaceId=ws-1",
|
|
workspaceId: "ws-1",
|
|
targetId: "issue-1",
|
|
name: "Task Center",
|
|
title: "Task Center",
|
|
iconUrl: "https://icons.example/task-center.png"
|
|
};
|
|
|
|
expect(attrsToMentionItem(mentionItemToAttrs(item))).toMatchObject({
|
|
kind: "workspace-issue",
|
|
iconUrl: "https://icons.example/task-center.png"
|
|
});
|
|
expect(formatAgentMentionMarkdown(item)).toBe(
|
|
"[@Task Center](mention://workspace-issue/issue-1?workspaceId=ws-1)"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("agent session mention links", () => {
|
|
it("builds the stable agent-session mention href", () => {
|
|
expect(
|
|
createAgentSessionMentionHref({
|
|
agentSessionId: "session-1",
|
|
agentTargetId: "local:claude-code",
|
|
label: "Session 1",
|
|
workspaceId: "room-1"
|
|
})
|
|
).toBe(
|
|
"mention://agent-session/session-1?agentTargetId=local%3Aclaude-code&workspaceId=room-1"
|
|
);
|
|
});
|
|
|
|
it("formats composer session mentions with the mention prefix", () => {
|
|
expect(
|
|
createAgentSessionMarkdownLink({
|
|
agentSessionId: "session-1",
|
|
agentTargetId: "local:codex",
|
|
label: "Session 1",
|
|
workspaceId: "room-1",
|
|
withAtPrefix: true
|
|
})
|
|
).toBe(
|
|
"[@Session 1](mention://agent-session/session-1?agentTargetId=local%3Acodex&workspaceId=room-1)"
|
|
);
|
|
});
|
|
|
|
it("formats copied session links without the mention prefix", () => {
|
|
expect(
|
|
createAgentSessionMarkdownLink({
|
|
agentSessionId: "session-1",
|
|
label: "Session [draft] \\ one",
|
|
workspaceId: "room-1",
|
|
withAtPrefix: false
|
|
})
|
|
).toBe(
|
|
"[Session \\[draft\\] \\\\ one](mention://agent-session/session-1?workspaceId=room-1)"
|
|
);
|
|
});
|
|
|
|
it("preserves the agent target when formatting a session mention item", () => {
|
|
expect(
|
|
formatAgentMentionMarkdown({
|
|
kind: "session",
|
|
href: "mention://agent-session/session-1?workspaceId=room-1",
|
|
workspaceId: "room-1",
|
|
targetId: "session-1",
|
|
agentTargetId: "local:claude-code",
|
|
name: "Taylor & Claude Code Session 1",
|
|
title: "Session 1",
|
|
scope: "my_sessions",
|
|
initiatorName: "Taylor",
|
|
agentName: "Claude Code"
|
|
})
|
|
).toBe(
|
|
"[@Session 1](mention://agent-session/session-1?agentTargetId=local%3Aclaude-code&workspaceId=room-1)"
|
|
);
|
|
});
|
|
|
|
it("builds the canonical session handoff draft with a trailing cursor space", () => {
|
|
expect(
|
|
createAgentSessionHandoffPrompt({
|
|
agentSessionId: "session-1",
|
|
agentTargetId: "local:claude-code",
|
|
label: "Session 1",
|
|
workspaceId: "room-1"
|
|
})
|
|
).toBe(
|
|
"[@Session 1](mention://agent-session/session-1?agentTargetId=local%3Aclaude-code&workspaceId=room-1) "
|
|
);
|
|
});
|
|
|
|
it("preserves the agent target from an existing session href", () => {
|
|
expect(
|
|
formatAgentMentionMarkdown({
|
|
kind: "session",
|
|
href: "mention://agent-session/session-1?agentTargetId=local%3Aclaude-code&workspaceId=room-1",
|
|
workspaceId: "room-1",
|
|
targetId: "session-1",
|
|
name: "Session 1",
|
|
title: "Session 1",
|
|
scope: "my_sessions",
|
|
initiatorName: "Taylor",
|
|
agentName: "Claude Code"
|
|
})
|
|
).toBe(
|
|
"[@Session 1](mention://agent-session/session-1?agentTargetId=local%3Aclaude-code&workspaceId=room-1)"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("formatAgentMentionMarkdown — agent target", () => {
|
|
it("renders the agent-target mention href without falling back to workspace-app", () => {
|
|
expect(
|
|
formatAgentMentionMarkdown({
|
|
kind: "agent-target",
|
|
href: "mention://agent-target/local:codex?workspaceId=ws-1",
|
|
workspaceId: "ws-1",
|
|
targetId: "local:codex",
|
|
name: "Codex",
|
|
agentProviderId: "codex"
|
|
})
|
|
).toBe("[@Codex](mention://agent-target/local:codex?workspaceId=ws-1)");
|
|
});
|
|
});
|
|
|
|
describe("formatAgentMentionMarkdown — workspace reference", () => {
|
|
const referenceItem = {
|
|
kind: "workspace-reference" as const,
|
|
href: "mention://workspace-reference/app-1?source=app&workspaceId=ws-1",
|
|
workspaceId: "ws-1",
|
|
targetId: "app-1",
|
|
source: "app" as const,
|
|
name: "Design",
|
|
fileCount: 2
|
|
};
|
|
|
|
it("renders one chip link (no expansion)", () => {
|
|
expect(formatAgentMentionMarkdown(referenceItem)).toBe(
|
|
"[@Design](mention://workspace-reference/app-1?count=2&source=app&workspaceId=ws-1)"
|
|
);
|
|
});
|
|
|
|
it("omits display icon data from the prompt href", () => {
|
|
expect(
|
|
formatAgentMentionMarkdown({
|
|
...referenceItem,
|
|
href: "mention://workspace-reference/app-1?icon=data%3Aimage%2Fpng%3Bbase64%2Cabc&source=app&workspaceId=ws-1",
|
|
iconUrl: "data:image/png;base64,abc"
|
|
})
|
|
).toBe(
|
|
"[@Design](mention://workspace-reference/app-1?count=2&source=app&workspaceId=ws-1)"
|
|
);
|
|
});
|
|
|
|
it("parses legacy href icon data for chip display", () => {
|
|
const href = createRichTextMentionHref({
|
|
providerId: "workspace-reference",
|
|
entityId: "topic-1",
|
|
label: "Design",
|
|
scope: {
|
|
workspaceId: "ws-1",
|
|
source: "task",
|
|
groupId: "issue-1",
|
|
icon: "https://icons/app-1.png",
|
|
count: "5"
|
|
}
|
|
});
|
|
const parsed = parseAgentMentionMarkdown(`[@Design](${href})`);
|
|
expect(parsed?.item).toMatchObject({
|
|
kind: "workspace-reference",
|
|
name: "Design",
|
|
source: "task",
|
|
targetId: "topic-1",
|
|
groupId: "issue-1",
|
|
iconUrl: "https://icons/app-1.png",
|
|
fileCount: 5
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("expandRangeOverMentionPlaceholder", () => {
|
|
it("swallows a surrounding `{ … }` mention placeholder plus one trailing space", () => {
|
|
// "让 { @ } 审查": paragraph offsets 1=让 2=' ' 3={ 4=' ' 5=@ 6=' ' 7=} 8=' ' 9=审 10=查
|
|
const { editor, range } = editorForPlaceholder("让 { @ } 审查");
|
|
expect(expandRangeOverMentionPlaceholder(editor, range)).toEqual({
|
|
from: 3,
|
|
to: 9
|
|
});
|
|
});
|
|
|
|
it("swallows a placeholder that still carries its seeded label text", () => {
|
|
// The seeded `@agent` label survives when the user types a fresh `@` inside.
|
|
const { editor, range } = editorForPlaceholder("让 { @agent } 审查");
|
|
const expanded = expandRangeOverMentionPlaceholder(editor, range);
|
|
const doc = editor.state.doc;
|
|
expect(doc.textBetween(expanded.from, expanded.to)).toBe("{ @agent } ");
|
|
});
|
|
|
|
it("leaves an ordinary `@` mention (no enclosing braces) untouched", () => {
|
|
const { editor, range } = editorForPlaceholder("ping @");
|
|
expect(expandRangeOverMentionPlaceholder(editor, range)).toEqual(range);
|
|
});
|
|
|
|
it("does not cross a closing brace when scanning left", () => {
|
|
const { editor, range } = editorForPlaceholder("a } @ } b");
|
|
expect(expandRangeOverMentionPlaceholder(editor, range)).toEqual(range);
|
|
});
|
|
|
|
it("leaves large, user-authored braces intact", () => {
|
|
const { editor, range } = editorForPlaceholder(
|
|
'{ "role": "assistant", "mentionedAt": @, "count": 1 }'
|
|
);
|
|
expect(expandRangeOverMentionPlaceholder(editor, range)).toEqual(range);
|
|
});
|
|
|
|
it("does not swallow a group that already contains a mention chip", () => {
|
|
const doc = placeholderSchema.node("doc", null, [
|
|
placeholderSchema.node("paragraph", null, [
|
|
placeholderSchema.text("{ @ "),
|
|
placeholderSchema.node("chip"),
|
|
placeholderSchema.text(" }")
|
|
])
|
|
]);
|
|
const editor = { state: { doc } } as unknown as Editor;
|
|
const range = { from: 3, to: 4 }; // over the `@`
|
|
expect(expandRangeOverMentionPlaceholder(editor, range)).toEqual(range);
|
|
});
|
|
});
|