Skip to content

Commit 330ffd2

Browse files
committed
Merge branch 'skills-unreachable-sources' into skills-shared-store
2 parents d64a220 + 623fccb commit 330ffd2

3 files changed

Lines changed: 75 additions & 10 deletions

File tree

‎src/node/acp/agent.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,12 +1405,19 @@ export class MuxAgent implements Agent {
14051405
advertisedSkills = cachedSkillsByName ? Array.from(cachedSkillsByName.values()) : [];
14061406
}
14071407

1408+
await this.publishSessionCommands(sessionId, advertisedSkills);
1409+
}
1410+
1411+
private async publishSessionCommands(
1412+
sessionId: string,
1413+
skills: AgentSkillDescriptor[]
1414+
): Promise<void> {
14081415
try {
14091416
await this.connection.sessionUpdate({
14101417
sessionId,
14111418
update: {
14121419
sessionUpdate: "available_commands_update",
1413-
availableCommands: buildAcpAvailableCommands(advertisedSkills),
1420+
availableCommands: buildAcpAvailableCommands(skills),
14141421
},
14151422
});
14161423
} catch (error) {
@@ -1428,10 +1435,11 @@ export class MuxAgent implements Agent {
14281435
return cached;
14291436
}
14301437

1431-
return this.cacheSessionSkills(
1432-
sessionId,
1433-
await this.server.client.agentSkills.list({ workspaceId })
1434-
);
1438+
const result = await this.server.client.agentSkills.list({ workspaceId });
1439+
// No cache means the client was last sent a partial list (or none), so its
1440+
// command picker is stale too: send it the list just read.
1441+
await this.publishSessionCommands(sessionId, result.skills);
1442+
return this.cacheSessionSkills(sessionId, result);
14351443
}
14361444

14371445
private cacheSessionSkills(

‎src/node/services/agentPlugins/composition.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { discoverAgentDefinitions } from "@/node/services/agentDefinitions/agent
1010
import {
1111
discoverAgentSkills,
1212
getDefaultAgentSkillsRoots,
13+
requireReachableSkills,
1314
} from "@/node/services/agentSkills/agentSkillsService";
1415
import { getErrorMessage } from "@/common/utils/errors";
1516
import { log } from "@/node/services/log";
@@ -182,10 +183,14 @@ export async function buildWorkspaceComposition(
182183
}
183184
: undefined;
184185

185-
const { skills: skillDescriptors } = await discoverAgentSkills(args.runtime, args.workspacePath, {
186-
dedupeByName: false,
187-
...(checkoutSkillRoots ?? {}),
188-
});
186+
// Like the turn's skill index, the inspector must not report a composition
187+
// that silently lost a project root to an unreachable host.
188+
const skillDescriptors = requireReachableSkills(
189+
await discoverAgentSkills(args.runtime, args.workspacePath, {
190+
dedupeByName: false,
191+
...(checkoutSkillRoots ?? {}),
192+
})
193+
);
189194
const skills = markShadowed(
190195
skillDescriptors.map((skill) => ({
191196
name: skill.name,

‎tests/ipc/acp.promptCorrelation.test.ts‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk";
22
import { STOP_UNRECORDED_MESSAGE } from "../../src/common/constants/workspace";
33
import type { OnChatMode, WorkspaceChatMessage } from "../../src/common/orpc/types";
4+
import type { AgentSkillListResult } from "../../src/common/types/agentSkill";
45
import { MuxAgent } from "../../src/node/acp/agent";
56
import type { ORPCClient, ServerConnection } from "../../src/node/acp/serverConnection";
67

@@ -291,6 +292,7 @@ interface HarnessOptions {
291292
sendHeldInput?: (
292293
input: Record<string, unknown>
293294
) => Promise<{ success: boolean; data?: unknown; error?: unknown }>;
295+
listSkills?: () => Promise<AgentSkillListResult>;
294296
/** Custom output WritableStream for simulating stdout backpressure. */
295297
acpOutputStream?: WritableStream<Uint8Array>;
296298
agentOptions?: ConstructorParameters<typeof MuxAgent>[2];
@@ -338,7 +340,12 @@ function createHarness(options?: HarnessOptions): Harness {
338340
list: async () => [],
339341
},
340342
agentSkills: {
341-
list: async () => ({ skills: [], invalidSkills: [], unavailableSources: [] }),
343+
list: async () =>
344+
(await options?.listSkills?.()) ?? {
345+
skills: [],
346+
invalidSkills: [],
347+
unavailableSources: [],
348+
},
342349
get: async () => {
343350
throw new Error("createHarness: get not implemented for this test");
344351
},
@@ -1753,6 +1760,51 @@ describe("ACP prompt stream correlation", () => {
17531760
}, 15_000);
17541761
});
17551762

1763+
describe("ACP slash commands after a partial skill list", () => {
1764+
it("sends the client the complete list once a slash prompt reads it", async () => {
1765+
const partial: AgentSkillListResult = {
1766+
skills: [],
1767+
invalidSkills: [],
1768+
unavailableSources: [
1769+
{ scope: "project", displayPath: "/remote/.xum/skills", message: "host unreachable" },
1770+
],
1771+
};
1772+
const complete: AgentSkillListResult = {
1773+
skills: [{ name: "deploy", description: "Deploy the app", scope: "project" }],
1774+
invalidSkills: [],
1775+
unavailableSources: [],
1776+
};
1777+
let listCount = 0;
1778+
const harness = createHarness({
1779+
listSkills: async () => (listCount++ === 0 ? partial : complete),
1780+
});
1781+
const advertised = () =>
1782+
harness.sessionUpdates
1783+
.filter(({ update }) => update.sessionUpdate === "available_commands_update")
1784+
.map(({ update }) => (update.availableCommands as { name: string }[]).map((c) => c.name));
1785+
1786+
await initializeDefaultAgent(harness);
1787+
const { sessionId } = await createDefaultSession(harness);
1788+
await waitForCondition(() => advertised().length === 1);
1789+
expect(advertised()[0]).not.toContain("deploy");
1790+
1791+
const { promptPromise, promptCorrelationId } = await startPromptTurn(
1792+
harness,
1793+
sessionId,
1794+
"/deploy now"
1795+
);
1796+
harness.pushChatEvent(
1797+
streamStart(sessionId, "assistant-deploy", { acpPromptId: promptCorrelationId })
1798+
);
1799+
harness.pushChatEvent(streamEnd(sessionId, "assistant-deploy"));
1800+
await expect(promptPromise).resolves.toMatchObject({ stopReason: "end_turn" });
1801+
1802+
expect(advertised().at(-1)).toContain("deploy");
1803+
harness.closeConnection();
1804+
await harness.connectionClosed;
1805+
});
1806+
});
1807+
17561808
describe("ACP held inputs (#4944)", () => {
17571809
const heldInputs = ["queued follow-up", "second queued"].map((displayText, index) => ({
17581810
id: `held-${index + 1}`,

0 commit comments

Comments
 (0)