Skip to content
Open
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
140 changes: 140 additions & 0 deletions desktop/src/features/messages/lib/composerAgentSkills.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";

import {
buildSkillInsertion,
extractAvailableAgentSkills,
} from "./composerAgentSkills.ts";

function event(seq, overrides = {}) {
return {
seq,
timestamp: `2026-08-22T00:00:0${seq}.000Z`,
kind: "acp_read",
agentIndex: 0,
channelId: "channel-a",
sessionId: "session-a",
turnId: "turn-a",
payload: {},
...overrides,
};
}

function commandsEvent(seq, commands, overrides = {}) {
return event(seq, {
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "available_commands_update",
availableCommands: commands,
},
},
},
...overrides,
});
}

describe("extractAvailableAgentSkills", () => {
test("returns the latest commands for the current channel and session", () => {
const skills = extractAvailableAgentSkills(
[
commandsEvent(1, [{ name: "old", description: "Old command" }]),
commandsEvent(
2,
[
{ name: "/review", description: "Review this change" },
{ name: "plan", description: "Create a plan" },
{ name: "plan", description: "Duplicate" },
],
{ sessionId: "session-b" },
),
],
"channel-a",
);

assert.deepEqual(skills, [
{
name: "review",
description: "Review this change",
inputHint: "",
},
{ name: "plan", description: "Create a plan", inputHint: "" },
]);
});

test("does not leak commands from a previous session", () => {
const skills = extractAvailableAgentSkills(
[
commandsEvent(1, [{ name: "review" }]),
event(2, { sessionId: "session-b", kind: "turn_start" }),
],
"channel-a",
);

assert.deepEqual(skills, []);
});

test("keeps commands advertised while a new session id is still unknown", () => {
const skills = extractAvailableAgentSkills(
[
commandsEvent(1, [{ name: "review" }], { sessionId: null }),
event(2, { kind: "session_resolved" }),
],
"channel-a",
);

assert.deepEqual(
skills.map((skill) => skill.name),
["review"],
);
});

test("ignores command updates from other channels", () => {
const skills = extractAvailableAgentSkills(
[
commandsEvent(1, [{ name: "review" }]),
commandsEvent(2, [{ name: "wrong" }], {
channelId: "channel-b",
sessionId: "session-b",
}),
],
"channel-a",
);

assert.deepEqual(
skills.map((skill) => skill.name),
["review"],
);
});
});

describe("buildSkillInsertion", () => {
test("inserts a slash command at an empty caret", () => {
assert.deepEqual(buildSkillInsertion("", 0, "review"), {
insertText: "/review ",
replaceFromOffset: 0,
replaceToOffset: 0,
});
});

test("adds spacing when inserting between words", () => {
assert.deepEqual(buildSkillInsertion("helloworld", 5, "/review"), {
insertText: " /review ",
replaceFromOffset: 5,
replaceToOffset: 5,
});
});

test("does not duplicate existing whitespace", () => {
assert.deepEqual(buildSkillInsertion("hello world", 6, "review"), {
insertText: "/review ",
replaceFromOffset: 6,
replaceToOffset: 6,
});
});

test("rejects an invalid command name", () => {
assert.equal(buildSkillInsertion("", 0, "two words"), null);
});
});
123 changes: 123 additions & 0 deletions desktop/src/features/messages/lib/composerAgentSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";

export type ComposerAgentSkill = {
description: string;
inputHint: string;
name: string;
};

type UnknownRecord = Record<string, unknown>;

function asRecord(value: unknown): UnknownRecord | null {
return typeof value === "object" && value !== null
? (value as UnknownRecord)
: null;
}

function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}

function normalizeSkill(value: unknown): ComposerAgentSkill | null {
const record = asRecord(value);
const rawName = record ? asString(record.name) : asString(value);
const name = rawName.replace(/^\/+/, "");
if (!name || /\s/.test(name)) return null;

return {
description: record ? asString(record.description) : "",
inputHint: record ? asString(record.inputHint) : "",
name,
};
}

function availableSkillsFromEvent(
event: ObserverEvent,
): ComposerAgentSkill[] | null {
const payload = asRecord(event.payload);
if (payload?.method !== "session/update") return null;
const params = asRecord(payload.params);
const update = asRecord(params?.update);
if (update?.sessionUpdate !== "available_commands_update") return null;
if (!Array.isArray(update.availableCommands)) return [];

const seen = new Set<string>();
const skills: ComposerAgentSkill[] = [];
for (const value of update.availableCommands) {
const skill = normalizeSkill(value);
if (!skill || seen.has(skill.name)) continue;
seen.add(skill.name);
skills.push(skill);
}
return skills;
}

/**
* Return the commands most recently advertised by an agent session in a
* channel. A newer session without a command update intentionally returns an
* empty list instead of leaking stale commands from the previous session.
*/
export function extractAvailableAgentSkills(
events: readonly ObserverEvent[],
channelId: string | null,
): ComposerAgentSkill[] {
const scopedEvents = events.filter((event) => event.channelId === channelId);
let latestSessionId: string | null = null;
for (let index = scopedEvents.length - 1; index >= 0; index -= 1) {
const sessionId = scopedEvents[index]?.sessionId;
if (sessionId) {
latestSessionId = sessionId;
break;
}
}
const latestSessionTurnIds = latestSessionId
? new Set(
scopedEvents
.filter((event) => event.sessionId === latestSessionId)
.map((event) => event.turnId)
.filter((turnId): turnId is string => turnId !== null),
)
: null;

for (let index = scopedEvents.length - 1; index >= 0; index -= 1) {
const event = scopedEvents[index];
if (!event) continue;
const belongsToLatestSession =
!latestSessionId ||
event.sessionId === latestSessionId ||
(event.sessionId === null &&
event.turnId !== null &&
latestSessionTurnIds?.has(event.turnId));
if (!belongsToLatestSession) continue;
const skills = availableSkillsFromEvent(event);
if (skills !== null) return skills;
}
return [];
}

export function buildSkillInsertion(
text: string,
cursor: number,
skillName: string,
): {
insertText: string;
replaceFromOffset: number;
replaceToOffset: number;
} | null {
const name = skillName.trim().replace(/^\/+/, "");
if (!name || /\s/.test(name)) return null;

const safeCursor = Math.max(0, Math.min(cursor, text.length));
const needsLeadingSpace =
safeCursor > 0 && !/\s/.test(text.charAt(safeCursor - 1));
const needsTrailingSpace =
safeCursor === text.length || !/\s/.test(text.charAt(safeCursor));

return {
insertText: `${needsLeadingSpace ? " " : ""}/${name}${
needsTrailingSpace ? " " : ""
}`,
replaceFromOffset: safeCursor,
replaceToOffset: safeCursor,
};
}
35 changes: 35 additions & 0 deletions desktop/src/features/messages/lib/useComposerAgentSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from "react";

import {
getAgentObserverSnapshot,
subscribeAgentObserverStore,
} from "@/features/agents/observerRelayStore";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { extractAvailableAgentSkills } from "./composerAgentSkills";

export function useComposerAgentSkills(
agentPubkey: string | null,
channelId: string | null,
) {
const [revision, setRevision] = React.useState(0);

React.useEffect(() => {
if (!agentPubkey) return;
const normalizedAgentPubkey = normalizePubkey(agentPubkey);
return subscribeAgentObserverStore((update) => {
if (
!update ||
normalizePubkey(update.agentPubkey) === normalizedAgentPubkey
) {
setRevision((current) => current + 1);
}
});
}, [agentPubkey]);

if (!agentPubkey) return [];
void revision;
return extractAvailableAgentSkills(
getAgentObserverSnapshot(agentPubkey, true).events,
channelId,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as React from "react";

import type { AutocompleteEdit } from "@/features/messages/lib/useRichTextEditor";
import {
buildSkillInsertion,
type ComposerAgentSkill,
} from "./composerAgentSkills";
import { useComposerAgentSkills } from "./useComposerAgentSkills";

type AddressedAgent = { displayName: string; pubkey: string };
type PlainTextCursor = { cursor: number; text: string };

export function useComposerInsertionActions<
ChannelSuggestion,
EmojiSuggestion,
>({
addressedAgents,
applyAutocompleteEdit,
channelId,
enabled,
getPlainTextAndCursor,
insertChannel,
insertEmoji,
}: {
addressedAgents: readonly AddressedAgent[];
applyAutocompleteEdit: (edit: AutocompleteEdit) => void;
channelId: string | null;
enabled: boolean;
getPlainTextAndCursor: () => PlainTextCursor;
insertChannel: (
suggestion: ChannelSuggestion,
cursor: number,
) => AutocompleteEdit;
insertEmoji: (
suggestion: EmojiSuggestion,
cursor: number,
) => AutocompleteEdit;
}) {
const skillAgent =
enabled && addressedAgents.length === 1 ? addressedAgents[0] : null;
const skills = useComposerAgentSkills(skillAgent?.pubkey ?? null, channelId);

const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const { cursor } = getPlainTextAndCursor();
applyAutocompleteEdit(insertChannel(suggestion, cursor));
},
[applyAutocompleteEdit, getPlainTextAndCursor, insertChannel],
);
const applyEmojiInsert = React.useCallback(
(suggestion: EmojiSuggestion) => {
const { cursor } = getPlainTextAndCursor();
applyAutocompleteEdit(insertEmoji(suggestion, cursor));
},
[applyAutocompleteEdit, getPlainTextAndCursor, insertEmoji],
);
const insertSkill = React.useCallback(
(skill: ComposerAgentSkill) => {
const { cursor, text } = getPlainTextAndCursor();
const edit = buildSkillInsertion(text, cursor, skill.name);
if (edit) applyAutocompleteEdit(edit);
},
[applyAutocompleteEdit, getPlainTextAndCursor],
);

return {
applyChannelInsert,
applyEmojiInsert,
insertSkill,
skillAgentDisplayName: skillAgent?.displayName,
skills,
};
}
Loading