Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public
- MCP / AGENTS docs no longer imply a silent `PRM_PASSWORD=workbench` default
- Settings → AI: OpenAI-compatible gateways are a separate provider from Cursor Cloud Agents
- Empty workspaces land in setup until finished or skipped; demo seed marks onboarding complete
- **Cursor Cloud Agents** chat: stream status and assistant text live via SSE; empty-result errors include agent/run ids and a dashboard link

### Fixed

Expand All @@ -29,6 +30,7 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public
- Desktop AI: clearer network errors; Test saves then probes with visible status
- Empty-workspace onboarding: optional EM name at create, single setup checklist, less Home clutter
- Settings / long-page scroll jank: section nav no longer forces layout on scroll; drop sticky chrome backdrop blur
- Cursor Cloud Agents: recover reply text from streamed assistant deltas when the terminal `result` field is empty

## [0.1.0-alpha.1] - TBD

Expand Down
199 changes: 198 additions & 1 deletion apps/api/src/ai.cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ describe("normalizeAiProvider", () => {
});
});

function sseResponse(chunks: string[]): Response {
const encoder = new TextEncoder();
let i = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (i >= chunks.length) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunks[i]));
i += 1;
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}

describe("Cursor Cloud Agents client", () => {
it("formats a grounded prompt", () => {
const text = formatCloudAgentPrompt(
Expand All @@ -65,7 +84,182 @@ describe("Cursor Cloud Agents client", () => {
assert.match(text, /Summarize \[ach_1\]/);
});

it("creates a no-repo agent, polls to FINISHED, archives", async () => {
it("streams assistant text via SSE and archives", async () => {
const progress: Array<{ type: string; detail?: string }> = [];
const calls: Array<{ url: string; method: string }> = [];
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
calls.push({ url, method });

if (method === "POST" && url.endsWith("/agents")) {
return new Response(
JSON.stringify({
agent: {
id: "bc-1",
url: "https://cursor.com/agents/bc-1",
latestRunId: "run-1",
},
run: { id: "run-1", agentId: "bc-1", status: "CREATING" },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "GET" && url.endsWith("/stream")) {
return sseResponse([
'event: status\ndata: {"runId":"run-1","status":"RUNNING"}\n\n',
'id: 1\nevent: assistant\ndata: {"text":"Hello "}\n\n',
'id: 2\nevent: assistant\ndata: {"text":"from stream"}\n\n',
'id: 3\nevent: result\ndata: {"runId":"run-1","status":"FINISHED","text":"Hello from stream","durationMs":1200}\n\n',
"id: 4\nevent: done\ndata: {}\n\n",
]);
}
if (method === "POST" && url.endsWith("/archive")) {
return new Response(JSON.stringify({ id: "bc-1", status: "ARCHIVED" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("unexpected", { status: 500 });
};

const result = await runCursorCloudAgent(
{
apiKey: "crsr_test",
messages: [{ role: "user", content: "ping" }],
feature: "chat_dossier",
model: "composer-2",
pollMs: 1,
timeoutMs: 5_000,
onProgress: (p) => {
if (p.type === "status") progress.push({ type: "status", detail: p.status });
if (p.type === "text") progress.push({ type: "text", detail: p.cumulative });
},
},
{ fetchFn, sleep: async () => undefined, now: () => 1 },
);

assert.equal(result.text, "Hello from stream");
assert.equal(result.model, "cursor-cloud:composer-2");
assert.equal(result.agentId, "bc-1");
assert.ok(progress.some((p) => p.type === "text" && p.detail === "Hello from stream"));
assert.ok(calls.some((c) => c.method === "POST" && c.url.endsWith("/archive")));
});

it("uses streamed assistant deltas when result text is empty", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();

if (method === "POST" && url.endsWith("/agents")) {
return new Response(
JSON.stringify({
agent: { id: "bc-2", url: "https://cursor.com/agents/bc-2" },
run: { id: "run-2", agentId: "bc-2", status: "RUNNING" },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "GET" && url.endsWith("/stream")) {
return sseResponse([
'event: assistant\ndata: {"text":"Recovered answer"}\n\n',
'event: result\ndata: {"runId":"run-2","status":"FINISHED","durationMs":800}\n\n',
"event: done\ndata: {}\n\n",
]);
}
if (method === "GET" && url.includes("/runs/run-2") && !url.endsWith("/stream")) {
return new Response(
JSON.stringify({
id: "run-2",
agentId: "bc-2",
status: "FINISHED",
result: "",
durationMs: 800,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "POST" && url.endsWith("/archive")) {
return new Response("{}", { status: 200 });
}
return new Response("unexpected", { status: 500 });
};

const result = await runCursorCloudAgent(
{
apiKey: "crsr_test",
messages: [{ role: "user", content: "ping" }],
feature: "chat_dossier",
pollMs: 1,
timeoutMs: 5_000,
},
{ fetchFn, sleep: async () => undefined, now: () => 1 },
);
assert.equal(result.text, "Recovered answer");
});

it("reports agent url and ids on truly empty FINISHED runs", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();

if (method === "POST" && url.endsWith("/agents")) {
return new Response(
JSON.stringify({
agent: { id: "bc-3", url: "https://cursor.com/agents/bc-3" },
run: { id: "run-3", agentId: "bc-3", status: "RUNNING" },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "GET" && url.endsWith("/stream")) {
return sseResponse([
'event: result\ndata: {"runId":"run-3","status":"FINISHED","durationMs":500}\n\n',
"event: done\ndata: {}\n\n",
]);
}
if (method === "GET" && url.includes("/runs/run-3") && !url.endsWith("/stream")) {
return new Response(
JSON.stringify({
id: "run-3",
agentId: "bc-3",
status: "FINISHED",
result: null,
durationMs: 500,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "POST" && url.endsWith("/archive")) {
return new Response("{}", { status: 200 });
}
return new Response("unexpected", { status: 500 });
};

await assert.rejects(
() =>
runCursorCloudAgent(
{
apiKey: "crsr_test",
messages: [{ role: "user", content: "ping" }],
feature: "chat_dossier",
pollMs: 1,
timeoutMs: 5_000,
},
{ fetchFn, sleep: async () => undefined, now: () => 1 },
),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.match(err.message, /empty result/);
assert.match(err.message, /agent bc-3/);
assert.match(err.message, /run run-3/);
assert.match(err.message, /https:\/\/cursor\.com\/agents\/bc-3/);
return true;
},
);
});

it("falls back to polling when stream endpoint is unavailable", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
let poll = 0;
const fetchFn: typeof fetch = async (input, init) => {
Expand All @@ -89,6 +283,9 @@ describe("Cursor Cloud Agents client", () => {
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "GET" && url.endsWith("/stream")) {
return new Response("gone", { status: 410 });
}
if (method === "GET" && url.includes("/runs/run-1")) {
poll += 1;
const status = poll === 1 ? "RUNNING" : "FINISHED";
Expand Down
39 changes: 37 additions & 2 deletions apps/api/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { eq } from "drizzle-orm";
import { aiSettings } from "@prm/db";
import { decryptSecret, getDb, getWorkspaceSecret, id, logActivity, nowIso } from "./store.js";
import { aiGenerations } from "@prm/db";
import { CURSOR_CLOUD_API_BASE, cursorCloudMe, runCursorCloudAgent } from "./cursorCloud.js";
import {
CURSOR_CLOUD_API_BASE,
cursorCloudMe,
runCursorCloudAgent,
type CursorCloudProgress,
} from "./cursorCloud.js";

export type AiFeature =
| "evidence_digest"
Expand Down Expand Up @@ -223,13 +228,24 @@ export async function probeCursorCloudKey(apiKey: string) {
};
}

export async function runChat(feature: AiFeature, messages: ChatMessage[]) {
export type RunChatOpts = {
/** Live updates (Cursor Cloud Agents SSE status / text / tools). */
onProgress?: (event: CursorCloudProgress) => void;
};

export async function runChat(feature: AiFeature, messages: ChatMessage[], opts: RunChatOpts = {}) {
const cfg = getAiConfig();
if (!cfg.enabled) {
throw new Error("AI disabled — enable in Settings → AI");
}
const maxTokens = feature === "framework_extract" ? 4096 : 1600;
if (cfg.localOnly || cfg.provider === "ollama") {
opts.onProgress?.({
type: "status",
status: "RUNNING",
agentId: "ollama",
runId: "local",
});
const text = await callOllama(cfg.ollamaBaseUrl, cfg.modelDraft || "llama3.1", messages);
return { text, model: `ollama:${cfg.modelDraft || "llama3.1"}`, provider: "ollama" as const };
}
Expand All @@ -244,6 +260,12 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) {
}
const model = feature === "evidence_digest" ? cfg.modelDigest : cfg.modelDraft;
if (cfg.provider === "openai") {
opts.onProgress?.({
type: "status",
status: "RUNNING",
agentId: "openai",
runId: "chat",
});
const text = await callOpenAI(cfg.apiKey, model || "gpt-4o-mini", messages);
return { text, model: `openai:${model}`, provider: "openai" as const };
}
Expand All @@ -254,6 +276,12 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) {
"or switch provider to Cursor Cloud Agents to use a crsr_… dashboard key.",
);
}
opts.onProgress?.({
type: "status",
status: "RUNNING",
agentId: "openai_compatible",
runId: "chat",
});
const text = await callOpenAI(
cfg.apiKey,
model || "gpt-4o-mini",
Expand All @@ -273,9 +301,16 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) {
messages,
feature,
model: model || null,
onProgress: opts.onProgress,
});
return { text: result.text, model: result.model, provider: "cursor" as const };
}
opts.onProgress?.({
type: "status",
status: "RUNNING",
agentId: "anthropic",
runId: "chat",
});
const text = await callAnthropic(cfg.apiKey, model || "claude-sonnet-4-5", messages, maxTokens);
return { text, model: `anthropic:${model}`, provider: "anthropic" as const };
}
Expand Down
Loading