Skip to content

Commit 166536e

Browse files
authored
fix(agent): inject local skill context into codex follow-up prompts
Codex bakes its skill catalog into the thread when it starts, so a local skill bundle installed by a follow-up message is invisible to it. The Claude adapter already consumes _meta.localSkillContext to inline the invoked skill's definition; the codex adapter dropped it, so follow-up /my-skill invocations reached codex as meaningless plain text. - codex adapter: consume localSkillContext/localSkillName — prepend the inlined skill definition to the forwarded turn input and drop the bare /name chunk (echo unchanged), mirroring the Claude adapter. - agent-server: list co-installed skills (auto-bundled dependencies and skills from earlier messages) with their installed paths in the injected context. - agent-server: announce mid-message skill installs — messages that install bundles without being a bare invocation get a context that inlines skills named in the message (token-boundary matched) and lists the rest by path.
1 parent f4f299d commit 166536e

4 files changed

Lines changed: 491 additions & 31 deletions

File tree

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2054,6 +2054,125 @@ describe("CodexAppServerAgent", () => {
20542054
]);
20552055
});
20562056

2057+
it("injects _meta.localSkillContext in place of the bare skill command, echo unchanged", async () => {
2058+
const stub = makeStubRpc({
2059+
"thread/start": { thread: { id: "t" } },
2060+
"turn/start": { turn: { id: "turn_1" } },
2061+
});
2062+
const { client, sessionUpdates } = makeFakeClient();
2063+
const agent = new CodexAppServerAgent(client, {
2064+
processOptions: { binaryPath: "/x/codex" },
2065+
rpcFactory: stub.factory,
2066+
});
2067+
2068+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
2069+
const done = agent.prompt({
2070+
sessionId: "t",
2071+
prompt: [{ type: "text", text: "/my-skill do the thing" }],
2072+
_meta: {
2073+
localSkillContext: "BEGIN SKILL my-skill ... END SKILL",
2074+
localSkillName: "my-skill",
2075+
},
2076+
} as unknown as PromptRequest);
2077+
stub.emit("turn/completed", { turn: { status: "completed" } });
2078+
await done;
2079+
2080+
const turnStart = stub.requests.find((r) => r.method === "turn/start");
2081+
expect(
2082+
(turnStart?.params as { input: Array<{ text?: string }> }).input,
2083+
).toEqual([
2084+
{
2085+
type: "text",
2086+
text: "BEGIN SKILL my-skill ... END SKILL",
2087+
text_elements: [],
2088+
},
2089+
]);
2090+
// The echoed user turn still shows what the user actually typed.
2091+
const echoes = (sessionUpdates as any[]).filter(
2092+
(u) => u.update?.sessionUpdate === "user_message_chunk",
2093+
);
2094+
expect(echoes).toEqual([
2095+
{
2096+
sessionId: "t",
2097+
update: {
2098+
sessionUpdate: "user_message_chunk",
2099+
content: { type: "text", text: "/my-skill do the thing" },
2100+
},
2101+
},
2102+
]);
2103+
});
2104+
2105+
it("orders prContext before localSkillContext and keeps non-command chunks", async () => {
2106+
const stub = makeStubRpc({
2107+
"thread/start": { thread: { id: "t" } },
2108+
"turn/start": { turn: { id: "turn_1" } },
2109+
});
2110+
const { client } = makeFakeClient();
2111+
const agent = new CodexAppServerAgent(client, {
2112+
processOptions: { binaryPath: "/x/codex" },
2113+
rpcFactory: stub.factory,
2114+
});
2115+
2116+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
2117+
const done = agent.prompt({
2118+
sessionId: "t",
2119+
prompt: [
2120+
{ type: "text", text: "/my-skill" },
2121+
{ type: "text", text: "also check the README" },
2122+
],
2123+
_meta: {
2124+
prContext: "PR #123 is open.",
2125+
localSkillContext: "SKILL DEFINITION",
2126+
localSkillName: "my-skill",
2127+
},
2128+
} as unknown as PromptRequest);
2129+
stub.emit("turn/completed", { turn: { status: "completed" } });
2130+
await done;
2131+
2132+
const turnStart = stub.requests.find((r) => r.method === "turn/start");
2133+
expect(
2134+
(turnStart?.params as { input: Array<{ text?: string }> }).input,
2135+
).toEqual([
2136+
{ type: "text", text: "PR #123 is open.", text_elements: [] },
2137+
{ type: "text", text: "SKILL DEFINITION", text_elements: [] },
2138+
{ type: "text", text: "also check the README", text_elements: [] },
2139+
]);
2140+
});
2141+
2142+
it("injects localSkillContext without a skill name and keeps the message text", async () => {
2143+
const stub = makeStubRpc({
2144+
"thread/start": { thread: { id: "t" } },
2145+
"turn/start": { turn: { id: "turn_1" } },
2146+
});
2147+
const { client } = makeFakeClient();
2148+
const agent = new CodexAppServerAgent(client, {
2149+
processOptions: { binaryPath: "/x/codex" },
2150+
rpcFactory: stub.factory,
2151+
});
2152+
2153+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
2154+
// a mid-message mention has no command chunk to strip, so no localSkillName
2155+
const done = agent.prompt({
2156+
sessionId: "t",
2157+
prompt: [{ type: "text", text: "please use /my-skill for this" }],
2158+
_meta: { localSkillContext: "ATTACHED SKILLS CONTEXT" },
2159+
} as unknown as PromptRequest);
2160+
stub.emit("turn/completed", { turn: { status: "completed" } });
2161+
await done;
2162+
2163+
const turnStart = stub.requests.find((r) => r.method === "turn/start");
2164+
expect(
2165+
(turnStart?.params as { input: Array<{ text?: string }> }).input,
2166+
).toEqual([
2167+
{ type: "text", text: "ATTACHED SKILLS CONTEXT", text_elements: [] },
2168+
{
2169+
type: "text",
2170+
text: "please use /my-skill for this",
2171+
text_elements: [],
2172+
},
2173+
]);
2174+
});
2175+
20572176
it("echoes an image-only user turn as a user_message_chunk", async () => {
20582177
const stub = makeStubRpc({
20592178
"thread/start": { thread: { id: "t" } },

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
emptyBaseline,
3636
estimateTokens,
3737
} from "../claude/context-breakdown";
38+
import { isLocalSkillCommandChunk } from "../local-skill";
3839
import {
3940
AppServerClient,
4041
type AppServerClientHandlers,
@@ -551,12 +552,48 @@ export class CodexAppServerAgent extends BaseAcpAgent {
551552
this.planHandoffCancel?.();
552553
// Prepend _meta.prContext (host PR-follow-up / Slack runs) to the FORWARDED prompt,
553554
// else codex cloud follow-ups lose the PR-review context. The echo omits it.
554-
const prContext = (params._meta as { prContext?: unknown } | undefined)
555-
?.prContext;
555+
const meta = params._meta as
556+
| {
557+
prContext?: unknown;
558+
localSkillContext?: unknown;
559+
localSkillName?: unknown;
560+
}
561+
| undefined;
562+
const prContext = meta?.prContext;
563+
// Inline installed local skill definitions (mirrors the Claude adapter):
564+
// codex's skill catalog is fixed at thread start, so mid-session installs
565+
// are invisible without this. A bare `/name` command chunk is dropped —
566+
// the injected context already carries the user's args.
567+
const localSkillContext =
568+
typeof meta?.localSkillContext === "string"
569+
? meta.localSkillContext
570+
: null;
571+
const localSkillName =
572+
typeof meta?.localSkillName === "string" ? meta.localSkillName : null;
573+
let forwarded = params.prompt;
574+
if (localSkillContext) {
575+
if (localSkillName) {
576+
let skippedLocalSkillCommand = false;
577+
forwarded = forwarded.filter((chunk) => {
578+
if (
579+
!skippedLocalSkillCommand &&
580+
isLocalSkillCommandChunk(chunk, localSkillName)
581+
) {
582+
skippedLocalSkillCommand = true;
583+
return false;
584+
}
585+
return true;
586+
});
587+
}
588+
forwarded = [
589+
{ type: "text" as const, text: localSkillContext },
590+
...forwarded,
591+
];
592+
}
556593
const promptBlocks =
557594
typeof prContext === "string" && prContext.length > 0
558-
? [{ type: "text" as const, text: prContext }, ...params.prompt]
559-
: params.prompt;
595+
? [{ type: "text" as const, text: prContext }, ...forwarded]
596+
: forwarded;
560597
const input = toCodexInput(promptBlocks);
561598
if (input.length === 0) {
562599
// turn/start rejects empty input, so end the turn cleanly.

0 commit comments

Comments
 (0)