Skip to content

Commit 2ffe20f

Browse files
egor-baranovslapoguzov
authored andcommitted
Implement per-session listSkills configuration via additionalRoots meta parameter
1 parent d332389 commit 2ffe20f

3 files changed

Lines changed: 141 additions & 0 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
import type {JsonValue} from "./app-server/serde_json/JsonValue";
1515
import {ModelId} from "./ModelId";
1616
import {AgentMode} from "./AgentMode";
17+
import {CodexAdditionalRootsProvider} from "./CodexAdditionalRootsProvider";
1718
import path from "node:path";
1819
import {logger} from "./Logger";
1920
import type {
@@ -38,13 +39,15 @@ import type {AuthenticationLogoutResponse, AuthenticationStatusResponse} from ".
3839
export class CodexAcpClient {
3940

4041
private readonly codexClient: CodexAppServerClient;
42+
private readonly additionalRootsProvider: CodexAdditionalRootsProvider;
4143
private readonly config: JsonObject;
4244
private readonly modelProvider: string | null;
4345
private gatewayConfig: GatewayConfig | null;
4446

4547

4648
constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) {
4749
this.codexClient = codexClient;
50+
this.additionalRootsProvider = new CodexAdditionalRootsProvider(codexClient);
4851
this.config = codexConfig ?? {};
4952
this.modelProvider = modelProvider ?? null;
5053
this.gatewayConfig = null;
@@ -179,6 +182,8 @@ export class CodexAcpClient {
179182
}
180183

181184
async resumeSession(request: acp.ResumeSessionRequest): Promise<SessionMetadata> {
185+
await this.additionalRootsProvider.refreshSkills(request);
186+
182187
const response = await this.codexClient.threadResume({
183188
approvalPolicy: null,
184189
sandbox: null,
@@ -228,6 +233,8 @@ export class CodexAcpClient {
228233
}
229234

230235
async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
236+
await this.additionalRootsProvider.refreshSkills(request);
237+
231238
const response = await this.codexClient.threadStart({
232239
config: this.createSessionConfig(request.cwd, request.mcpServers),
233240
modelProvider: this.getModelProvider(),
@@ -334,6 +341,8 @@ export class CodexAcpClient {
334341
): Promise<TurnCompletedNotification> {
335342
const input = buildPromptItems(request.prompt);
336343
const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion
344+
345+
await this.additionalRootsProvider.refreshSkills(request);
337346
await this.codexClient.turnStart({
338347
outputSchema: null,
339348
threadId: request.sessionId,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type {CodexAppServerClient} from "./CodexAppServerClient";
2+
3+
export class CodexAdditionalRootsProvider {
4+
constructor(private readonly codexClient: CodexAppServerClient) {}
5+
6+
async refreshSkills(request: { _meta?: Record<string, unknown> | null, cwd?: string }): Promise<void> {
7+
const additionalRoots = this.readAdditionalRoots(request._meta);
8+
const cwd = this.nonEmpty(request.cwd) ?? this.nonEmpty(request._meta?.["cwd"]);
9+
if (!cwd) {
10+
return;
11+
}
12+
13+
await this.codexClient.listSkills({
14+
cwds: [cwd],
15+
forceReload: true,
16+
perCwdExtraUserRoots: [{
17+
cwd: cwd,
18+
extraUserRoots: additionalRoots
19+
}]
20+
});
21+
}
22+
23+
private readAdditionalRoots(meta: Record<string, unknown> | null | undefined): string[] {
24+
const rawRoots = meta?.["additionalRoots"];
25+
if (!Array.isArray(rawRoots)) {
26+
return [];
27+
}
28+
29+
return Array.from(new Set(rawRoots
30+
.filter((value): value is string => typeof value === "string")
31+
.map(value => value.trim())
32+
.filter(value => value.length > 0)));
33+
}
34+
35+
private nonEmpty(value: unknown): string | null {
36+
if (typeof value !== "string") {
37+
return null;
38+
}
39+
const trimmed = value.trim();
40+
return trimmed.length > 0 ? trimmed : null;
41+
}
42+
}

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,96 @@ describe('ACP server test', { timeout: 40_000 }, () => {
105105
expect(newSessionResponse.sessionId).toBeDefined()
106106
})
107107

108+
it('prefetches session additional skill roots before thread start', async () => {
109+
const mockFixture = createCodexMockTestFixture();
110+
const codexAcpClient = mockFixture.getCodexAcpClient();
111+
const codexAppServerClient = mockFixture.getCodexAppServerClient();
112+
113+
const listSkillsSpy = vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({ data: [] });
114+
const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({
115+
thread: { id: "thread-id" } as any,
116+
model: "gpt-5",
117+
modelProvider: "openai",
118+
cwd: "/workspace",
119+
approvalPolicy: "on-request",
120+
sandbox: "workspace-write",
121+
reasoningEffort: "medium",
122+
} as any);
123+
vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({
124+
data: [{
125+
id: "gpt-5",
126+
model: "gpt-5",
127+
upgrade: null,
128+
displayName: "gpt-5",
129+
description: "test model",
130+
supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "balanced" }],
131+
defaultReasoningEffort: "medium",
132+
inputModalities: ["text"],
133+
supportsPersonality: false,
134+
isDefault: true
135+
}],
136+
nextCursor: null
137+
});
138+
139+
await codexAcpClient.newSession({
140+
cwd: "/workspace",
141+
mcpServers: [],
142+
_meta: {
143+
additionalRoots: ["/skills/one", " /skills/two ", 7]
144+
}
145+
});
146+
147+
expect(listSkillsSpy).toHaveBeenCalledWith({
148+
cwds: ["/workspace"],
149+
forceReload: true,
150+
perCwdExtraUserRoots: [{
151+
cwd: "/workspace",
152+
extraUserRoots: ["/skills/one", "/skills/two"]
153+
}]
154+
});
155+
expect(listSkillsSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!);
156+
});
157+
158+
it('prefetches session additional skill roots before turn start', async () => {
159+
const mockFixture = createCodexMockTestFixture();
160+
const codexAcpAgent = mockFixture.getCodexAcpAgent();
161+
const codexAppServerClient = mockFixture.getCodexAppServerClient();
162+
163+
const listSkillsSpy = vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({ data: [] });
164+
const turnStartSpy = vi.spyOn(codexAppServerClient, "turnStart").mockResolvedValue({
165+
turn: { id: "turn-id", items: [], status: "inProgress", error: null }
166+
} as any);
167+
vi.spyOn(codexAppServerClient, "awaitTurnCompleted").mockResolvedValue({
168+
threadId: "session-id",
169+
turn: { id: "turn-id", items: [], status: "completed", error: null }
170+
} as any);
171+
172+
vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(createTestSessionState({
173+
sessionId: "session-id",
174+
cwd: "/workspace"
175+
}));
176+
177+
const promptRequest: acp.PromptRequest = {
178+
sessionId: "session-id",
179+
prompt: [{ type: "text", text: "Hello" }],
180+
_meta: {
181+
additionalRoots: ["/skills/one", " /skills/two ", 7],
182+
cwd: "/workspace"
183+
}
184+
};
185+
await codexAcpAgent.prompt(promptRequest);
186+
187+
expect(listSkillsSpy).toHaveBeenCalledWith({
188+
cwds: ["/workspace"],
189+
forceReload: true,
190+
perCwdExtraUserRoots: [{
191+
cwd: "/workspace",
192+
extraUserRoots: ["/skills/one", "/skills/two"]
193+
}]
194+
});
195+
expect(listSkillsSpy.mock.invocationCallOrder[0]!).toBeLessThan(turnStartSpy.mock.invocationCallOrder[0]!);
196+
});
197+
108198
function loadNotifications(){
109199
//TODO collect logs form dev run and then load them from file to speedup
110200
const serverNotifications: ServerNotification[] = [

0 commit comments

Comments
 (0)