Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3a06864
feat(providers): add a service-level model-list source
PeronGH Aug 6, 2026
2d5dc3a
feat(schema,engine): make the account's picked model set the only mod…
PeronGH Aug 6, 2026
0bae739
feat(agent-adapter): route a bare picked model id through opencode's …
PeronGH Aug 6, 2026
f7435f8
feat(workbench,i18n): pick an account's models from the service's own…
PeronGH Aug 6, 2026
c430eba
feat(workbench,ui): offer only the bound account's models, and refuse…
PeronGH Aug 6, 2026
c7f4e64
feat(schema,engine): record which account each session run resolved to
PeronGH Aug 6, 2026
1608dc4
feat(workbench,ui): pick models across every account an agent can bind
PeronGH Aug 6, 2026
8634d2c
feat(workbench): give the model pick a single owner in daemon config
PeronGH Aug 6, 2026
12fde61
refactor(ui,workbench,i18n): call the agent a Harness, not a provider
PeronGH Aug 6, 2026
b36c706
refactor(engine): give every session relaunch one resolve and one run…
PeronGH Aug 7, 2026
e4e149d
feat(schema,engine): switch a live thread to another account by relau…
PeronGH Aug 7, 2026
d589b93
feat(workbench,ui,i18n): let a live thread pick any account's model
PeronGH Aug 7, 2026
c8c91ca
docs(agent-adapter,schema): record that a cross-account switch relaun…
PeronGH Aug 7, 2026
9ceb386
fix(webview): seed the new-chat e2e with the current defaults key and…
PeronGH Aug 7, 2026
47c820e
feat(schema,engine): let a thread keep its own model and account acro…
PeronGH Aug 7, 2026
ab45738
feat(workbench,ui,i18n): enable accounts per agent, and make the defa…
PeronGH Aug 7, 2026
00b75fe
docs(schema,engine,agent-adapter): record enabled accounts vs the age…
PeronGH Aug 7, 2026
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Large rewrites are encouraged when they're the right fix — replace subsystems
- Keep table-definition / schema modules free of hooks and browser APIs so they stay importable anywhere.
- Directory names must describe responsibility, not incidental data. For example, a sidebar footer belongs with sidebar/workbench presentation, not in a `host/` folder just because it displays host state; a layout adapter belongs under layout, not a one-file pseudo-subsystem.
- Terminology: the product term **Thread** is the code/wire term **`session`** — the rename is UI/i18n-only. Never rename `session` in wire or code identifiers.
- Terminology: **"provider" means the account/service** (DeepSeek, OpenRouter) — never the agent. The agent is a **Harness**, so client-side UI text and identifiers use that (`selectableHarnesses`, `onHarnessChange`, `lastHarness`); `AgentKind` and every wire/daemon term stay as they are. The two meanings used to collide in adjacent UI — the composer's "provider" picker chose the *agent* while the Providers settings page meant accounts. `groupModelsByProvider` is the genuine exception: it groups by *model* provider.

## Tooling And Aliases

Expand Down
11 changes: 10 additions & 1 deletion apps/daemon/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ describe('loadConfig providers', () => {

const config = loadConfig(vault);

// `defaultModel` carries over as the persisted pick; without that it would be silently stripped.
expect(config.providers).toEqual({
'claude-code': { enabled: true, defaultModel: 'sonnet' },
'claude-code': { enabled: true, model: 'sonnet' },
});
expect(errorSpy).toHaveBeenCalled();
});
Expand Down Expand Up @@ -236,6 +237,14 @@ describe('loadConfig accounts', () => {
expect(errorSpy).toHaveBeenCalled();
});

it("carries a pre-selection account's single model over as its picked set", () => {
writeAccountsConfig([{ ...validAccount, model: 'deepseek-v4-pro' }]);

expect(loadConfig(vault).accounts).toEqual([
{ ...validAccount, models: [{ id: 'deepseek-v4-pro' }] },
]);
});

it('drops an account whose stored secret is gone, rather than half-loading it', () => {
const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop);
// The post-migration on-disk shape: an api-key credential with no key. With an empty vault the
Expand Down
23 changes: 21 additions & 2 deletions apps/daemon/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ function parseAccounts(store: SecretStore, raw: unknown): Parsed<Accounts> {
// secret that is gone fails the schema and lands in the same drop-and-log path as a malformed one.
const attached = withAccountSecret(store, value);
migrated ||= attached.migrated;
const account = AccountSchema.safeParse(attached.value);
const account = AccountSchema.safeParse(withPickedModels(attached.value));
if (!account.success) {
logger.warn({ operation: 'config.load' }, 'Dropping invalid account config');
continue;
Expand All @@ -221,6 +221,25 @@ function parseAccounts(store: SecretStore, raw: unknown): Parsed<Accounts> {
return { value: accounts, migrated };
}

/** Pre-selection configs stored one free-text model per account; carry it over as the picked set,
* or zod strips the unknown key and the user silently loses their model. Idempotent. */
function withPickedModels(value: unknown): unknown {
if (typeof value !== 'object' || value === null) return value;
const { model, ...rest } = value as { model?: unknown; models?: unknown };
if (typeof model !== 'string' || model === '' || rest.models !== undefined) return rest;
return { ...rest, models: [{ id: model }] };
}

/** Same carry-over for the per-agent default, which is now the persisted pick. */
function withPickedModel(value: unknown): unknown {
if (typeof value !== 'object' || value === null) return value;
const { defaultModel, ...rest } = value as { defaultModel?: unknown; model?: unknown };
if (typeof defaultModel !== 'string' || defaultModel === '' || rest.model !== undefined) {
return rest;
}
return { ...rest, model: defaultModel };
}

/**
* Parse element by element like {@link parseAccounts}: one invalid server is dropped and logged,
* never blanking the rest.
Expand Down Expand Up @@ -270,7 +289,7 @@ function parseProviders(store: SecretStore, raw: unknown): Parsed<ProvidersConfi
}
const attached = withProviderSecret(store, kind.data, value);
migrated ||= attached.migrated;
const config = ProviderConfigSchema.safeParse(attached.value);
const config = ProviderConfigSchema.safeParse(withPickedModel(attached.value));
if (!config.success) {
logger.warn({ agentKind: key, operation: 'config.load' }, 'Dropping invalid provider config');
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function HistoryImportTab({ kind }: { kind: AgentKind }): React.ReactNode
<>
<DesktopChromePortal segment="main" position="left" className="gap-2 px-2">
<span className="min-w-0 truncate font-semibold text-sm">
{t('panelTitle', { provider: AGENT_LABELS[kind] })}
{t('panelTitle', { harness: AGENT_LABELS[kind] })}
</span>
{surface.count > 0 && (
<span className="shrink-0 text-muted-foreground text-xs">
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function DesktopShell({
attachmentSupport,
agentCatalogs,
newSessionDefaultModels,
newSessionPreferredModels,
accountModels,
newSessionPreferredEfforts,
newSessionPreferredBranches,
NewSessionBranchPickerComponent,
Expand Down Expand Up @@ -429,7 +429,7 @@ export function DesktopShell({
attachmentSupport={attachmentSupport}
agentCatalogs={agentCatalogs}
defaultModels={newSessionDefaultModels}
preferredModels={newSessionPreferredModels}
accountModels={accountModels}
preferredEfforts={newSessionPreferredEfforts}
preferredBranches={newSessionPreferredBranches}
NewSessionBranchPickerComponent={NewSessionBranchPickerComponent}
Expand All @@ -453,6 +453,8 @@ export function DesktopShell({
composer={conversationComposer}
agentKind={active?.kind}
agentLabel={agentLabel}
accountModels={active ? accountModels?.[active.kind] : undefined}
accountId={active?.accountId}
attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])}
cwd={active?.cwd}
runtimeCues={runtimeCues}
Expand Down
21 changes: 14 additions & 7 deletions apps/webview/e2e/browser-smoke.e2e.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { chromium } from 'playwright-core';
const webviewDir = fileURLToPath(new URL('..', import.meta.url));
const daemonDir = fileURLToPath(new URL('../../daemon', import.meta.url));
const viteCli = fileURLToPath(new URL('../../bin/vite.js', import.meta.resolve('vite')));
const newSessionDefaultsKey = 'linkcode.workbench.new-session-defaults:v7';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This key moved twice inside this PR (:v5:v6:v7), and this line is the second place a hand-copied copy drifted. The first was new-session-defaults-store.test.ts, which this PR fixed structurally — by importing the constant, with a comment saying the mismatch had turned a test vacuous. Here the same obligation is only a code comment, so the next bump silently breaks this e2e again and the sole signal is a red CI job.

Technical details
# Pin the e2e's storage key to `NEW_SESSION_DEFAULTS_STORAGE_KEY` at compile time

## Affected sites
- `apps/webview/e2e/browser-smoke.e2e.mts:18``newSessionDefaultsKey` is a hand-copied string literal; nothing fails if it drifts from the store.
- `apps/webview/e2e/browser-smoke.e2e.mts:76``lastHarness` is likewise hand-copied. `PersistedNewSessionDefaultsSchema` is `.partial()`, so a stale field name is dropped by `safeParse` rather than rejected, which is exactly why this drift is silent.
- `packages/client/workbench/src/surface/new-session-defaults-store.ts:19` — the constant exists and is exported for precisely this reason, but is not re-exported from the package barrel (`src/index.ts` covers `./surface/*` selectively and omits this module).

## Required outcome
- A future bump of the storage key or a rename of a persisted field fails `pnpm typecheck` rather than only the webview e2e job. `apps/webview/e2e/tsconfig.json` is already a root `tsconfig.json` reference, so the e2e file is covered by the solution build.

## Suggested approach
A runtime import is not viable — the e2e runs under plain `node` type-stripping and the workbench barrel pulls in React, zustand and CSS. A type-only pin costs nothing at runtime:

```ts
const newSessionDefaultsKey: typeof import('@linkcode/workbench').NEW_SESSION_DEFAULTS_STORAGE_KEY =
  'linkcode.workbench.new-session-defaults:v7';
```

`export const NEW_SESSION_DEFAULTS_STORAGE_KEY = '…'` already infers the string literal type, so the assignment stops compiling the moment the key changes. This needs one line added to `packages/client/workbench/src/index.ts` to put the module on the barrel (the package's `AGENTS.md` forbids consumers deep-importing other paths).

## Open questions for the human
- Worth doing the same for the persisted field names, or is the code comment enough there? A `keyof` pin would need `PersistedNewSessionDefaults` exported too, which is more surface than the key alone.

const mockThreadTitle = 'Wire the workbench to the daemon';
const mockChatThreadTitle = 'Prototype without git';
const longThreadTitle = 'Long thread · navigation testbed';
Expand Down Expand Up @@ -69,12 +70,11 @@ async function sendPrompt(page: Page, prompt: string, appErrors: string[]): Prom
}

async function verifyNewChatIsolation(page: Page, appErrors: string[]): Promise<void> {
await page.evaluate(() => {
localStorage.setItem(
'linkcode.workbench.new-session-defaults:v5',
JSON.stringify({ state: { lastProvider: 'pi' }, version: 0 }),
);
});
// Must track NEW_SESSION_DEFAULTS_STORAGE_KEY and its schema: a stale blob is discarded silently,
// the new chat falls back to claude-code, and its `missing` mock runtime blocks Send forever.
await page.evaluate((key) => {
localStorage.setItem(key, JSON.stringify({ state: { lastHarness: 'pi' }, version: 0 }));
}, newSessionDefaultsKey);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('[data-thread-title]', { hasText: mockChatThreadTitle }).waitFor();
await page.locator('[data-thread-title]', { hasText: mockChatThreadTitle }).click();
Expand All @@ -101,7 +101,14 @@ async function verifyNewChatIsolation(page: Page, appErrors: string[]): Promise<
});
});

await page.getByRole('button', { name: 'Send' }).click();
const send = page.getByRole('button', { name: 'Send' });
if (await send.isDisabled()) {
throw new Error(
`New chat cannot send: the ${newSessionDefaultsKey} seed did not resolve a sendable harness. ` +
'Check the storage key version and the persisted field names against new-session-defaults-store.ts.',
);
}
await send.click();
await page.getByText(`You said: ${prompt}`, { exact: false }).waitFor({ timeout: 15000 });
const titles = await page.evaluate(() => {
const finish = Reflect.get(window, '__newChatIsolationProbe') as
Expand Down
14 changes: 8 additions & 6 deletions packages/client/core/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
Expand Down Expand Up @@ -793,8 +792,8 @@ export class LinkCodeClient {
return this.control.setSubscriptionMode(mode);
}

setModel(sessionId: SessionId, model: string): Promise<RequestAck> {
return this.control.setModel(sessionId, model);
setModel(sessionId: SessionId, model: string, accountId?: string): Promise<RequestAck> {
return this.control.setModel(sessionId, model, accountId);
}

setEffort(sessionId: SessionId, effort: EffortLevel): Promise<RequestAck> {
Expand Down Expand Up @@ -841,9 +840,12 @@ export class LinkCodeClient {
return this.control.getAccounts();
}

/** Model list an endpoint serves, read daemon-side with a not-yet-saved secret. */
probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise<AccountModel[]> {
return this.control.probeAccountModels(endpoint, secret);
/** Models a service serves, read daemon-side with an unsaved secret or a saved account's own. */
probeAccountModels(
service: string,
credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string },
): Promise<AccountModel[]> {
return this.control.probeAccountModels(service, credential);
}

/** Masked custom MCP servers (env/header keys only — the daemon never returns values). */
Expand Down
28 changes: 19 additions & 9 deletions packages/client/core/src/client/control-channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
Expand Down Expand Up @@ -267,9 +266,15 @@ export class ControlChannel {
}));
}

/** Switch the session's model, going forward. Rejects if the adapter can't rebind a live session. */
setModel(sessionId: SessionId, model: string): Promise<RequestAck> {
return this.send(sessionId, { type: 'set-model', model });
/** Switch the session's model, going forward. Rejects if the adapter can't rebind a live session.
* `accountId` names the account the model came from: picking one the session isn't running on
* restarts it on that account and resumes the transcript. */
setModel(sessionId: SessionId, model: string, accountId?: string): Promise<RequestAck> {
return this.send(sessionId, {
type: 'set-model',
model,
...(accountId !== undefined && { accountId }),
});
}

/** Switch the session's reasoning-effort level, going forward. Same acceptance rule as setModel. */
Expand Down Expand Up @@ -579,14 +584,19 @@ export class ControlChannel {
}));
}

/** Ask the daemon what an endpoint serves, using a not-yet-saved secret: the account forms offer
* the answer as the model picker. The daemon must do it — the renderer's CSP blocks the fetch. */
probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise<AccountModel[]> {
/** Ask the daemon which models a service serves, so the account forms can offer a real list to
* pick from. The daemon must do it — the renderer's CSP blocks the fetch, and it resolves the list
* URL from the service catalog itself. Pass a secret the add form has not saved yet, or the id of
* a saved account so its stored secret never leaves the daemon. */
probeAccountModels(
service: string,
credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string },
): Promise<AccountModel[]> {
return this.sendCorrelated('accountModels', (clientReqId) => ({
kind: 'config.probe-models',
clientReqId,
endpoint,
secret,
service,
credential,
}));
}

Expand Down
13 changes: 6 additions & 7 deletions packages/client/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import type {
import { LinkCodeClient } from '@linkcode/client-core';
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
Expand Down Expand Up @@ -243,8 +242,8 @@ export class LinkCodeSdkClient {
return toResult(this.raw.cancel(sessionId));
}

setModel(sessionId: SessionId, model: string): RequestResult<{ ok: true }> {
return toResult(this.raw.setModel(sessionId, model));
setModel(sessionId: SessionId, model: string, accountId?: string): RequestResult<{ ok: true }> {
return toResult(this.raw.setModel(sessionId, model, accountId));
}

setEffort(sessionId: SessionId, effort: EffortLevel): RequestResult<{ ok: true }> {
Expand Down Expand Up @@ -291,12 +290,12 @@ export class LinkCodeSdkClient {
return toResult(this.raw.setAccounts(accounts));
}

/** Enumerate what an endpoint serves, using a secret that is not saved yet. */
/** Enumerate the models a service serves, with an unsaved secret or a saved account's own. */
probeAccountModels(
endpoint: AccountEndpoint,
secret: AccountSecret,
service: string,
credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string },
): RequestResult<AccountModel[]> {
return toResult(this.raw.probeAccountModels(endpoint, secret));
return toResult(this.raw.probeAccountModels(service, credential));
}

/** Masked custom MCP servers (data plane) — env/header keys only, never a secret value. */
Expand Down
12 changes: 7 additions & 5 deletions packages/client/sdk/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
} from '@linkcode/client-core';
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
Expand Down Expand Up @@ -222,9 +221,9 @@ export function cancelTurn(
}

export function setModel(
options: Options<{ sessionId: SessionId; model: string }>,
options: Options<{ sessionId: SessionId; model: string; accountId?: string }>,
): RequestResult<{ ok: true }> {
return resolveClient(options).setModel(options.sessionId, options.model);
return resolveClient(options).setModel(options.sessionId, options.model, options.accountId);
}

export function setEffort(
Expand Down Expand Up @@ -278,9 +277,12 @@ export function setAccounts(options: Options<{ accounts: Accounts }>): RequestRe
}

export function probeAccountModels(
options: Options<{ endpoint: AccountEndpoint; secret: AccountSecret }>,
options: Options<{
service: string;
credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string };
}>,
): RequestResult<AccountModel[]> {
return resolveClient(options).probeAccountModels(options.endpoint, options.secret);
return resolveClient(options).probeAccountModels(options.service, options.credential);
}

/** Masked custom MCP servers — env/header keys only, never a secret value. */
Expand Down
Loading