Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 4f5224122a fix(codex): invoke an inline skill steered into a running turn 2026-09-26 19:54:47 +02:00
paseo-bot[bot] af27e28054 fix(codex): invoke a skill picked from autocomplete after prose
Composer autocomplete inserts a Codex skill as /name anywhere in the
prompt, but only a prompt starting with /name was sent to Codex as a
skill input item. A skill placed after other text reached Codex as plain
text and was never injected.

Resolve /name mentions of enabled skills in the prompt into skill input
items and $name text, the same shape the leading form already sends.

Closes #5008
2026-09-26 17:31:15 +02:00
4 changed files with 151 additions and 146 deletions
@@ -12,10 +12,7 @@ import {
} from "@/contexts/session-workspace-upserts";
import { WorkspaceDirectoryReplica } from "./workspace-replica";
function workspace(
id: string,
projectId = "project"
): WorkspaceDescriptorPayload {
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
return {
id,
projectId,
@@ -50,16 +47,11 @@ it("commits workspace and project-parent state with filtered removals", () => {
{
workspaces: new Map([
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
[
"filtered",
normalizeWorkspaceDescriptor(
workspace("filtered", "filtered-project")
),
],
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
]),
projects: new Map([[empty.projectId, empty]]),
},
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }]
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
);
const session = useSessionStore.getState().sessions[serverId];
@@ -73,16 +65,10 @@ it("commits the authoritative snapshot before buffered project updates", () => {
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const attachedMain = normalizeWorkspaceDescriptor(
workspace("attached-main", "attached")
);
const attachedFeature = normalizeWorkspaceDescriptor(
workspace("attached-feature", "attached")
);
const attachedMain = normalizeWorkspaceDescriptor(workspace("attached-main", "attached"));
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
const unrelated = normalizeWorkspaceDescriptor(
workspace("unrelated", "unrelated")
);
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
const staleAttachedProject = normalizeProjectDescriptor({
projectId: "attached",
projectDisplayName: "Stale attached project",
@@ -138,7 +124,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
},
},
{ kind: "remove", projectId: "removed" },
]
],
);
const session = useSessionStore.getState().sessions[serverId];
@@ -192,11 +178,9 @@ it("preserves unchanged project identity when another project changes", () => {
["second", second],
]),
},
[]
[],
);
const previousSecond = useSessionStore
.getState()
.sessions[serverId]?.projects.get("second");
const previousSecond = useSessionStore.getState().sessions[serverId]?.projects.get("second");
replica.commitSnapshot(
{
@@ -206,12 +190,12 @@ it("preserves unchanged project identity when another project changes", () => {
["second", { ...second }],
]),
},
[]
[],
);
expect(
useSessionStore.getState().sessions[serverId]?.projects.get("second")
).toBe(previousSecond);
expect(useSessionStore.getState().sessions[serverId]?.projects.get("second")).toBe(
previousSecond,
);
store.clearSession(serverId);
});
@@ -221,10 +205,7 @@ it("does not invent a null-key project from a workspace update", () => {
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
replica.applyDelta({
kind: "upsert",
workspace: workspace("main", "fresh-project"),
});
replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") });
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.has("main")).toBe(true);
@@ -241,40 +222,11 @@ it("does not restore a targeted cached workspace while its archive is pending",
markWorkspaceArchivePending({ serverId, workspaceId });
try {
replica.commitCachedWorkspace(
normalizeWorkspaceDescriptor(workspace(workspaceId)),
undefined
);
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
expect(
useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)
).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
} finally {
clearWorkspaceArchivePending({ serverId, workspaceId });
store.clearSession(serverId);
}
});
it("keeps a live pin when the older workspace creation response arrives after it (#5447)", () => {
const serverId = "creation-response-after-pin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const created = workspace("created");
const pinnedAt = "2026-09-26T12:29:32.206Z";
replica.applyDelta({ kind: "upsert", workspace: created });
replica.applyDelta({ kind: "upsert", workspace: { ...created, pinnedAt } });
replica.acceptWorkspaces([
{
...normalizeWorkspaceDescriptor({ ...created, pinnedAt: null }),
status: "running",
},
]);
const row = useSessionStore
.getState()
.sessions[serverId]?.workspaces.get("created");
expect(row?.pinnedAt).toBe(pinnedAt);
store.clearSession(serverId);
});
@@ -242,6 +242,54 @@ describe("Codex active-turn steering admission", () => {
appServer.assertNoErrors();
});
test("a steer resolves a Codex skill mentioned after prose into app-server skill input", async () => {
const appServer = createFakeCodexAppServer({
"skills/list": () => ({
data: [
{
cwd: "/workspace/project",
skills: [
{
name: "paseo-implement",
description: "Execute an existing Paseo plan.",
path: "/workspace/skills/paseo-implement/SKILL.md",
},
],
errors: [],
},
],
}),
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
await expect(
session.steerActiveTurn!("Then use /paseo-implement to complete it.", {
expectedTurnId: paseoTurnId,
}),
).resolves.toEqual({ status: "accepted" });
const steer = appServer.requests().find((request) => request.method === "turn/steer");
expect(steer?.params).toEqual(
expect.objectContaining({
input: [
{
type: "skill",
name: "paseo-implement",
path: "/workspace/skills/paseo-implement/SKILL.md",
},
{
type: "text",
text: "Then use $paseo-implement to complete it.",
text_elements: [],
},
],
}),
);
await session.close();
appServer.assertNoErrors();
});
test("a clearing steer denies every pending permission through its provider handler", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
@@ -2233,6 +2281,59 @@ describe("Codex app-server provider", () => {
);
});
test("resolves a Codex skill mentioned after prose into app-server skill input", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "skills/list") {
return {
data: [
{
cwd: "/tmp/codex-question-test",
skills: [
{
name: "paseo-implement",
description: "Execute an existing Paseo plan.",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
],
errors: [],
},
],
};
}
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("Review the accepted plan, then use /paseo-implement to complete it.");
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
input: [
{
type: "skill",
name: "paseo-implement",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
{
type: "text",
text: "Review the accepted plan, then use $paseo-implement to complete it.",
text_elements: [],
},
],
}),
);
});
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
const commands = await listCommandsFromFakeCodex([
{
@@ -3169,6 +3169,26 @@ function enabledCodexSkills(
return Array.from(skillsByName.values());
}
// Composer autocomplete inserts skills as `/name` anywhere in the prompt, while
// Codex only injects a skill it receives as a skill input item.
const SKILL_MENTION_PATTERN = /(^|\s)\/([^\s/"']+)/g;
function resolveCodexSkillMentions(
text: string,
skills: ReadonlyArray<{ name: string; path: string }>,
): { skills: CodexSkillPromptBlock[]; text: string } {
const mentioned = new Map<string, CodexSkillPromptBlock>();
const rewritten = text.replace(SKILL_MENTION_PATTERN, (mention, prefix: string, name: string) => {
const skill = skills.find((entry) => entry.name === name);
if (!skill) {
return mention;
}
mentioned.set(skill.name, { type: "skill", name: skill.name, path: skill.path });
return `${prefix}$${skill.name}`;
});
return { skills: Array.from(mentioned.values()), text: rewritten };
}
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
type CodexPromptInput = string | CodexPromptContentBlock[];
interface CodexTextElement {
@@ -4063,6 +4083,18 @@ export class CodexAppServerAgentSession implements AgentSession {
return args ? `$${commandName} ${args}` : `$${commandName}`;
}
private async attachMentionedSkills(prompt: AgentPromptInput): Promise<CodexPromptInput> {
if (typeof prompt !== "string" || !prompt.includes("/")) {
return prompt;
}
await this.loadSkills();
const resolved = resolveCodexSkillMentions(prompt, this.cachedSkills ?? []);
if (resolved.skills.length === 0) {
return prompt;
}
return [...resolved.skills, { type: "text", text: resolved.text }];
}
private async buildTurnStartParams(
prompt: CodexPromptInput,
options?: AgentRunOptions,
@@ -4266,7 +4298,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
: await this.attachMentionedSkills(prompt);
if (this.currentThreadId) {
await this.ensureThreadLoaded();
@@ -4333,7 +4365,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
const input = await this.buildUserInput(prompt);
const input = await this.buildUserInput(await this.attachMentionedSkills(prompt));
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
@@ -1,80 +0,0 @@
import { test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { DaemonClient } from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
// #5447: a second client pins a workspace as soon as it sees it appear, while
// the creating client is still waiting for its initial agent.
test("workspace.create response reflects a pin made while the initial agent was starting", async () => {
const daemon = await createTestPaseoDaemon();
const directory = mkdtempSync(path.join(tmpdir(), "workspace-pin-order-"));
const creator = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.9.2",
});
const pinner = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.9.2",
});
const seen: Array<{ source: string; pinnedAt: string | null | undefined }> =
[];
try {
await creator.connect();
await pinner.connect();
await creator.fetchWorkspaces({ subscribe: {} });
await pinner.fetchWorkspaces({ subscribe: {} });
const pinned = new Set<string>();
pinner.subscribeRawMessages((message) => {
if (
message.type !== "workspace_update" ||
message.payload.kind !== "upsert"
)
return;
const id = message.payload.workspace.id;
if (pinned.has(id)) return;
pinned.add(id);
void pinner.setWorkspacePinned(id, true);
});
creator.subscribeRawMessages((message) => {
if (
message.type === "workspace_update" &&
message.payload.kind === "upsert"
) {
seen.push({
source: "workspace_update",
pinnedAt: message.payload.workspace.pinnedAt,
});
}
if (message.type === "workspace.create.response") {
seen.push({
source: "create_response",
pinnedAt: message.payload.workspace?.pinnedAt,
});
}
});
const created = await creator.createWorkspace({
source: { kind: "directory", path: directory },
agent: { provider: "claude", cwd: directory, modeId: "default" },
});
expect(created.error).toBeNull();
const liveBeforeResponse = seen
.slice(
0,
seen.findIndex((entry) => entry.source === "create_response")
)
.filter((entry) => entry.source === "workspace_update")
.at(-1);
expect(liveBeforeResponse?.pinnedAt).toBeTruthy();
expect(created.workspace?.pinnedAt).toBe(liveBeforeResponse?.pinnedAt);
} finally {
await creator.close().catch(() => undefined);
await pinner.close().catch(() => undefined);
await daemon.close();
rmSync(directory, { recursive: true, force: true });
}
}, 180000);