mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* feat(attachments): issue deliverables model and sequence block ids (MUL-7649) collectDeliverableFiles groups every comment upload into deliverables, merging same-name, same-type re-uploads into versions (newest version first). Description attachments are inputs and never enter the model. Sequence items now carry the id of the block they came from, so a viewer can say which comment (or the description) a file was posted in. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(viewer): host details, overview and info panel slots (MUL-7649) PreviewSequenceProvider takes an optional describeItem (title accessory, info panel, locate action) and onOpenOverview. The viewer renders only the slots it is handed: a locate button, a grid button (G) and an info toggle (I) whose panel sits beside the stage. Letter keys stand down in fields, and any key pressed inside an open menu stays with the menu. A zoom canvas still at fit now follows a viewport resize (the info panel opening), while a zoom the reader chose is only clamped, as before. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): deliverables section, overview grid and info panel (MUL-7649) The sidebar's "Deliverables" section shows what the issue delivered as a whole: its pull requests and the files its comments uploaded, re-uploads merged into one entry marked v2. It lists the three newest images and four newest files, plus "View all N deliverables". The former Pull requests section becomes the code group, keeping its MUL-7429 actions (link a PR by URL, the auto-complete menu) beside the group label. While the workspace shows PRs, the section stays up even with nothing delivered, since that is where a PR gets linked by hand. The overview (from "view all" or G in the viewer) shows code first, then files grouped by posting comment, filterable by kind, each group one click from its comment. Its count always equals the sidebar's. In the viewer, deliverables get a version switcher, and every file gets an info panel (source excerpt, sibling files, uploader, time, size) and "Show in comments", which unfolds a resolved thread if needed and reuses the timeline's jump-and-flash highlight. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(comments): standalone attachments as cards and an image row (MUL-7649) The files under a comment (and a chat message) no longer stack as one full-width row each. Other files become cards in a grid: type glyph, name, "TYPE · size", download / remove on hover, and the whole card opens the file. Several images sit in one row at a shared height; a lone image keeps its full size. HTML keeps its embedded preview (MUL-2330). On the issue page a re-uploaded file's card carries its version (v1, v2) from the deliverables model, through a small context. Standalone attachments are grouped images, then HTML, then files, and the preview sequence walks them in that same order, so paging follows the screen. The file-type glyph moves to editor/utils/file-icon so comment cards and the deliverables surfaces share one mapping. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(issues): pull requests keep their own section (MUL-7649) Since MUL-7429 the PR block is issue workflow — linking a PR by hand, the auto-complete switch, "won't auto-complete: missing Closes" — rather than output, so it goes back to main's own Pull requests section, unchanged, directly above Deliverables. Deliverables is now the delivered files only: hidden until a comment delivers one, and the sidebar number, "View all N" and the overview's "All N" all count files. The overview drops its code group. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(comments): lay several images out as justified rows (MUL-7649) Fixed-height tiles wrapped as soon as the column narrowed — three screenshots became two plus an orphan, with a ragged right edge. Rows are now justified from each image's real aspect ratio: every row shares one height and a full row runs edge to edge. A row takes as many images as fit above a minimum height, never grows past a maximum, and a last row that does not fill keeps the height of the row above it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(rich-content): dynamic block frame for html and mermaid fences (MUL-7649) A fenced ```html or ```mermaid block is part of the message body, so it now renders in one shared frame instead of two unrelated widgets: - Always-visible title bar: kind icon, the fence's title="..." (or the kind name), a kind chip, Preview | Source, fullscreen and copy. Previously the controls only appeared on hover and floated over the content. - The body takes its content's height (at least 120px) and collapses past 480px behind a fade and "Show all". An HTML block used to be a fixed 480px. - Loading keeps the height this block had earlier in the session; a script error or a Mermaid parse error is explained inside the frame with "View source" and "Copy error". - HTML gets the app's theme tokens (--foreground, --chart-1 ...); HTML that uses one also gets the app's color-scheme, so it follows dark mode. HTML that uses none keeps its own look. The sandboxed document reports its height and first uncaught error through a one-line postMessage bridge; the page checks the message source and clamps every value, and stops a document whose height follows the frame. rehype-raw drops the fence meta, so the title is read in the closed-fence parse. The Mermaid sandbox now also declares the app's color-scheme and font: in dark mode it was painted on an opaque white canvas, and its labels were drawn in a serif font that did not match the layout Mermaid measured. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(attachments): HTML files show as cards, not inline previews (MUL-7649) Reverses the MUL-2330 pin. An uploaded HTML file is a deliverable to open, not part of the text: like every other non-image file it shows as a card (a row inline in the body, a grid card under it) that opens in the viewer, and its contents are no longer fetched to embed a 480px iframe. HTML meant to be read in place is written as a ```html block, which renders as a dynamic block. - Attachment drops its html branch; HtmlAttachmentPreview is deleted and HtmlPreviewBody keeps only the inline source the viewer uses. - orderStandaloneAttachments groups images, then files (HTML included), so AttachmentList and the viewer sequence still walk the same order. - The MUL-2330 regression pins now assert the card and that no text fetch happens. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * docs: charts go in html/mermaid blocks, files show as cards (MUL-7649) Agents that uploaded an HTML chart expecting it to render in the comment now get a file card instead. Tell them where each kind of content goes: - multica-platform skill (issues reference, routed from the table): a fenced ```html / ```mermaid block renders in place with a title bar and content height; an attached file, HTML included, is a card that opens the viewer. Covers title="...", the sandbox, the theme variables and their opt-in color scheme, and sizing to content rather than the viewport. - `multica issue comment add --attachment` help says the same in one line. - Comments docs (all five languages) describe both. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(daemon): runtime brief says where charts and diagrams go (MUL-7649) The chart/file rule lived only in the multica-platform skill's issues reference, which an agent opens on demand, and the skill's description did not mention comment formatting, so an agent that just wanted a chart in its result had no reason to read it. Agents that uploaded report.html expecting an inline chart now get a card. - The brief's Output section carries one line, beside the file-delivery line, on the surfaces the web renders (issue comments and web/mobile chat): put charts and diagrams in the text as a fenced html or mermaid block, name it with title="...", and an attached file, HTML included, shows as a card. Theming and sizing stay in the reference. - Channel chats, autopilot run results and quick-create stdout do not render these blocks and do not get the line; the delivery tests pin both sides. - The skill description names "charts and files in comments" so the reference is picked for that task. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): locate a file in a collapsed thread (MUL-7649) "Show in comments" for a file posted in a reply only unfolded resolved threads and gave up after 30 frames, so a thread the reader had collapsed stayed shut and the reply never mounted. A reply now goes through the quick-jump rail's jumpToReply, which already undoes every kind of folding and waits for the reply to land. A root comment gets the same treatment for its own thread (the reader's collapse, or a resolved bar), then the jump. Found in review by Emacs. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(viewer): the info panel's "Show in comments" closes the viewer (MUL-7649) The top bar's locate went through the viewer, which closes itself first; the same button in the info panel called the host's callback directly, so the page scrolled underneath a viewer that still covered it. Controls handed to describeItem gain close(), and the issue page builds one close-then-locate action for both entry points. Found in review by Emacs. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): the deliverables overview is a real modal dialog (MUL-7649) The overview was a portaled layer with role="dialog" and nothing else: focus stayed on the opener, Tab walked into the page underneath, and closing left focus wherever it was. It now renders through the shared Dialog, restyled to cover the window, so the primitive moves focus in, keeps it inside, returns it to the opener, and handles Escape. `G` back to the viewer's file stays. The dialog is named by its title ("MUL-123 deliverables"). Found in review by Emacs. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]>
414 lines
17 KiB
Go
414 lines
17 KiB
Go
package execenv
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The MUL-4899 delivery contract as the BRIEF states it. Two orthogonal
|
|
// properties are pinned here and must not be collapsed:
|
|
//
|
|
// - The invariant ("never link a local path") is ALWAYS-ON — every task kind,
|
|
// no exceptions. It lives outside writeOutput's kind switch so a future kind
|
|
// cannot silently inherit no invariant at all; this test is what keeps that
|
|
// true.
|
|
// - The surface policy ("here is how a file actually gets delivered HERE") is
|
|
// PER-KIND, and the chat kind splits two ways: web/mobile names the upload
|
|
// command, because a browser renders the bound file as a card and that never
|
|
// changes; a channel-backed chat points at the per-turn user message.
|
|
//
|
|
// The brief stops there on purpose. Whether the last hop into a room happens is
|
|
// a DEPLOYMENT fact — object storage, and a server new enough to report it —
|
|
// that flips under a session already running, and the brief is the prompt-cache
|
|
// prefix (MUL-5377), so a brief that answered it would render twice for one
|
|
// resumed chat. The verdict is stated by the per-turn chat prompt, and both of
|
|
// its branches are pinned by TestBuildChatPromptTwoLayerChannelPolicy in
|
|
// daemon/prompt_test.go — that is where the "upload works here" / "describe it
|
|
// in words" split is covered now.
|
|
//
|
|
// So the same channel still appears below with both verdicts, but what the rows
|
|
// assert has changed: they must render the SAME brief, and
|
|
// TestBriefChannelDeliveryCopyIgnoresServerVerdict is the row-level statement
|
|
// of it.
|
|
|
|
// deliveryInvariantFixtures covers every task kind. The chat kind appears six
|
|
// times because the surface splits on channel type, and because the three WeCom
|
|
// rows carry the verdicts whose briefs must not differ.
|
|
func deliveryInvariantFixtures() map[string]TaskContextForEnv {
|
|
return map[string]TaskContextForEnv{
|
|
"comment": {IssueID: "i-1", TriggerCommentID: "tc-1", AgentName: "Eve", AgentID: "eve-1"},
|
|
"assignment": {IssueID: "i-1", AgentName: "Eve", AgentID: "eve-1"},
|
|
"autopilot": {AutopilotRunID: "r-1", AgentName: "Eve", AgentID: "eve-1"},
|
|
"quickcreate": {QuickCreatePrompt: "p", AgentName: "Eve", AgentID: "eve-1"},
|
|
"chat_direct": {ChatSessionID: "c-1", AgentName: "Eve", AgentID: "eve-1"},
|
|
"chat_slack": {ChatSessionID: "c-1", ChatChannelType: ChannelTypeSlack, AgentName: "Eve", AgentID: "eve-1"},
|
|
"chat_feishu": {ChatSessionID: "c-1", ChatChannelType: ChannelTypeFeishu, AgentName: "Eve", AgentID: "eve-1"},
|
|
"chat_wecom": {ChatSessionID: "c-1", ChatChannelType: ChannelTypeWecom, ChatChannelDeliversFiles: true, AgentName: "Eve", AgentID: "eve-1"},
|
|
// The same adapter on a deployment with no object storage: there is
|
|
// nothing to read the bound attachment out of, so the server reports no
|
|
// delivery. Turning that storage on or off is an operator action, which
|
|
// is precisely why the brief may not encode the answer.
|
|
"chat_wecom_no_store": {ChatSessionID: "c-1", ChatChannelType: ChannelTypeWecom, AgentName: "Eve", AgentID: "eve-1"},
|
|
// A daemon that knows about file delivery against a server that does
|
|
// not send the field. Identical shape to the row above by construction —
|
|
// an absent field decodes as false — and listed separately because it
|
|
// is a separate way to reach the same state, one an upgrade leaves.
|
|
"chat_wecom_old_server": {ChatSessionID: "c-1", ChatChannelType: ChannelTypeWecom, AgentName: "Eve", AgentID: "eve-1"},
|
|
}
|
|
}
|
|
|
|
// inlineBlocksRule is the brief's one-line rule for charts and diagrams
|
|
// (MUL-7649). Surfaces the web renders carry it; surfaces that deliver plain
|
|
// text (channel chats, autopilot results, quick-create stdout) must not, since
|
|
// a fenced html/mermaid block does not render there.
|
|
const inlineBlocksRule = "fenced `html` or `mermaid` code block"
|
|
|
|
func TestBriefDeliveryInvariantIsAlwaysOn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Phrases every kind must carry, whatever its surface can or cannot deliver.
|
|
wantAll := []string{
|
|
"Runtime-local paths are never deliverables",
|
|
"NEVER write an absolute path or a `file://` URL as a clickable link",
|
|
"`path/to/file.ts:42`",
|
|
}
|
|
|
|
for name, ctx := range deliveryInvariantFixtures() {
|
|
out := buildMetaSkillContent("claude", ctx)
|
|
for _, want := range wantAll {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("kind=%s: brief is missing always-on delivery invariant %q", name, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBriefSurfaceDeliveryPolicy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := map[string]struct {
|
|
mustHave []string
|
|
mustNot []string
|
|
}{
|
|
// Issue surfaces: files ride the comment.
|
|
"comment": {
|
|
mustHave: []string{"`--attachment <path>` to `multica issue comment add`", inlineBlocksRule},
|
|
mustNot: []string{"multica attachment upload"},
|
|
},
|
|
"assignment": {
|
|
mustHave: []string{"`--attachment <path>` to `multica issue comment add`", inlineBlocksRule},
|
|
mustNot: []string{"multica attachment upload"},
|
|
},
|
|
// Direct chat: the upload binds to the reply and the browser renders a
|
|
// card, so the file can sit inline where the agent puts it.
|
|
"chat_direct": {
|
|
mustHave: []string{"`multica attachment upload <local-path>`", inlineBlocksRule},
|
|
mustNot: []string{"text-only", "separate message"},
|
|
},
|
|
// A channel-backed chat names its platform, defers the verdict to the
|
|
// per-turn message, and states neither answer. Both verdicts' old copy
|
|
// is denied on every such row: "separate message" and the bare upload
|
|
// imperative are the promise, "conversation is text-only" and "does NOT
|
|
// apply" are the denial, and the brief may carry neither — the same
|
|
// session sees both verdicts, so either one is wrong half the time.
|
|
"chat_wecom": {
|
|
mustHave: []string{
|
|
"WeCom conversation depends on how this deployment is configured",
|
|
"the per-turn user message tells you",
|
|
"never report a file as delivered",
|
|
},
|
|
mustNot: []string{
|
|
"run `multica attachment upload",
|
|
"separate message",
|
|
"conversation is text-only",
|
|
"does NOT apply",
|
|
inlineBlocksRule,
|
|
},
|
|
},
|
|
"chat_wecom_no_store": {
|
|
mustHave: []string{
|
|
"WeCom conversation depends on how this deployment is configured",
|
|
"the per-turn user message tells you",
|
|
},
|
|
mustNot: []string{
|
|
"run `multica attachment upload",
|
|
"separate message",
|
|
"conversation is text-only",
|
|
"does NOT apply",
|
|
inlineBlocksRule,
|
|
},
|
|
},
|
|
"chat_wecom_old_server": {
|
|
mustHave: []string{
|
|
"WeCom conversation depends on how this deployment is configured",
|
|
"the per-turn user message tells you",
|
|
},
|
|
mustNot: []string{
|
|
"run `multica attachment upload",
|
|
"separate message",
|
|
"conversation is text-only",
|
|
"does NOT apply",
|
|
inlineBlocksRule,
|
|
},
|
|
},
|
|
// No deployment carries files into Slack or Lark today, but the brief
|
|
// must not say so: wiring that hop is a server-side change the daemon
|
|
// never sees, and a brief holding the denial would keep denying it
|
|
// afterwards. Naming the platform still matters — the deferral has to
|
|
// be about THIS conversation.
|
|
//
|
|
// These two carry the SAME four denials the WeCom rows do, and that is
|
|
// what makes the paragraph above a rule rather than a note. The
|
|
// byte-identity invariant cannot enforce it on its own: a denial
|
|
// written into the brief unconditionally is the same on both values of
|
|
// the verdict, so it renders one brief and passes.
|
|
//
|
|
// A phrase list cannot be the only guard either — it denies the
|
|
// wordings someone thought to name, and the next one will be worded
|
|
// differently. TestBriefChannelDeliveryCopyIsPlatformNeutral holds the
|
|
// same rule with no wording in it, by requiring every channel-backed
|
|
// brief to differ only in the platform it names. Keep both: that one
|
|
// catches any wording, these fail beside the copy being edited.
|
|
"chat_slack": {
|
|
mustHave: []string{
|
|
"Slack conversation depends on how this deployment is configured",
|
|
"the per-turn user message tells you",
|
|
"never report a file as delivered",
|
|
},
|
|
mustNot: []string{
|
|
"run `multica attachment upload",
|
|
"separate message",
|
|
"conversation is text-only",
|
|
"does NOT apply",
|
|
inlineBlocksRule,
|
|
},
|
|
},
|
|
"chat_feishu": {
|
|
mustHave: []string{
|
|
"Feishu/Lark conversation depends on how this deployment is configured",
|
|
"the per-turn user message tells you",
|
|
"never report a file as delivered",
|
|
},
|
|
mustNot: []string{
|
|
"run `multica attachment upload",
|
|
"separate message",
|
|
"conversation is text-only",
|
|
"does NOT apply",
|
|
inlineBlocksRule,
|
|
},
|
|
},
|
|
"autopilot": {
|
|
mustHave: []string{"this surface is text-only"},
|
|
mustNot: []string{"multica attachment upload", inlineBlocksRule},
|
|
},
|
|
"quickcreate": {
|
|
mustHave: []string{"your stdout is text-only", "`multica issue create` call itself via `--attachment <path>`"},
|
|
mustNot: []string{"multica attachment upload", inlineBlocksRule},
|
|
},
|
|
}
|
|
|
|
fixtures := deliveryInvariantFixtures()
|
|
for name, want := range cases {
|
|
ctx, ok := fixtures[name]
|
|
if !ok {
|
|
t.Fatalf("no fixture for surface %q", name)
|
|
}
|
|
out := buildMetaSkillContent("claude", ctx)
|
|
for _, phrase := range want.mustHave {
|
|
if !strings.Contains(out, phrase) {
|
|
t.Errorf("surface=%s: brief missing surface policy %q\n--- Output section ---\n%s",
|
|
name, phrase, outputSection(out))
|
|
}
|
|
}
|
|
// mustNot is checked against the WHOLE brief, not just `## Output`: a
|
|
// denial is wrong wherever it is written, and scoping the search to
|
|
// the section it usually lands in would let the next one land
|
|
// somewhere else. The phrases are ordinary English, though, so a
|
|
// future section could match one legitimately — quote the text around
|
|
// the actual hit and say which section it was in, or the failure
|
|
// points a reader at an Output section the phrase may not be in.
|
|
for _, phrase := range want.mustNot {
|
|
at := strings.Index(out, phrase)
|
|
if at < 0 {
|
|
continue
|
|
}
|
|
where := "in the `## Output` section"
|
|
if !strings.Contains(outputSection(out), phrase) {
|
|
where = "OUTSIDE the `## Output` section"
|
|
}
|
|
t.Errorf("surface=%s: brief must NOT carry %q — found %s\n--- context ---\n%s",
|
|
name, phrase, where, briefContext(out, at, len(phrase)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBriefChannelDeliveryCopyIgnoresServerVerdict is what the two WeCom rows
|
|
// assert now that the brief no longer answers the delivery question.
|
|
//
|
|
// They used to assert two DIFFERENT briefs, one per verdict, which is the shape
|
|
// of the defect: the verdict arrives on every claim, an operator toggling object
|
|
// storage or upgrading the server flips it under a chat that is already running,
|
|
// and the brief is the prompt-cache prefix. Two briefs for one resumed session
|
|
// is a cache miss on every turn after the flip (MUL-5377).
|
|
//
|
|
// So the property is inverted here rather than dropped. What the verdict
|
|
// actually changes still has to be pinned somewhere, and it is: the per-turn
|
|
// prompt states it, and TestBuildChatPromptTwoLayerChannelPolicy holds both of
|
|
// its branches.
|
|
//
|
|
// TestBriefByteIdenticalAcrossRunsForEveryKind carries the same guarantee for
|
|
// every kind and every per-run field. This one is deliberately narrow and lives
|
|
// beside the delivery copy, so an edit that reintroduces the branch fails in the
|
|
// file that edit would be made in.
|
|
func TestBriefChannelDeliveryCopyIgnoresServerVerdict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fixtures := deliveryInvariantFixtures()
|
|
// chat_wecom carries the verdict true and chat_wecom_no_store carries it
|
|
// false; the pair is the whole test. chat_wecom_old_server has the same
|
|
// shape as no_store by construction and is included so the row cannot drift
|
|
// away from the invariant unnoticed.
|
|
delivering := buildMetaSkillContent("claude", fixtures["chat_wecom"])
|
|
for _, name := range []string{"chat_wecom_no_store", "chat_wecom_old_server"} {
|
|
got := buildMetaSkillContent("claude", fixtures[name])
|
|
if got != delivering {
|
|
t.Errorf("brief for %q differs from chat_wecom — the server's per-turn file-delivery verdict reached the cached prefix (MUL-5377).\n%s",
|
|
name, firstBriefDiff(delivering, got))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBriefChannelDeliveryCopyIsPlatformNeutral is the structural form of the
|
|
// rule the phrase lists above state one wording at a time.
|
|
//
|
|
// A phrase list is open-ended by construction: it denies the wordings someone
|
|
// thought to name, and a fifth wording passes. The rule underneath has no
|
|
// wording in it — one channel-backed brief may differ from another ONLY in
|
|
// which platform it names. Anything platform-specific past the name is either a
|
|
// promise or a denial about the last hop, and the brief may carry neither,
|
|
// because that hop is a deployment fact stated per turn (MUL-4899).
|
|
//
|
|
// So this substitutes the display name out and requires the briefs to be
|
|
// byte-identical. A per-platform position fails here whatever words it is
|
|
// written in. The phrase lists stay: they name the wordings, and they fail
|
|
// beside the copy rather than on a whole-brief diff.
|
|
//
|
|
// A channel type added to channel_type.go belongs in the list below — the
|
|
// constants are the only enumeration of them that exists.
|
|
func TestBriefChannelDeliveryCopyIsPlatformNeutral(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
neutralized := func(channelType string, delivers bool) string {
|
|
out := buildMetaSkillContent("claude", TaskContextForEnv{
|
|
ChatSessionID: "c-1",
|
|
ChatChannelType: channelType,
|
|
ChatChannelDeliversFiles: delivers,
|
|
AgentName: "Eve",
|
|
AgentID: "eve-1",
|
|
})
|
|
return strings.ReplaceAll(out, ChannelDisplayName(channelType), "<CHANNEL>")
|
|
}
|
|
|
|
// Both verdicts, because the tempting place to write a denial is the
|
|
// branch someone believes cannot deliver today.
|
|
for _, delivers := range []bool{false, true} {
|
|
baseline := neutralized(ChannelTypeSlack, delivers)
|
|
for _, channelType := range []string{ChannelTypeFeishu, ChannelTypeWecom} {
|
|
got := neutralized(channelType, delivers)
|
|
if got != baseline {
|
|
t.Errorf("brief for channel %q (delivers=%v) differs from %q beyond the platform name — the brief took a position on one platform's file delivery, which is a per-turn deployment fact (MUL-4899, MUL-5377).\n%s",
|
|
channelType, delivers, ChannelTypeSlack, firstBriefDiff(baseline, got))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBriefInboundAttachmentIsNotADeliverable locks the inbound half: a
|
|
// downloaded attachment's local path is a private working copy, and the most
|
|
// tempting one to echo back because it arrived from the conversation.
|
|
//
|
|
// The Attachments section owns that framing — it is what `## Output` cannot
|
|
// express, because Output does not know an attachment felt shared. The
|
|
// no-clickable-local-path rule itself belongs to Output and used to be restated
|
|
// here verbatim; MUL-5442 replaced the restatement with a pointer, so this test
|
|
// pins the framing plus the pointer and lets the delivery tests above own the
|
|
// rule.
|
|
func TestBriefInboundAttachmentIsNotADeliverable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
out := buildMetaSkillContent("claude", TaskContextForEnv{
|
|
IssueID: "i-1", TriggerCommentID: "tc-1", AgentName: "Eve", AgentID: "eve-1",
|
|
})
|
|
for _, want := range []string{
|
|
"private working copy",
|
|
"not something the reader can open",
|
|
"the link rules in `## Output` apply to it too",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("Attachments section missing %q\n---\n%s", want, out)
|
|
}
|
|
}
|
|
// The rule the pointer defers to must actually be present in the brief.
|
|
if !strings.Contains(out, "NEVER write an absolute path or a `file://` URL as a clickable link") {
|
|
t.Errorf("Attachments points at ## Output but the rule is missing\n---\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestChannelDisplayName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := map[string]string{
|
|
ChannelTypeSlack: "Slack",
|
|
ChannelTypeFeishu: "Feishu/Lark",
|
|
"": "",
|
|
// An unmapped channel names itself rather than reading as "unknown".
|
|
"discord": "discord",
|
|
}
|
|
for in, want := range cases {
|
|
if got := ChannelDisplayName(in); got != want {
|
|
t.Errorf("ChannelDisplayName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quick actions moved to the daemon's dedicated post-completion suggestion
|
|
// pass; the brief must no longer teach the in-band footer syntax to anyone —
|
|
// a taught agent would emit footers the transcript then has to strip.
|
|
func TestQuickActionsInstructionsAbsentFromAllChatBriefs(t *testing.T) {
|
|
t.Parallel()
|
|
contexts := []TaskContextForEnv{
|
|
{ChatSessionID: "c-1", AgentName: "Eve", AgentID: "eve-1"},
|
|
{ChatSessionID: "c-1", ChatChannelType: ChannelTypeSlack, AgentName: "Eve", AgentID: "eve-1"},
|
|
{ChatSessionID: "c-1", ChatChannelType: ChannelTypeFeishu, AgentName: "Eve", AgentID: "eve-1"},
|
|
}
|
|
for _, ctx := range contexts {
|
|
brief := buildMetaSkillContent("claude", ctx)
|
|
if strings.Contains(brief, "```quick-actions") || strings.Contains(brief, "### Quick Actions") {
|
|
t.Fatalf("brief (channel=%q) must not teach the in-band quick-actions syntax", ctx.ChatChannelType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// briefContext quotes the brief around a match so a mustNot failure shows the
|
|
// sentence that actually tripped it rather than a section it may not be in.
|
|
func briefContext(brief string, at, length int) string {
|
|
lo := at - 200
|
|
if lo < 0 {
|
|
lo = 0
|
|
}
|
|
hi := at + length + 200
|
|
if hi > len(brief) {
|
|
hi = len(brief)
|
|
}
|
|
return brief[lo:hi]
|
|
}
|
|
|
|
// outputSection extracts the brief's `## Output` section for readable failures.
|
|
func outputSection(brief string) string {
|
|
idx := strings.Index(brief, "\n## Output\n")
|
|
if idx < 0 {
|
|
return "<no ## Output section>"
|
|
}
|
|
return brief[idx:]
|
|
}
|