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
1 change: 1 addition & 0 deletions src/components/automations/automation-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export function AutomationEditor({
mentionUiLabels={mentionUiLabels}
tabLabels={referenceGroupLabels}
mentionAnchorRef={composerBoxRef}
knownInvocations={invocations.knownInvocations}
onChange={(text) => {
setPrompt(text)
invocations.detect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function invocations(
isOpen: true,
commands,
skills: [],
knownInvocations: new Set(commands.map((cmd) => `/${cmd.name}`)),
activeIndex: 0,
detect: () => {},
onKeyDown: () => false,
Expand Down
18 changes: 18 additions & 0 deletions src/components/automations/composer-invocations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type PopupPosition,
} from "@/components/chat/composer/suggestion/popup-position"
import {
buildKnownInvocations,
commandInvocationToken,
commandToReference,
skillToReference,
Expand All @@ -26,6 +27,7 @@ import type { ReferenceAttrs } from "@/components/chat/composer/types"
import { useAgentSkills } from "@/hooks/use-agent-skills"
import { rankByTextMatch } from "@/lib/fuzzy-text-match"
import { isImeCompositionKey } from "@/lib/ime-composition"
import type { KnownInvocations } from "@/lib/invocation-token"
import { cn } from "@/lib/utils"
import type {
AgentSkillItem,
Expand Down Expand Up @@ -53,6 +55,9 @@ export interface ComposerInvocations {
isOpen: boolean
commands: AvailableCommandInfo[]
skills: AgentSkillItem[]
/** Every invocation this menu could offer, for the composer's `knownInvocations`
* — so seeded / pasted text badges exactly what the menu would insert. */
knownInvocations: KnownInvocations
/** Index into the merged [commands, skills] list. */
activeIndex: number
/** Re-evaluate the trigger from the editor's current caret (call on change). */
Expand Down Expand Up @@ -134,6 +139,18 @@ export function useComposerInvocations({
)
}, [isCodex, open, triggerChar, skills, filter])

// Built from the FULL lists, not the filtered ones: this answers "is there
// such a command", which the current query has no say in.
const knownInvocations = useMemo(
() =>
buildKnownInvocations(
availableCommands,
isCodex ? skills : null,
isCodex ? "$" : "/"
),
[availableCommands, isCodex, skills]
)

const count = commands.length + matchedSkills.length
// Clamp on read so a shrinking filtered list never points past the end (avoids
// a clamping effect / set-state-in-effect).
Expand Down Expand Up @@ -228,6 +245,7 @@ export function useComposerInvocations({
isOpen: open && count > 0,
commands,
skills: matchedSkills,
knownInvocations,
activeIndex,
detect,
onKeyDown,
Expand Down
19 changes: 16 additions & 3 deletions src/components/chat/composer/composer-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,11 @@ describe("restoreBlocksIntoEditor", () => {
// docToPromptBlocks emits ONE text block with every badge serialized inline,
// so a queue-edit has to parse them back out to show the sender's badges.
const text = "run /review on [app.ts](file:///repo/app.ts)"
const attachments = restoreBlocksIntoEditor(editor, [
{ type: "text", text },
])
const attachments = restoreBlocksIntoEditor(
editor,
[{ type: "text", text }],
new Set(["/review"])
)
expect(
JSON.stringify(editor.getJSON()).match(/"type":"reference"/g)
).toHaveLength(2)
Expand All @@ -279,6 +281,17 @@ describe("restoreBlocksIntoEditor", () => {
expect(attachments).toEqual([])
})

it("restores a queued `/cmd` the agent no longer advertises as its text", () => {
// The message still sends the same bytes; it just stops claiming to be a
// command the agent would recognize.
const text = "run /review on [app.ts](file:///repo/app.ts)"
restoreBlocksIntoEditor(editor, [{ type: "text", text }], new Set())
expect(
JSON.stringify(editor.getJSON()).match(/"type":"reference"/g)
).toHaveLength(1)
expect(serialized(editor)).toBe(text)
})

it("restores every serialized agent link as a badge, losslessly", () => {
// No badge is privileged over another: routing is derived backend-side from
// the VISIBLE link, so a restored draft sends exactly like the original.
Expand Down
15 changes: 11 additions & 4 deletions src/components/chat/composer/composer-commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { Editor } from "@tiptap/core"

import {
NO_KNOWN_INVOCATIONS,
type KnownInvocations,
} from "@/lib/invocation-token"
import type { PromptInputBlock } from "@/lib/types"

import type { InputAttachment } from "../message-input-attachments"
Expand Down Expand Up @@ -149,19 +153,22 @@ export function restampSkillPrefixes(
* references `docToPromptBlocks` serialized INTO that text (it emits one text
* block with every badge inline) come back as badges instead of raw
* `[label](uri)` / `/cmd` source — the same treatment paste and the other
* seeding paths get. Lossless: re-serializing the restored badges reproduces the
* block text verbatim.
* seeding paths get, `known` included: a `/cmd` the agent no longer advertises
* comes back as the text it will be sent as, rather than as a badge claiming a
* command that isn't there. Lossless either way: re-serializing the restored
* content reproduces the block text verbatim.
*/
export function restoreBlocksIntoEditor(
editor: Editor,
blocks: PromptInputBlock[]
blocks: PromptInputBlock[],
known: KnownInvocations = NO_KNOWN_INVOCATIONS
): InputAttachment[] {
const { segments, attachments } = blocksToRestoredDraft(blocks)
let chain = editor.chain().clearContent()
for (const segment of segments) {
chain =
segment.kind === "text"
? chain.insertContent(textToSeededInlineContent(segment.text))
? chain.insertContent(textToSeededInlineContent(segment.text, known))
: chain.insertReference(segment.attrs)
}
chain.focus("end").run()
Expand Down
23 changes: 23 additions & 0 deletions src/components/chat/composer/invocation-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"
import type { AgentSkillItem, AvailableCommandInfo } from "@/lib/types"

import {
buildKnownInvocations,
commandInvocationToken,
commandToReference,
skillToReference,
Expand Down Expand Up @@ -85,3 +86,25 @@ describe("skillToReference", () => {
expect(skillToReference(skill("only-id", ""), "/").label).toBe("only-id")
})
})

describe("buildKnownInvocations", () => {
it("keys commands and skills by the token they are sent as", () => {
const known = buildKnownInvocations(
[cmd("review"), cmd("$deploy")],
[skill("ship", "Ship")],
"$"
)
expect([...known].sort()).toEqual(["$deploy", "$ship", "/review"])
})

it("uses `/` for skills when no Codex prefix is given", () => {
expect([...buildKnownInvocations(null, [skill("ship", "Ship")])]).toEqual([
"/ship",
])
})

it("is empty for an agent that has advertised nothing yet", () => {
expect(buildKnownInvocations(null).size).toBe(0)
expect(buildKnownInvocations([]).size).toBe(0)
})
})
26 changes: 26 additions & 0 deletions src/components/chat/composer/invocation-reference.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { KnownInvocations } from "@/lib/invocation-token"
import type { AgentSkillItem, AvailableCommandInfo } from "@/lib/types"

import type { ReferenceAttrs } from "./types"
Expand Down Expand Up @@ -52,3 +53,28 @@ export function skillToReference(
meta: { invocationPrefix: prefix, scope: skill.scope },
}
}

/**
* The {@link KnownInvocations} of a composer: exactly the tokens its own
* `/`·`$` menu can offer, written the way they are sent — so whatever the menu
* would insert as a badge is also what typed or pasted text is allowed to become
* one, and nothing else is.
*
* `skills` are the on-disk skills behind the `$` menu (Codex only; every other
* agent advertises its skills through `commands` already) and are keyed with the
* prefix that agent triggers them by.
*/
export function buildKnownInvocations(
commands: readonly AvailableCommandInfo[] | null | undefined,
skills?: readonly AgentSkillItem[] | null,
skillPrefix: InvocationPrefix = "/"
): KnownInvocations {
const tokens = new Set<string>()
for (const cmd of commands ?? []) {
if (cmd.name) tokens.add(commandInvocationToken(cmd.name))
}
for (const skill of skills ?? []) {
if (skill.id) tokens.add(`${skillPrefix}${skill.id}`)
}
return tokens
}
100 changes: 96 additions & 4 deletions src/components/chat/composer/plain-text-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
textToSeededInlineContent,
} from "./plain-text-content"

/** What the agent in these tests advertises: `/review` and `$deploy`, nothing else. */
const KNOWN = new Set(["/review", "$deploy"])

describe("decidePastedContent", () => {
it("inserts text/plain when the clipboard carries an external HTML fragment", () => {
// What a browser puts on the clipboard when a URL is copied from the address
Expand Down Expand Up @@ -52,6 +55,24 @@ describe("decidePastedContent", () => {
])
})

it("pastes a slash word the agent does not advertise as prose", () => {
// A regex, a path or a sentence pasted out of a terminal is not a command,
// and a paste is not a choice from the menu.
expect(
decidePastedContent({ html: "", text: "run /notacommand now" }, KNOWN)
).toBeNull()
expect(
decidePastedContent({ html: "", text: "run /review now" }, KNOWN)
).toEqual([
{ type: "text", text: "run " },
{
type: "reference",
attrs: expect.objectContaining({ refType: "skill", id: "review" }),
},
{ type: "text", text: " now" },
])
})

it("hydrates references when forcing text/plain over an external HTML fragment", () => {
const decision = decidePastedContent({
html: "<div>run [@Codex](codeg://agent/codex)</div>",
Expand Down Expand Up @@ -134,7 +155,8 @@ describe("textToSeededInlineContent", () => {
// filling one into the composer must show badges, not `[label](uri)` text.
expect(
textToSeededInlineContent(
"/review [app.ts](file:///repo/app.ts) with [@Codex](codeg://agent/codex)"
"/review [app.ts](file:///repo/app.ts) with [@Codex](codeg://agent/codex)",
KNOWN
)
).toEqual([
{
Expand Down Expand Up @@ -219,8 +241,8 @@ describe("textToHydratedInlineContent", () => {
expect(textToHydratedInlineContent("see /usr/bin for it")).toBeNull()
})

it("hydrates bare `/cmd` and `$skill` tokens, keeping their trigger", () => {
const content = textToHydratedInlineContent("$deploy prod")
it("hydrates an advertised bare `/cmd` / `$skill` token, keeping its trigger", () => {
const content = textToHydratedInlineContent("$deploy prod", KNOWN)
expect(content).toEqual([
{
type: "reference",
Expand All @@ -233,7 +255,7 @@ describe("textToHydratedInlineContent", () => {
},
{ type: "text", text: " prod" },
])
expect(textToHydratedInlineContent("/review it")?.[0]).toEqual({
expect(textToHydratedInlineContent("/review it", KNOWN)?.[0]).toEqual({
type: "reference",
attrs: expect.objectContaining({
refType: "skill",
Expand All @@ -243,6 +265,76 @@ describe("textToHydratedInlineContent", () => {
})
})

describe("bare tokens the agent does not advertise", () => {
it("leaves a token matching no command as prose", () => {
expect(
textToHydratedInlineContent("/notacommand hello", KNOWN)
).toBeNull()
})

it("leaves a prefix of a real command as prose", () => {
// `/rev` is not `/review`, and a menu row nobody picked is not a choice.
expect(textToHydratedInlineContent("/rev it", KNOWN)).toBeNull()
expect(textToHydratedInlineContent("/reviews it", KNOWN)).toBeNull()
})

it("matches command names case-sensitively", () => {
// The agent CLI reads the name it advertised; `/Review` is not it.
expect(textToHydratedInlineContent("/Review it", KNOWN)).toBeNull()
})

it("leaves paths, mid-word slashes and urls as prose", () => {
for (const text of [
"/tmp/x is the scratch dir",
"/usr/bin/env python",
"check and/or fix it",
"open http://x now",
"/etc please",
]) {
expect(textToHydratedInlineContent(text, KNOWN)).toBeNull()
}
})

it("badges nothing at all with no advertised list", () => {
// The connection is still coming up (or there is no agent behind this
// box): unverifiable is not the same as valid.
expect(textToHydratedInlineContent("/review it")).toBeNull()
expect(textToSeededInlineContent("/review it")).toEqual([
{ type: "text", text: "/review it" },
])
})

it("keeps a reference link in text whose command is unknown", () => {
// Pass 1 is unambiguous — a `file:` link was inserted deliberately — so
// gating the bare token must not cost the badge next to it.
expect(
textToHydratedInlineContent(
"/notacommand [app.ts](file:///repo/app.ts)",
KNOWN
)
).toEqual([
{ type: "text", text: "/notacommand " },
{
type: "reference",
attrs: expect.objectContaining({ uri: "file:///repo/app.ts" }),
},
])
})

it("keeps an unknown token's text byte for byte around a known one", () => {
expect(
textToSeededInlineContent("run /nope then /review ok", KNOWN)
).toEqual([
{ type: "text", text: "run /nope then " },
{
type: "reference",
attrs: expect.objectContaining({ refType: "skill", id: "review" }),
},
{ type: "text", text: " ok" },
])
})
})

it("hydrates session links and maps newlines around badges to hard breaks", () => {
expect(
textToHydratedInlineContent("re:\n[My chat](codeg://session/42)")
Expand Down
Loading
Loading