Skip to content

Commit fa2ad16

Browse files
committed
feat: improve model picker metadata and model list sync
Keep the model picker complete by merging native ACP models with the full CLI list and add model-id hover metadata without cluttering picker labels.
1 parent 43fee8d commit fa2ad16

5 files changed

Lines changed: 184 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ If `cursor-acp` is not on your PATH, use the full absolute path to the entry poi
171171
```
172172

173173
- `default_mode` — one of `default`, `yolo`, `plan`, or `ask` (legacy alias: `acceptEdits``default`)
174-
- `default_model` — optional model ID (e.g. the model ID shown by `/model`)
174+
- `default_model` — optional model ID (e.g. one of the model IDs shown by `/model`)
175175

176176
Omit keys you do not need.
177177

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@raphael/cursor-acp",
3-
"version": "0.5.0",
3+
"version": "0.5.1",
44
"description": "ACP adapter backed by Cursor Agent CLI",
55
"main": "dist/lib.js",
66
"types": "dist/lib.d.ts",

src/cursor-acp-agent.ts

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ export class CursorAcpAgent implements Agent {
576576
try {
577577
const loaded = await nativeClient.loadSessionBackend(loadId);
578578
session.backendSessionId = loadId;
579-
this.applyNativeSessionModelsAndModes(session, loaded);
579+
await this.applyNativeSessionModelsAndModes(session, loaded);
580580
session.nativeLoadSucceeded = true;
581581
} catch (error) {
582582
this.logger.warn?.(
@@ -586,12 +586,12 @@ export class CursorAcpAgent implements Agent {
586586
session.nativeLoadSucceeded = false;
587587
const response = await nativeClient.createSessionBackend();
588588
session.backendSessionId = response.sessionId;
589-
this.applyNativeSessionModelsAndModes(session, response);
589+
await this.applyNativeSessionModelsAndModes(session, response);
590590
}
591591
} else {
592592
const response = await nativeClient.createSessionBackend();
593593
session.backendSessionId = response.sessionId;
594-
this.applyNativeSessionModelsAndModes(session, response);
594+
await this.applyNativeSessionModelsAndModes(session, response);
595595
}
596596

597597
try {
@@ -603,17 +603,61 @@ export class CursorAcpAgent implements Agent {
603603
await this.applyNativeModeAfterConnect(session, nativeClient);
604604
}
605605

606-
private applyNativeSessionModelsAndModes(
606+
private async applyNativeSessionModelsAndModes(
607607
session: SessionState,
608608
loaded: {
609609
models?: NewSessionResponse["models"];
610610
modes?: NewSessionResponse["modes"];
611611
},
612-
): void {
612+
): Promise<void> {
613613
if (loaded.models) {
614-
session.nativeSessionModels = loaded.models;
615-
if (loaded.models.currentModelId) {
616-
session.modelId = loaded.models.currentModelId;
614+
let listedModels: CursorModelDescriptor[] = [];
615+
try {
616+
listedModels = await this.runner.listModels();
617+
} catch (error) {
618+
this.logger.warn?.(
619+
"[cursor-acp] Unable to refresh full model list from CLI",
620+
error,
621+
);
622+
}
623+
624+
const merged = new Map<
625+
string,
626+
{ modelId: string; name: string; description: string }
627+
>();
628+
629+
for (const model of loaded.models.availableModels ?? []) {
630+
merged.set(model.modelId, {
631+
modelId: model.modelId,
632+
name: this.modelDisplayName(model.modelId, model.name),
633+
description: this.modelHoverDescription(
634+
model.modelId,
635+
model.description ?? model.name,
636+
),
637+
});
638+
}
639+
640+
for (const model of listedModels) {
641+
merged.set(model.modelId, {
642+
modelId: model.modelId,
643+
name: this.modelDisplayName(model.modelId, model.name),
644+
description: this.modelHoverDescription(model.modelId, model.name),
645+
});
646+
}
647+
648+
const currentModelId =
649+
loaded.models.currentModelId ??
650+
listedModels.find((model) => model.current)?.modelId ??
651+
session.modelId ??
652+
[...merged.keys()][0];
653+
654+
session.nativeSessionModels = {
655+
...loaded.models,
656+
currentModelId,
657+
availableModels: [...merged.values()],
658+
};
659+
if (currentModelId) {
660+
session.modelId = currentModelId;
617661
}
618662
}
619663

@@ -715,19 +759,30 @@ export class CursorAcpAgent implements Agent {
715759
const availableModels = listed.map((model) => ({
716760
modelId: model.modelId,
717761
name: model.name,
718-
description: model.name,
762+
description: this.modelHoverDescription(model.modelId, model.name),
719763
}));
720764

721-
if (!session.modelId) {
765+
const hasSelectedModel =
766+
typeof session.modelId === "string" &&
767+
listed.some((model) => model.modelId === session.modelId);
768+
if (!hasSelectedModel) {
722769
session.modelId = listed.find((model) => model.current)?.modelId ?? listed[0]?.modelId;
723770
}
724771

725772
return {
726773
availableModels,
727-
currentModelId: session.modelId,
774+
currentModelId: session.modelId ?? "auto",
728775
};
729776
}
730777

778+
private modelHoverDescription(modelId: string, baseDescription: string): string {
779+
return `${baseDescription} (id: ${modelId})`;
780+
}
781+
782+
private modelDisplayName(_modelId: string, name: string): string {
783+
return name;
784+
}
785+
731786
private async handleNativeSessionUpdate(
732787
session: SessionState,
733788
notification: SessionNotification,

src/tests/cursor-acp-agent.test.ts

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,16 @@ class FakeNativeBackend implements NativeSessionBackend {
129129

130130
return {
131131
models: {
132-
currentModelId: "gpt-5.2",
133-
availableModels: [{ modelId: "gpt-5.2", name: "GPT-5.2", description: "GPT-5.2" }],
132+
currentModelId: "gpt-5.4-medium",
133+
availableModels: [
134+
{ modelId: "gpt-5.4-medium", name: "GPT-5.4", description: "GPT-5.4" },
135+
{
136+
modelId: "gpt-5.4-medium-fast",
137+
name: "GPT-5.4 Fast",
138+
description: "GPT-5.4 Fast",
139+
},
140+
{ modelId: "gpt-5.2", name: "GPT-5.2", description: "GPT-5.2" },
141+
],
134142
},
135143
modes: {
136144
currentModeId: "agent",
@@ -215,7 +223,10 @@ function createAgentTestHarness() {
215223
async listModels() {
216224
return [
217225
{ modelId: "auto", name: "Auto", current: true },
226+
{ modelId: "gpt-5.4-medium", name: "GPT-5.4" },
227+
{ modelId: "gpt-5.4-medium-fast", name: "GPT-5.4 Fast" },
218228
{ modelId: "gpt-5.2", name: "GPT-5.2" },
229+
{ modelId: "claude-4.5-opus-high", name: "Opus 4.5" },
219230
];
220231
},
221232
} as any,
@@ -432,6 +443,29 @@ describe("CursorAcpAgent", () => {
432443
expect(backends).toHaveLength(0);
433444
});
434445

446+
it("exposes listed models in newSession model listing", async () => {
447+
const { agent } = createAgentTestHarness();
448+
449+
await agent.initialize({
450+
protocolVersion: 1,
451+
clientCapabilities: {},
452+
} as any);
453+
454+
const session = await agent.newSession({
455+
cwd: "/tmp",
456+
mcpServers: [],
457+
} as any);
458+
459+
expect(session.models?.currentModelId).toBe("auto");
460+
expect(session.models?.availableModels.map((model) => model.modelId)).toEqual([
461+
"auto",
462+
"gpt-5.4-medium",
463+
"gpt-5.4-medium-fast",
464+
"gpt-5.2",
465+
"claude-4.5-opus-high",
466+
]);
467+
});
468+
435469
it("uses default mode by default", async () => {
436470
const { agent } = createAgentTestHarness();
437471

@@ -500,7 +534,7 @@ describe("CursorAcpAgent", () => {
500534

501535
const response = await agent.prompt({
502536
sessionId: session.sessionId,
503-
prompt: [{ type: "text", text: "/model gpt-5.2" }],
537+
prompt: [{ type: "text", text: "/model gpt-5.4-medium" }],
504538
} as any);
505539

506540
expect(response.stopReason).toBe("end_turn");
@@ -510,6 +544,33 @@ describe("CursorAcpAgent", () => {
510544
recordSpy.mockRestore();
511545
});
512546

547+
it("accepts /model fast variants before first prompt without backend restart", async () => {
548+
const { agent, backends } = createAgentTestHarness();
549+
550+
await agent.initialize({
551+
protocolVersion: 1,
552+
clientCapabilities: {},
553+
} as any);
554+
const session = await agent.newSession({
555+
cwd: "/tmp",
556+
mcpServers: [],
557+
} as any);
558+
559+
const restartSpy = vi.spyOn(agent as any, "restartBackend");
560+
561+
const response = await agent.prompt({
562+
sessionId: session.sessionId,
563+
prompt: [{ type: "text", text: "/model gpt-5.4-medium-fast" }],
564+
} as any);
565+
566+
expect(response.stopReason).toBe("end_turn");
567+
expect(backends).toHaveLength(0);
568+
expect(restartSpy).not.toHaveBeenCalled();
569+
expect((agent as any).sessions[session.sessionId]?.modelId).toBe("gpt-5.4-medium-fast");
570+
571+
restartSpy.mockRestore();
572+
});
573+
513574
it("forwards permission requests in default mode", async () => {
514575
const { agent, backends, client } = createAgentTestHarness();
515576

@@ -644,12 +705,35 @@ describe("CursorAcpAgent", () => {
644705

645706
await agent.unstable_setSessionModel({
646707
sessionId: session.sessionId,
647-
modelId: "gpt-5.2",
708+
modelId: "gpt-5.4-medium",
709+
} as any);
710+
711+
expect(backends).toHaveLength(2);
712+
expect(backends[0]!.closeCalls).toBe(1);
713+
expect(backends[1]!.options.modelId).toBe("gpt-5.4-medium");
714+
});
715+
716+
it("passes fast model ids through to native backend restart", async () => {
717+
const { agent, backends } = createAgentTestHarness();
718+
719+
await agent.initialize({
720+
protocolVersion: 1,
721+
clientCapabilities: {},
722+
} as any);
723+
const session = await agent.newSession({
724+
cwd: "/tmp",
725+
mcpServers: [],
726+
} as any);
727+
await startNativeBackend(agent, session.sessionId);
728+
729+
await agent.unstable_setSessionModel({
730+
sessionId: session.sessionId,
731+
modelId: "gpt-5.4-medium-fast",
648732
} as any);
649733

650734
expect(backends).toHaveLength(2);
651735
expect(backends[0]!.closeCalls).toBe(1);
652-
expect(backends[1]!.options.modelId).toBe("gpt-5.2");
736+
expect(backends[1]!.options.modelId).toBe("gpt-5.4-medium-fast");
653737
});
654738

655739
it("rejects a second prompt while one is in progress", async () => {
@@ -720,7 +804,7 @@ describe("CursorAcpAgent", () => {
720804
await expect(
721805
agent.unstable_setSessionModel({
722806
sessionId: session.sessionId,
723-
modelId: "gpt-5.2",
807+
modelId: "gpt-5.4-medium",
724808
} as any),
725809
).rejects.toThrow("Invalid params");
726810

@@ -823,7 +907,14 @@ describe("CursorAcpAgent", () => {
823907

824908
expect(backends).toHaveLength(1);
825909
expect(backends[0]!.loadCalls).toEqual(["be-native-1"]);
826-
expect(response.models?.currentModelId).toBe("gpt-5.2");
910+
expect(response.models?.currentModelId).toBe("gpt-5.4-medium");
911+
expect(response.models?.availableModels.map((model) => model.modelId)).toEqual([
912+
"gpt-5.4-medium",
913+
"gpt-5.4-medium-fast",
914+
"gpt-5.2",
915+
"auto",
916+
"claude-4.5-opus-high",
917+
]);
827918
expect(
828919
client.updates.filter((u) => u.update?.sessionUpdate === "user_message_chunk"),
829920
).toHaveLength(1);

src/tests/slash-commands.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ describe("slash commands", () => {
5252
expect(session.modelId).toBe("gpt-5.2");
5353
});
5454

55+
it("handles /model set for fast variants", async () => {
56+
const session = { modelId: "auto", modeId: "default" as const };
57+
const result = await handleSlashCommand("model", "gpt-5.2-fast", {
58+
session,
59+
auth: mockAuth,
60+
listModels: async () => [
61+
{ modelId: "auto", name: "Auto" },
62+
{ modelId: "auto-fast", name: "Auto (Fast)" },
63+
{ modelId: "gpt-5.2", name: "GPT-5.2" },
64+
{ modelId: "gpt-5.2-fast", name: "GPT-5.2 (Fast)" },
65+
],
66+
});
67+
68+
expect(result.handled).toBe(true);
69+
expect(result.responseText).toContain("Model set to gpt-5.2-fast");
70+
expect(session.modelId).toBe("gpt-5.2-fast");
71+
});
72+
5573
it("handles /mode set", async () => {
5674
const session = { modelId: "auto", modeId: "default" as const };
5775
const result = await handleSlashCommand("mode", "yolo", {

0 commit comments

Comments
 (0)