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
6 changes: 6 additions & 0 deletions extensions/levelcode-ai/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,12 @@ async function runAgent(ctx) {
ctx.post({ type: 'agentError', message: 'You’ve hit the model’s context window (the conversation got too long). Start a New chat to reset it, switch to a larger-context model, or pin fewer files — then continue.', kind: 'context' });
reason = 'error';
}
else if (typeof ctx.isSessionExpired === 'function' && ctx.isSessionExpired(e)) {
// The gateway 401 that refreshAuth could not recover: the session is over. A sign-in card,
// not the adapter's raw message — the user needs a button, not a status code.
ctx.post({ type: 'agentError', message: ctx.sessionExpiredMessage || msg, code: 'session_expired' });
reason = 'error';
}
else { ctx.post({ type: 'agentError', message: msg, code }); reason = 'error'; }
} finally {
dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null });
Expand Down
416 changes: 382 additions & 34 deletions extensions/levelcode-ai/extension.js

Large diffs are not rendered by default.

41 changes: 38 additions & 3 deletions extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@
.upgradecard .ucbtn.primary:hover { filter: brightness(1.1); }
.upgradecard .ucbtn.ghost { background: transparent; border: 1px solid var(--border); color: var(--vscode-foreground); }
.upgradecard .ucbtn.ghost:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.14)); }
/* session expired — the upgrade card's shape, because it too ends in one button the user wants to press */
.sessioncard .uchead .ci { color: var(--accent); }
/* our-side outage notice — neutral, NO accent, NO CTA (it is not the user's account/usage) */
.noticecard { border: 1px solid var(--border); border-radius: 12px; margin: 8px 2px; padding: 13px 15px 14px; background: var(--field-bg); }
.noticecard .uchead { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
Expand Down Expand Up @@ -2733,6 +2735,32 @@
log.appendChild(card); scrollIfStuck();
card.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => vscode.postMessage({ type: 'accountUpgrade' }); });
}
// The cloud session is over (the refresh token expired — 30 days without use, or a sign-out
// elsewhere). This is the ONE failure the user can fix in a click, so it gets a button, not red
// text: the raw "API 401: Signature has expired" this replaces told them nothing about what to do,
// while the account popover beside it still said they were signed in.
function isSessionExpired(m){ return !!(m && m.code === 'session_expired'); }
let sessionCard = null;
function addSignInCard(m){
clearStatus(); finishAgentBubble(); closeGroup();
if (sessionCard && sessionCard.isConnected){ sessionCard.remove(); } // one card, however many paths find the expiry
const who = m && m.name ? 'Welcome back, ' + esc(m.name) + '.' : '';
const card = document.createElement('div'); card.className = 'upgradecard sessioncard';
card.innerHTML =
'<div class="uchead">' + codicon('shield') + '<span>Your session has expired</span></div>'
+ '<div class="ucbody">' + (who ? who + ' ' : '') + 'Sign in again to keep using LevelCode Cloud — your chat and files here are untouched.</div>'
+ '<div class="ucbtns"><button class="ucbtn primary" data-act="signin">Sign in</button>'
+ '<button class="ucbtn ghost" data-act="byok">Use my own key instead</button></div>';
log.appendChild(card); scrollIfStuck();
card.querySelector('[data-act="signin"]').onclick = () => vscode.postMessage({ type: 'accountSignIn' });
// Choosing your own key answers the expiry — the host stops asking — so the card goes too,
// rather than sitting in the transcript offering a sign-in nobody is waiting on.
card.querySelector('[data-act="byok"]').onclick = () => {
vscode.postMessage({ type: 'byokSettings' });
card.remove(); if (sessionCard === card){ sessionCard = null; }
};
sessionCard = card;
}
// Our-side outage / transient issue → a neutral "service" notice, NOT the red error and NOT the
// upgrade card (the gateway already sanitized the message; this just picks a calmer presentation).
function isServiceIssue(m){
Expand Down Expand Up @@ -4121,7 +4149,8 @@
else if (m.type === 'assistantError'){
pending = ''; flushAll = false; doneSignaled = false;
const cap = capReachedInfo(m.message);
if (cap){ current = null; addUpgradeCard(cap); }
if (isSessionExpired(m)){ current = null; addSignInCard(m); }
else if (cap){ current = null; addUpgradeCard(cap); }
else if (isServiceIssue(m)){ current = null; addServiceCard(m); }
else {
const html = '<span class="err">' + esc(m.message) + '</span>';
Expand All @@ -4130,6 +4159,7 @@
}
setStreaming(false);
}
else if (m.type === 'sessionExpired'){ addSignInCard(m); }
else if (m.type === 'activeFile'){ activeFileLabel = m.label; renderChips(); }
else if (m.type === 'contextFiles'){ ctxFiles = m.files || []; renderChips(); }
else if (m.type === 'autoContext'){ addAutoCtx(m.names); }
Expand Down Expand Up @@ -4167,9 +4197,14 @@
else if (m.type === 'compactStart'){ ctxCompact = 'busy'; renderCtxCard(); }
else if (m.type === 'compactResult'){ onCompactResult(m); }
else if (m.type === 'debug'){ addDebug(m); }
else if (m.type === 'account'){ renderAccount(m); if (m.open) openAccount(); }
else if (m.type === 'account'){
// The sign-in card stays only while the host says an expiry is still waiting (m.expired).
// Signing in answers it; so does anything that takes the session out of play — BYOK mode
// chosen in Settings, or no cloud host to sign in to. Requests run on the user's own key by
// then, so a card still asking them to sign in would be wrong.
if (!m.expired && sessionCard && sessionCard.isConnected){ sessionCard.remove(); sessionCard = null; } renderAccount(m); if (m.open) openAccount(); }
else if (m.type === 'fileIndex'){ setFileIndex(m.files || []); }
else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '<span class="err">' + esc(m.message) + '</span>'); } }
else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (isSessionExpired(m)){ addSignInCard(m); } else if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '<span class="err">' + esc(m.message) + '</span>'); } }
else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); }
else if (m.type === 'context'){ selLabel = m.label; renderChips(); }
else if (m.type === 'clearContext'){ selLabel = null; renderChips(); }
Expand Down
100 changes: 100 additions & 0 deletions extensions/levelcode-ai/providers/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*---------------------------------------------------------------------------------------------
* LevelCode — AI · LevelCode Cloud session state (pure)
*
* The editor keeps two credentials for the cloud: a short-lived access token (8 h) and a refresh
* token (30 days, rotated on use). Everything here is the arithmetic and classification around
* them — no VS Code, no IO — so it can be unit-tested (test/session.test.js) and so the host can
* answer "is this session still alive?" WITHOUT a network round-trip, by reading the access
* token's own `exp` claim. A JWT's payload is plain base64url JSON; reading it is not verifying it
* (the server does that), it is only asking the token when it says it dies.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

/** Refresh this far ahead of expiry, so a request issued right now cannot land after the deadline. */
const EXPIRY_MARGIN_MS = 5 * 60 * 1000;

/**
* How long the whole refresh exchange may take — connecting, the headers AND the body. The webview's
* `ready` waits on a refresh before it restores the chat, so this is how long a host that accepts the
* connection and then says nothing can hold that up. Running out of it is "this attempt failed",
* never "the session is over".
*/
const REFRESH_TIMEOUT_MS = 10 * 1000;

/** The sentence shown when the session is gone. Mirrors the server's own wording. */
const SESSION_EXPIRED_MESSAGE = 'Your LevelCode Cloud session has expired. Sign in again to continue.';

/**
* The `exp` claim of a JWT as epoch milliseconds, or null when the token is not a JWT, carries no
* `exp`, or is unreadable. Never throws: a malformed token is a reason to re-check with the server,
* not a reason to crash the editor.
* @param {string|null|undefined} token
* @returns {number|null}
*/
function jwtExpiresAt(token) {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) { return null; }
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(Buffer.from(b64 + '='.repeat((4 - b64.length % 4) % 4), 'base64').toString('utf8'));
const exp = Number(payload && payload.exp);
return Number.isFinite(exp) && exp > 0 ? exp * 1000 : null;
} catch { return null; }
}

/**
* Whether an access token should be refreshed before use: it expires within the margin, has
* already expired, or cannot be read at all (an unreadable token is treated as expired — the
* server would reject it anyway, and asking first is cheaper than a failed request).
* @param {string|null|undefined} token
* @param {number} [nowMs]
*/
function accessNeedsRefresh(token, nowMs = Date.now()) {
const exp = jwtExpiresAt(token);
return exp === null || exp - nowMs <= EXPIRY_MARGIN_MS;
}

/**
* Classify the outcome of POST /auth/refresh so the caller knows whether the SESSION is over, or
* only this attempt failed.
*
* 'ok' — a new access token was issued
* 'expired' — the server rejected the refresh token itself (401): the session is over, sign in again
* 'retry' — anything else: offline, 5xx, a malformed reply. Keep the tokens; nothing is known yet.
*
* Only an explicit 401 ends the session. Clearing credentials on a network blip would log a user
* out for closing their laptop on the train.
*
* And 'ok' means the reply can be STORED as it stands, not merely that it was a 2xx with something
* in the right field. The tokens go into SecretStorage, which takes strings: `{ access: {} }` would
* throw on the way in, and `{ access: 'a', refresh: {} }` would throw after the access token had
* already been replaced. Either is a malformed reply — "nothing is known yet" — and the credentials
* in hand are still the best ones available. The refresh token is optional (a server that does not
* rotate sends none); when one is present it has to be usable too.
* @param {{status?:number, body?:any}|null|undefined} res
* @returns {'ok'|'expired'|'retry'}
*/
function classifyRefresh(res) {
if (!res) { return 'retry'; }
if (res.status === 401) { return 'expired'; }
if (!(res.status >= 200 && res.status < 300) || !res.body) { return 'retry'; }
const isToken = (v) => typeof v === 'string' && v.length > 0;
if (!isToken(res.body.access || res.body.token)) { return 'retry'; } // the one the host will store
if (res.body.refresh && !isToken(res.body.refresh)) { return 'retry'; }
return 'ok';
}

/**
* True when a provider error means the cloud session is dead — a gateway 401 that a refresh could
* not recover. The adapter formats failures as `<label> API <status>: <detail>`; the server's own
* codes (`token_expired`, `refresh_expired`) are matched too so a structured body is not needed.
* @param {any} e
*/
function isSessionExpiredError(e) {
const status = e && e.status;
const msg = String((e && e.message) || e || '');
return status === 401 || /\bAPI 401\b|token_expired|refresh_expired|signature has expired/i.test(msg);
}

module.exports = { EXPIRY_MARGIN_MS, REFRESH_TIMEOUT_MS, SESSION_EXPIRED_MESSAGE, jwtExpiresAt, accessNeedsRefresh, classifyRefresh, isSessionExpiredError };
115 changes: 115 additions & 0 deletions extensions/levelcode-ai/test/session.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*---------------------------------------------------------------------------------------------
* Unit tests for providers/session.js (pure) — run: node test/session.test.js
* - jwtExpiresAt: reads `exp` off a JWT payload without verifying; never throws
* - accessNeedsRefresh: the 5-minute margin, expired, unreadable
* - classifyRefresh: ONLY a 401 ends the session; offline/5xx keep the tokens; a 2xx is a
* renewal only when what it carries can be stored
* - isSessionExpiredError: the shapes a dead session arrives in
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

const assert = require('assert');
const S = require('../providers/session');

let n = 0;
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }

/** An unsigned JWT with the given payload — the shape is all jwtExpiresAt reads. */
function jwt(payload) {
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64(payload) + '.sig';
}

const NOW = 1_800_000_000_000; // fixed "now" so the margin arithmetic is exact

test('jwtExpiresAt: reads exp (seconds) as epoch milliseconds', () => {
assert.strictEqual(S.jwtExpiresAt(jwt({ sub: 1, exp: 1_800_000_123 })), 1_800_000_123_000);
});
test('jwtExpiresAt: survives base64url padding edge cases (payload lengths mod 4)', () => {
for (const pad of ['', 'x', 'xy', 'xyz']) {
assert.strictEqual(S.jwtExpiresAt(jwt({ p: pad, exp: 7 })), 7000, 'pad=' + JSON.stringify(pad));
}
});
test('jwtExpiresAt: null for a non-JWT, a JWT without exp, garbage, and nothing — never throws', () => {
assert.strictEqual(S.jwtExpiresAt('opaque-token'), null);
assert.strictEqual(S.jwtExpiresAt(jwt({ sub: 1 })), null);
assert.strictEqual(S.jwtExpiresAt('a.!!!.c'), null);
assert.strictEqual(S.jwtExpiresAt(''), null);
assert.strictEqual(S.jwtExpiresAt(null), null);
assert.strictEqual(S.jwtExpiresAt(undefined), null);
});

test('accessNeedsRefresh: a token with hours left does not', () => {
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 3600 }), NOW), false);
});
test('accessNeedsRefresh: inside the 5-minute margin, at the margin, and already expired all do', () => {
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 299 }), NOW), true);
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 300 }), NOW), true);
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 - 1 }), NOW), true);
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 301 }), NOW), false);
});
test('accessNeedsRefresh: an unreadable token is treated as expired (ask the server, do not guess)', () => {
assert.strictEqual(S.accessNeedsRefresh('opaque', NOW), true);
assert.strictEqual(S.accessNeedsRefresh('', NOW), true);
});

test('classifyRefresh: 200 with an access token → ok', () => {
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: 'a' } }), 'ok');
assert.strictEqual(S.classifyRefresh({ status: 200, body: { token: 'a' } }), 'ok'); // legacy field name
});
test('classifyRefresh: ONLY an explicit 401 ends the session', () => {
assert.strictEqual(S.classifyRefresh({ status: 401, body: { error: { code: 'refresh_expired' } } }), 'expired');
assert.strictEqual(S.classifyRefresh({ status: 401, body: null }), 'expired');
});
test('classifyRefresh: offline, 5xx, 403, a 200 with no token, nothing at all → retry (tokens kept)', () => {
assert.strictEqual(S.classifyRefresh(null), 'retry');
assert.strictEqual(S.classifyRefresh({ status: 503, body: null }), 'retry');
assert.strictEqual(S.classifyRefresh({ status: 500, body: {} }), 'retry');
assert.strictEqual(S.classifyRefresh({ status: 403, body: {} }), 'retry');
assert.strictEqual(S.classifyRefresh({ status: 200, body: {} }), 'retry');
assert.strictEqual(S.classifyRefresh({ status: 200, body: null }), 'retry');
});

test('classifyRefresh: a 2xx whose access token is not a usable string is NOT a renewal', () => {
for (const access of [{}, [], ['a'], 123, true]) {
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access } }), 'retry', 'access=' + JSON.stringify(access));
}
assert.strictEqual(S.classifyRefresh({ status: 200, body: { token: {} } }), 'retry', 'the legacy field too');
// The host stores `access || token`, so that is the one judged: a broken `access` is not rescued by a
// good `token` beside it, and an EMPTY `access` falls through to `token` exactly as the store would.
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: {}, token: 'legacy' } }), 'retry');
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: '', token: 'legacy' } }), 'ok');
});
test('classifyRefresh: a refresh token, when one is sent, has to be a usable string too', () => {
for (const refresh of [{}, [], ['r'], 123, true]) {
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: 'a', refresh } }), 'retry', 'refresh=' + JSON.stringify(refresh));
}
});
test('classifyRefresh: no refresh token is still ok — a server that does not rotate sends none', () => {
for (const body of [{ access: 'a' }, { access: 'a', refresh: null }, { access: 'a', refresh: '' }, { access: 'a', refresh: 'r' }, { token: 'a', refresh: 'r' }]) {
assert.strictEqual(S.classifyRefresh({ status: 200, body }), 'ok', JSON.stringify(body));
}
});

test('isSessionExpiredError: the adapter\'s "<label> API 401: …" shape, with and without e.status', () => {
const e = new Error('LevelCode Cloud API 401: Your LevelCode Cloud session has expired. Sign in again to continue.');
assert.strictEqual(S.isSessionExpiredError(e), true);
// @ts-ignore
e.status = 401; assert.strictEqual(S.isSessionExpiredError(e), true);
assert.strictEqual(S.isSessionExpiredError({ status: 401 }), true);
});
test('isSessionExpiredError: the server codes and the old raw JWT text, as bare strings', () => {
assert.strictEqual(S.isSessionExpiredError('token_expired'), true);
assert.strictEqual(S.isSessionExpiredError('refresh_expired'), true);
assert.strictEqual(S.isSessionExpiredError('Signature has expired'), true);
});
test('isSessionExpiredError: a 402 cap hit, a 500, a 4010-byte message, nothing → not a dead session', () => {
assert.strictEqual(S.isSessionExpiredError(new Error('LevelCode Cloud API 402: {"error":{"code":"cap_reached"}}')), false);
assert.strictEqual(S.isSessionExpiredError(new Error('LevelCode Cloud API 500: upstream')), false);
assert.strictEqual(S.isSessionExpiredError(new Error('read 4010 bytes')), false);
assert.strictEqual(S.isSessionExpiredError(null), false);
assert.strictEqual(S.isSessionExpiredError(undefined), false);
});

console.log('\nsession: ' + n + ' tests passed.');
Loading
Loading