Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,17 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri
await scroller.waitFor();
await loadPromptRailBeyondVirtualWindow(page);
await scrollTranscriptTo(page, 'bottom');
// The bottom jump lands on a spacer layout sized by estimated turn heights.
// #3121 established this jump races the virtualizer's scroll-anchor restore:
// the window can settle against the estimates before the tail turn mounts,
// and with no further scroll event it stays settled short of the tail. The
// second-half jump below already re-asserts its intent for the same reason;
// re-assert the bottom scroll once the first paint lands, then dispatch the
// scroll event the window recompute listens for. A real regression (the
// tail turn never mounting at the bottom) still fails the assertion.
await waitForPaintedFrames(page);
await scrollTranscriptTo(page, 'bottom');
await notifyTranscriptScrolled(page);
await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1);
const retainedTurnId = await page.evaluate(() => {
const turns = document.querySelectorAll<HTMLElement>('[data-virtual-turn-id]');
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ test('WorkHub target metadata does not overlap the submitted Session result', as
);
await workHubComposer.fill(`继续${sessionName},补充重复投递测试点。`);
await workHubComposer.press('Enter');
await expect(page.locator('.workhub-result')).toBeVisible();
// The result panel waits on the same model-roundtrip budget the spec's
// first submit gets (20s above), plus the WorkHub routing and projection
// refresh on top of it — the default 10s occasionally loses that race on
// CI Xvfb runners (run 33035109906). Same class of wait as the 20s asserts
// in workhub-reconstruction.spec.ts.
await expect(page.locator('.workhub-result')).toBeVisible({ timeout: 20_000 });

const geometry = await page.evaluate(() => {
const button = document.querySelector<HTMLElement>('.workhub-submitted > button')!;
Expand Down
23 changes: 17 additions & 6 deletions apps/desktop/src/main/__tests__/relay-profile-draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,26 @@ test('the draft seed sanitizes a hand-edited saved table', () => {
// the same canonical view — a malformed local file degrades to no
// declaration, not to UI state TypeScript does not model.
assert.deepEqual(
relayProfileDraftSeed({
reasoner: { thinkingLevels: 'low' as never, contextWindow: '128000' as never },
ghost: { thinkingLevels: ['off', 'low'] },
visual: { vision: true },
}),
relayProfileDraftSeed(
{
reasoner: { thinkingLevels: 'low' as never, contextWindow: '128000' as never },
ghost: { thinkingLevels: ['off', 'low'] },
visual: { vision: true },
},
'openai-compatible',
),
{
ghost: { thinkingLevels: ['low'] },
visual: { vision: true },
},
);
assert.deepEqual(relayProfileDraftSeed(undefined), {});
assert.deepEqual(relayProfileDraftSeed(undefined, 'openai-compatible'), {});
// An Anthropic-protocol relay keeps `off`: its wire has a true disable.
assert.deepEqual(
relayProfileDraftSeed(
{ ghost: { thinkingLevels: ['off', 'low'] } },
'anthropic-compatible',
),
{ ghost: { thinkingLevels: ['off', 'low'] } },
);
});
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
bulkThinkingLevelStates,
relayProfileWithThinkingLevels,
} from '../../renderer/settings/relay-thinking-bulk.js';
import { DECLARABLE_RELAY_THINKING_LEVELS } from '@maka/core/model-thinking';
import { declarableRelayThinkingLevels } from '@maka/core/model-thinking';
import type { RelayModelProfile } from '@maka/core/model-thinking';

const MODELS = ['alpha', 'beta', 'gamma'];
Expand Down Expand Up @@ -63,7 +63,7 @@ test('a repeated model id is one model, not two', () => {
test('an empty selection ticks nothing rather than reading as fully covered', () => {
// 0 === 0 is the trap: `declaredCount === total` is true of an empty
// selection, which would present every level as declared everywhere.
for (const state of bulkThinkingLevelStates([], {}, DECLARABLE_RELAY_THINKING_LEVELS)) {
for (const state of bulkThinkingLevelStates([], {}, declarableRelayThinkingLevels('openai-compatible'))) {
assert.equal(state.checked, false);
assert.equal(state.total, 0);
}
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/main/connections-ipc-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,14 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn
? undefined
: normalizeConnectionApiKeyForIpc(input.apiKey, 'apiKey');
const slug = normalizeConnectionSlugForIpc(input.slug, 'connection slug');
// providerType is validated against PROVIDER_DEFAULTS above, so the
// declaration vocabulary can key off it (off is legal only where the
// provider has a true disable wire).
const providerType = input.providerType;
const relayModelProfiles =
input.relayModelProfiles === undefined
? undefined
: normalizeRelayModelProfiles(input.relayModelProfiles);
: normalizeRelayModelProfiles(input.relayModelProfiles, providerType);
const requestHeaders =
input.requestHeaders === undefined ? undefined : normalizeRequestHeaders(input.requestHeaders);
const requestBodyOverlay =
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/main/runtime-host-connections-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,11 @@ export function registerRuntimeHostConnectionsIpc(
// entirely, which the store reads as "leave the table alone".
...(patch.relayModelProfiles === undefined
? {}
: { relayModelProfiles: normalizeRelayModelProfiles(patch.relayModelProfiles) ?? null }),
: {
relayModelProfiles:
normalizeRelayModelProfiles(patch.relayModelProfiles, current.providerType) ??
null,
}),
...(patch.requestBodyOverlay === undefined
? {}
: { requestBodyOverlay: patch.requestBodyOverlay }),
Expand Down
56 changes: 29 additions & 27 deletions apps/desktop/src/renderer/settings/provider-connection-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
import { isRelayProviderType, PROVIDER_DEFAULTS } from '@maka/core/llm-connections';
import { hasModelMetadata } from '@maka/core/model-metadata';
import {
DECLARABLE_RELAY_THINKING_LEVELS,
declarableRelayThinkingLevels,
THINKING_LEVELS,
supportsRelayFastServiceTier,
type RelayModelProfile,
Expand Down Expand Up @@ -201,12 +201,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
refreshAfterRelogin,
} = useConnectionDetail(props);
// A model gets capability switches when Maka cannot describe it otherwise.
// On a custom OpenAI relay that is every model: the id is whatever the
// operator chose, so even one that collides with a known name may front
// something else entirely. Elsewhere it is the models the bundled metadata
// has never heard of — a model newer than this build, or one the user typed
// in on a provider whose key cannot call a model-list endpoint, which no
// refresh will ever describe (#1584).
// On a custom relay (OpenAI chat/responses or Anthropic protocol) that is
// every model: the id is whatever the operator chose, so even one that
// collides with a known name may front something else entirely. Elsewhere
// it is the models the bundled metadata has never heard of — a model newer
// than this build, or one the user typed in on a provider whose key cannot
// call a model-list endpoint, which no refresh will ever describe (#1584).
//
// A model that already carries a declaration always keeps its row, or a
// stale declaration would be uneditable and unclearable.
Expand Down Expand Up @@ -707,15 +707,16 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
hasChevron
menuWidth={240}
>
{/* The declarable vocabulary, which is the whole of what
a draft can hold: the seed sanitizes through
`normalizeRelayModelProfiles`, so `off` — a disable
wire no generic relay is presumed to speak — cannot
reach a row here either. */}
{/* The per-provider declarable vocabulary, which is the
whole of what a draft can hold: the seed sanitizes
through `normalizeRelayModelProfiles` with the same
provider, so `off` — legal only where the provider
has a true disable wire — cannot reach an OpenAI
relay row here either. */}
{bulkThinkingLevelStates(
capabilityModelIds,
relayProfileDraft,
DECLARABLE_RELAY_THINKING_LEVELS,
declarableRelayThinkingLevels(connection.providerType),
).map((state) => (
<DropdownMenuCheckboxItem
key={state.level}
Expand Down Expand Up @@ -765,16 +766,15 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
connection.providerType,
modelId,
);
// The menu offers the five declarable levels PLUS anything
// the stored table already claims — a level saved while it
// was still declarable (or hand-written into the document)
// must stay visible and un-checkable, never an invisible
// selection the trigger counts but the menu cannot show.
// The menu offers the provider's declarable levels PLUS
// anything the stored table already claims — a level saved
// while it was still declarable (or hand-written into the
// document) must stay visible and un-checkable, never an
// invisible selection the trigger counts but the menu
// cannot show.
const declarableLevels = declarableRelayThinkingLevels(connection.providerType);
const menuLevels: readonly ThinkingLevel[] = THINKING_LEVELS.filter(
(level) =>
(DECLARABLE_RELAY_THINKING_LEVELS as readonly ThinkingLevel[]).includes(
level,
) || draftLevels.includes(level),
(level) => declarableLevels.includes(level) || draftLevels.includes(level),
);
return (
<VStack key={modelId} gap={3}>
Expand All @@ -784,11 +784,13 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
left, one compact control on the right (the 模型功能
row language). A CheckboxList wall was the reason this
section looked like a form from a different app. */}
{/* Relay-only, like 快速模式 below: a declared level encodes
into `reasoning_effort`, a wire field only the
OpenAI-compatible relays accept. The catalog codec
refuses to persist one elsewhere, so offering the
control would promise an edit that cannot be saved. */}
{/* Relay-only, like 快速模式 below: a declared level
encodes into the relay's thinking wire —
`reasoning_effort` on the OpenAI relays, the
Anthropic `thinking`/`effort` controls on the
Anthropic-protocol relay. The catalog codec refuses
to persist one elsewhere, so offering the control
would promise an edit that cannot be saved. */}
{isRelay && (
<CapabilityRow label={copy.thinkingEffort} description={copy.thinkingEffortHelp}>
{/* DropdownMenu, not MultiSelector: levels have a
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/settings/relay-profile-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type RelayModelProfile,
type RelayModelProfiles,
} from '@maka/core/model-thinking';
import type { ProviderType } from '@maka/core/llm-connections';

/**
* Reseed decision for the relay-profile editor's local draft. The editor is
Expand Down Expand Up @@ -66,6 +67,7 @@ export function relayProfileDraftReseedPlan(
*/
export function relayProfileDraftSeed(
profiles: RelayModelProfiles | undefined,
providerType: ProviderType,
): Record<string, RelayModelProfile> {
return normalizeRelayModelProfiles(profiles) ?? {};
return normalizeRelayModelProfiles(profiles, providerType) ?? {};
}
13 changes: 9 additions & 4 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,16 +384,17 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
}
}

// Per-model profile declarations for custom OpenAI relays, edited as a
// LOCAL DRAFT and committed by an explicit 保存 button — never keystroke by
// Per-model profile declarations for custom relays (OpenAI chat/responses
// or Anthropic protocol), edited as a LOCAL DRAFT and committed by an
// explicit 保存 button — never keystroke by
// keystroke. A draft is `Record<modelId, RelayModelProfile>` seeded from the
// saved table; entries a user empties fully drop out of the map, and the
// ≥1-enabled-model invariant is honored live: the draft is pruned against
// `enabledModelIds` on every read, so disabling a model in the section above
// removes its unsaved declaration too (the store prunes the SAVED table the
// same way on write).
const [relayProfileDrafts, setRelayProfileDrafts] = useState<Record<string, RelayModelProfile>>(
() => relayProfileDraftSeed(connection.relayModelProfiles),
() => relayProfileDraftSeed(connection.relayModelProfiles, connection.providerType),
);
const [relayProfilesDirty, setRelayProfilesDirty] = useState(false);
// The dirty flag names a slug: the same instance continues across the
Expand Down Expand Up @@ -479,9 +480,11 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
// path applies, so a reordered-but-equal draft doesn't keep 保存 lit.
const savedRelayProfiles = normalizeRelayModelProfiles(
pruneRelayModelProfiles(connection.relayModelProfiles, enabledModelIds) ?? {},
connection.providerType,
);
const draftedRelayProfiles = normalizeRelayModelProfiles(
pruneRelayModelProfiles(relayProfileDrafts, enabledModelIds) ?? {},
connection.providerType,
);
const hasRelayProfileChanges = !relayProfilesEqual(draftedRelayProfiles, savedRelayProfiles);

Expand All @@ -496,7 +499,9 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
);
relayProfileDraftOwnerRef.current = connection.slug;
if (plan.reseed) {
setRelayProfileDrafts(relayProfileDraftSeed(connection.relayModelProfiles));
setRelayProfileDrafts(
relayProfileDraftSeed(connection.relayModelProfiles, connection.providerType),
);
}
if (plan.clearDirty) {
setRelayProfilesDirty(false);
Expand Down
85 changes: 79 additions & 6 deletions packages/core/src/__tests__/model-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
type ConnectionThinkingContext,
declarableRelayThinkingLevels,
normalizeRelayModelProfiles,
relayModelProfile,
resolveThinkingLevel,
Expand All @@ -32,18 +33,89 @@ import {
} from '../model-thinking.js';
import { isRelayProviderType } from '../llm-connections.js';

test('declarable relay levels are every intensity tier but off', () => {
// `off` is a disable-wire encoding (reasoning_effort 'none'), not an
// intensity tier — a hybrid UI/data contract keeps it out of declarations.
test('declarable relay levels are per provider: Anthropic relays may declare off', () => {
// OpenAI relays keep `off` out: it is a disable-wire encoding
// (reasoning_effort 'none') no generic relay is presumed to speak.
// Anthropic-protocol relays have a true disable wire
// (`thinking: { type: 'disabled' }`), so their declarations may carry it.
// They keep `minimal` out instead: their levels are emitted as
// `providerOptions.anthropic.effort`, which the SDK parses through a
// closed `low|medium|high|xhigh|max` enum before any request — `minimal`
// would throw locally.
assert.deepEqual(declarableRelayThinkingLevels('openai-compatible'), [
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
]);
assert.deepEqual(declarableRelayThinkingLevels('openai-responses-compatible'), [
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
]);
assert.deepEqual(declarableRelayThinkingLevels('anthropic-compatible'), [
'off',
'low',
'medium',
'high',
'xhigh',
'max',
]);
});

test('normalize filters off per provider and is lenient without one', () => {
// Explicit provider: the openai vocabulary drops `off`...
assert.deepEqual(
normalizeRelayModelProfiles({ m: { thinkingLevels: ['off', 'low'] } }, 'openai-compatible'),
{ m: { thinkingLevels: ['low'] } },
);
assert.equal(
normalizeRelayModelProfiles({ m: { thinkingLevels: ['off'] } }, 'openai-compatible'),
undefined,
);
// ...while an anthropic-compatible declaration keeps it.
assert.deepEqual(
normalizeRelayModelProfiles({ m: { thinkingLevels: ['off', 'high'] } }, 'anthropic-compatible'),
{ m: { thinkingLevels: ['off', 'high'] } },
);
// `minimal` is the mirror image on the Anthropic wire: not an effort the
// SDK's closed enum accepts, so the sanitizer drops it there just as it
// drops `off` on the OpenAI wires.
assert.deepEqual(
normalizeRelayModelProfiles(
{ m: { thinkingLevels: ['minimal', 'high'] } },
'anthropic-compatible',
),
{ m: { thinkingLevels: ['high'] } },
);
assert.equal(
normalizeRelayModelProfiles({ m: { thinkingLevels: ['minimal'] } }, 'anthropic-compatible'),
undefined,
);
// Without a provider (host-wire decode fallback) the sanitizer is
// provider-blind: the canonical store's codec has already validated
// provider fit, so decode keeps the full vocabulary and only drops junk.
assert.deepEqual(normalizeRelayModelProfiles({ m: { thinkingLevels: ['off', 'low'] } }), {
m: { thinkingLevels: ['low'] },
m: { thinkingLevels: ['off', 'low'] },
});
assert.equal(normalizeRelayModelProfiles({ m: { thinkingLevels: ['off'] } }), undefined);
});

test('anthropic-compatible declared levels surface through the read seam', () => {
const declaredOff = {
providerType: 'openai-compatible',
relayModelProfiles: { m: { thinkingLevels: ['off', 'low'] } },
} as const;
assert.deepEqual([...thinkingVariantsForConnection(declaredOff, 'm')], ['low']);
const anthropicRelay = {
providerType: 'anthropic-compatible',
relayModelProfiles: { m: { thinkingLevels: ['off', 'high'] } },
} as const;
assert.deepEqual([...thinkingVariantsForConnection(anthropicRelay, 'm')], ['off', 'high']);
});

test('relay profiles preserve the fast service tier declaration', () => {
Expand Down Expand Up @@ -148,9 +220,10 @@ test('relayModelProfile honours a declaration on any provider', () => {
);
});

test('isRelayProviderType only accepts the two custom OpenAI relay providers', () => {
test('isRelayProviderType accepts the three custom relay providers', () => {
assert.equal(isRelayProviderType('openai-compatible'), true);
assert.equal(isRelayProviderType('openai-responses-compatible'), true);
assert.equal(isRelayProviderType('anthropic-compatible'), true);
assert.equal(isRelayProviderType('openai'), false);
assert.equal(isRelayProviderType('anthropic'), false);
});
Expand Down
Loading