Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### Fixed

- Switching an existing Senpi conversation from another provider to `cursor-cli-oauth` now reinjects the bounded recent transcript into the first Cursor CLI turn, instead of sending only the latest user message and making Cursor respond as if the conversation were new.

### New Features

### Breaking Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Generated: 2026-08-17
| `transport.ts` | Spawns the resolved executable detached in its own process group with an explicit env allowlist (`HOME` = the account home, `AGENT_CLI_CREDENTIAL_STORE=file`, `PATH`/`TERM`/`LANG`/`LC_ALL`/`FORCE_COLOR`); rejects prompts over 130 KB pre-spawn; abort sends SIGTERM to the group then SIGKILL after 5 s; exposes the pid, parsed events, bounded stderr, and a settled outcome |
| `home-store.ts` | Durable per-account HOMEs under `<agentDir>/cursor-cli-oauth/accounts/<slot>/home`: rewrites `.cursor/auth.json` (`accessToken`/`refreshToken`/`apiKey: null`/`bedrockCredentials: null`) at mode 0600 inside 0700 directories immediately before each run, reads back rotated refresh tokens after; logs byte lengths only; traversal-checked paths; never deletes a HOME |
| `oauth-login.ts` | Provider OAuth config (`check`/`login`/`refreshToken`/`getApiKey`) reusing the `packages/ai` Cursor PKCE flow, first slot named `default`; one `configuredFor` predicate backs both `check` and turn-time lane resolution (`file-store` only, no ambient branch exists); local desktop/keychain import remains explicit, while `importNativeCursorCredential` is shared by explicit and automatic Senpi-native credential copies |
| `session-router.ts` | Sticky chat routing: per-senpi-session `{accountName, chatId, lastModel}` captured from `system/init`; same- or different-model turns resume via `--resume`, a model switch prepends a one-turn 8 KB context recap, and resume failure or `context_overflow` restarts a fresh chat with the recap plus a notice; prompt and recap are shrunk to the transport ceiling before spawning |
| `session-router.ts` | Sticky chat routing: per-senpi-session `{accountName, chatId, lastModel}` captured from `system/init`; same- or different-model turns resume via `--resume`, transitions from another provider and later Cursor model switches prepend a one-turn 8 KB context recap, and resume failure or `context_overflow` restarts a fresh chat with the recap plus a notice; prompt and recap are shrunk to the transport ceiling before spawning |
| `failover.ts` | Account rotation around one attempt: `rate_limit` blocks the slot (server hint else 60 s, max 48 h), `auth_error` blocks until re-login; retries only before any visible assistant delta; a replacement account always starts a fresh chat with a user-visible notice and never inherits chat context |
| `models.ts` | Model catalog: cached `cursor-agent models` probe (15 s deadline, full-stdout file capture, ANSI strip, `<id> - <label>` parsing, TTL cache at `<agentDir>/cursor-cli-oauth/models.json`) degrading to the exact 15-entry static fallback; zero cost, text-only input, 64 K max tokens |
| `guardrails.ts` | Execution policy: `--force` only in agent mode with `noApprovalAcknowledgedAt` set (typed `CursorCliExecutionRefusalError` naming the acknowledgement step otherwise); plan mode never forces; force-disabled agent mode and unproven sandbox modes warn once per session; deny lists sanitized to exact full commands and written per-spawn as `permissions.deny` `Shell(...)` entries in the account HOME's `cli-config.json` |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# cursor-cli-oauth extension changes

## 2026-09-01 - Preserve Senpi context on first Cursor CLI turn

### What changed

- `stream.ts` and `session-router.ts`: the turn boundary now detects when the immediately preceding assistant response came from another provider and requests a bounded Senpi context recap, covering both first-time Cursor use and returns to an existing Cursor chat. Resume-disabled turns also recap because every CLI invocation is a fresh chat. The current user prompt is removed from recap composition so it is sent exactly once. Same-turn failover and cross-turn account reselection both suppress the recap, and replacement accounts are forced off any older sticky chat, preserving the cross-account context-isolation contract.
- `session-router.test.ts` and `stream.test.ts`: add regressions for first-time and returning provider switches, resume-disabled continuity, the existing recap opt-out, single-copy current prompts on resume fallback, end-to-end provider detection, same-turn/cross-turn account isolation, and replacement accounts with stale bindings.

### Why

- The router previously built recaps only for model switches inside an already-bound Cursor chat. Switching from another provider either had no Cursor routing record or resumed an older Cursor chat, so the CLI received only the latest user message and missed the intervening Senpi conversation.

### Why an extension could not handle it

- This is the builtin extension's private prompt-composition boundary. External hooks cannot access its in-memory Cursor chat binding or alter the subprocess prompt after routing.

### Expected merge conflict zones

- LOW: `stream.ts` around turn-input/failover composition and `session-router.ts` around recap planning, plus their focused test suites.

## 2026-08-24 - Keep provider tool protocol out of assistant text

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export type CursorCliSessionTurnInput = {
readonly prompt: string;
readonly model: string | undefined;
readonly recentExchanges?: readonly CursorCliRecapExchange[];
/** The immediately preceding assistant turn came from another provider. */
readonly contextRecapRequested?: boolean;
/** A replacement account must never receive transcript context from the failed account. */
readonly contextRecapSuppressed?: boolean;
/** Cross-account failover promises a fresh chat even when this account has an older binding. */
readonly forceFreshChat?: boolean;
};

export type CursorCliSessionPolicy = {
Expand Down Expand Up @@ -141,6 +147,12 @@ function composePrompt(recap: string | undefined, prompt: string): string {
return recap === undefined ? prompt : `${recap}\n\n${prompt}`;
}

function priorRecapExchanges(input: CursorCliSessionTurnInput): readonly CursorCliRecapExchange[] | undefined {
return input.recentExchanges?.at(-1)?.role === "user" && input.recentExchanges.at(-1)?.text === input.prompt
? input.recentExchanges.slice(0, -1)
: input.recentExchanges;
}

function shrinkToCeiling(
recap: string | undefined,
rawPrompt: string,
Expand Down Expand Up @@ -267,12 +279,20 @@ export class CursorCliSessionRouter {
const bound = this.records.get(context.senpiSessionId);
// Chats live inside each account's HOME, so a record bound to another
// account can never be resumed here.
const resumable = bound !== undefined && bound.accountName === context.accountName && resumeEnabled;
const resumable =
input.forceFreshChat !== true &&
bound !== undefined &&
bound.accountName === context.accountName &&
resumeEnabled;
const resumeChatId = resumable && bound !== undefined ? bound.chatId : undefined;
const modelSwitch = resumable && bound !== undefined && bound.lastModel !== input.model;
const sameAccount = bound === undefined || bound.accountName === context.accountName;
const needsRecap =
input.contextRecapSuppressed !== true &&
((sameAccount && (input.contextRecapRequested === true || !resumeEnabled)) || modelSwitch);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const recap =
modelSwitch && policy.contextRecapOnModelSwitch !== false
? buildCursorCliContextRecap(input.model, input.recentExchanges, policy.maxRecapBytes)
needsRecap && policy.contextRecapOnModelSwitch !== false
? buildCursorCliContextRecap(input.model, priorRecapExchanges(input), policy.maxRecapBytes)
: undefined;
const shrunk = shrinkToCeiling(recap, input.prompt, ceilingBytes);
return {
Expand Down Expand Up @@ -348,7 +368,8 @@ export class CursorCliSessionRouter {
const reason: CursorCliSessionRestartReason =
classification.kind === "context_overflow" ? "context_overflow" : "resume_failed";
const recap =
plan.contextRecap ?? buildCursorCliContextRecap(undefined, input.recentExchanges, options.maxRecapBytes);
plan.contextRecap ??
buildCursorCliContextRecap(undefined, priorRecapExchanges(input), options.maxRecapBytes);
const shrunk = shrinkToCeiling(recap, input.prompt, ceilingBytes);
yield restartNotice(previousChatId, reason, recap !== undefined && !shrunk.recapDropped);
attempt = { prompt: shrunk.prompt, resumeChatId: undefined };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@ function recapExchanges(context: Context): CursorCliRecapExchange[] {
return exchanges.slice(-RECENT_EXCHANGE_LIMIT);
}

function enteringFromAnotherProvider(context: Context): boolean {
for (let index = context.messages.length - 1; index >= 0; index -= 1) {
const message = context.messages[index];
if (message?.role === "assistant") return message.provider !== CURSOR_CLI_OAUTH_PROVIDER_ID;
}
return false;
}

function isFailoverNotice(event: unknown): event is CursorCliFailoverNotice {
return (event as { type?: unknown }).type === "cursor_account_changed";
}
Expand Down Expand Up @@ -465,6 +473,7 @@ export function streamCursorCliOauth(
prompt,
model: spawnModel,
recentExchanges: recapExchanges(context),
contextRecapRequested: enteringFromAnotherProvider(context),
};

const spawnAndStream = (
Expand Down Expand Up @@ -551,7 +560,7 @@ export function streamCursorCliOauth(
now: now(),
},
now: () => now(),
runAttempt: async (selected, _options: CursorCliAttemptOptions) => {
runAttempt: async (selected, attemptOptions: CursorCliAttemptOptions) => {
let slot = selected;
const current = await store.read(CURSOR_CLI_OAUTH_PROVIDER_ID);
if (current?.type === "oauth") {
Expand Down Expand Up @@ -603,7 +612,12 @@ export function streamCursorCliOauth(
contextRecapOnModelSwitch: settings.contextRecapOnModelSwitch,
now: () => now(),
},
turnInput,
{
...turnInput,
contextRecapRequested: turnInput.contextRecapRequested && !attemptOptions.freshChat,
contextRecapSuppressed: attemptOptions.freshChat,
forceFreshChat: attemptOptions.freshChat,
},
);
},
});
Expand Down
115 changes: 113 additions & 2 deletions packages/coding-agent/test/cursor-cli-oauth/session-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,78 @@ describe("Cursor CLI OAuth session router", () => {
});
});

it("reinjects prior senpi context when switching into Cursor from another provider", async () => {
const router = makeRouter();
const prompt = "What was the codename?";
const recentExchanges: CursorCliRecapExchange[] = [
{ role: "user", text: "The codename is ORCHID." },
{ role: "assistant", text: "Understood." },
{ role: "user", text: prompt },
];
const { attempts, runAttempt } = scriptedRunner([[initEvent("chat-1", "model-a"), assistantEvent("ORCHID")]]);

await collect(
router.runTurn(
{ ...alphaContext, runAttempt },
{ prompt, model: "model-a", recentExchanges, contextRecapRequested: true },
),
);

const freshPrompt = attempts[0]?.prompt ?? "";
expect(attempts[0]?.resumeChatId).toBeUndefined();
expect(freshPrompt.startsWith(CURSOR_CLI_CONTEXT_RECAP_BEGIN)).toBe(true);
expect(freshPrompt).toContain("The codename is ORCHID.");
expect(occurrences(freshPrompt, prompt)).toBe(1);
expect(freshPrompt.endsWith(prompt)).toBe(true);
});

it("honors the recap opt-out when switching into Cursor from another provider", async () => {
const router = makeRouter();
const { attempts, runAttempt } = scriptedRunner([[initEvent("chat-1", "model-a"), assistantEvent("answer")]]);

await collect(
router.runTurn(
{ ...alphaContext, runAttempt, contextRecapOnModelSwitch: false },
{
prompt: "current question",
model: "model-a",
contextRecapRequested: true,
recentExchanges: [
{ role: "user", text: "private earlier context" },
{ role: "user", text: "current question" },
],
},
),
);

expect(attempts).toEqual([{ prompt: "current question", resumeChatId: undefined }]);
});

it("reinjects intervening context when returning to an existing Cursor chat", async () => {
const router = makeRouter();
await primeChat(router, "chat-1", "model-a");
const { attempts, runAttempt } = scriptedRunner([[initEvent("chat-1", "model-a"), assistantEvent("ORCHID")]]);

await collect(
router.runTurn(
{ ...alphaContext, runAttempt },
{
prompt: "What changed?",
model: "model-a",
contextRecapRequested: true,
recentExchanges: [
{ role: "assistant", text: "The other provider established ORCHID." },
{ role: "user", text: "What changed?" },
],
},
),
);

expect(attempts[0]?.resumeChatId).toBe("chat-1");
expect(attempts[0]?.prompt).toContain("The other provider established ORCHID.");
expect(occurrences(attempts[0]?.prompt ?? "", "What changed?")).toBe(1);
});

it("resumes the recorded chat on same-model turns without a recap", async () => {
const router = makeRouter();
await primeChat(router, "chat-1", "model-a");
Expand Down Expand Up @@ -177,6 +249,7 @@ describe("Cursor CLI OAuth session router", () => {
expect(attempts[1]?.resumeChatId).toBeUndefined();
expect(attempts[1]?.prompt.startsWith(CURSOR_CLI_CONTEXT_RECAP_BEGIN)).toBe(true);
expect(attempts[1]?.prompt.endsWith("second turn")).toBe(true);
expect(occurrences(attempts[1]?.prompt ?? "", "second turn")).toBe(1);

const notice = restartNotice(events);
expect(notice).toMatchObject({
Expand Down Expand Up @@ -318,11 +391,21 @@ describe("Cursor CLI OAuth session router", () => {
await collect(
router.runTurn(
{ senpiSessionId: alphaContext.senpiSessionId, accountName: "bravo", runAttempt },
{ prompt: "new account", model: "model-a" },
{
prompt: "new account",
model: "model-a",
contextRecapRequested: true,
recentExchanges: [
{ role: "assistant", text: "PRIVATE-ALPHA-CONTEXT" },
{ role: "user", text: "new account" },
],
},
),
);

expect(attempts[0]?.resumeChatId).toBeUndefined();
expect(attempts[0]?.prompt).toBe("new account");
expect(attempts[0]?.prompt).not.toContain("PRIVATE-ALPHA-CONTEXT");
expect(router.getRecord(alphaContext.senpiSessionId)).toEqual({
accountName: "bravo",
chatId: "chat-8",
Expand All @@ -331,6 +414,33 @@ describe("Cursor CLI OAuth session router", () => {
});
});

it("keeps replacement-account attempts fresh and context-free with resume disabled", async () => {
const router = makeRouter();
await primeChat(router, "replacement-old-chat", "model-a");
const { attempts, runAttempt } = scriptedRunner([
[initEvent("replacement-new-chat", "model-a"), assistantEvent("ok")],
]);

await collect(
router.runTurn(
{ ...alphaContext, runAttempt, resumeMode: "off" },
{
prompt: "replacement prompt",
model: "model-a",
contextRecapRequested: true,
contextRecapSuppressed: true,
forceFreshChat: true,
Comment thread
hisjune marked this conversation as resolved.
recentExchanges: [
{ role: "assistant", text: "PRIVATE-FAILED-ACCOUNT-CONTEXT" },
{ role: "user", text: "replacement prompt" },
],
},
),
);

expect(attempts).toEqual([{ prompt: "replacement prompt", resumeChatId: undefined }]);
});

it("never resumes when resumeMode is off", async () => {
const router = makeRouter();
const first = scriptedRunner([[initEvent("chat-1", "model-a"), assistantEvent("one")]]);
Expand All @@ -349,7 +459,8 @@ describe("Cursor CLI OAuth session router", () => {
);

expect(second.attempts[0]?.resumeChatId).toBeUndefined();
expect(second.attempts[0]?.prompt).toBe("two");
expect(second.attempts[0]?.prompt).toContain("hist");
expect(second.attempts[0]?.prompt.endsWith("two")).toBe(true);
});

it("omits the recap on model switches when contextRecapOnModelSwitch is false", async () => {
Expand Down
Loading