Skip to content
2 changes: 0 additions & 2 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ async function seedParentRemovalSessions(userDataDir: string): Promise<void> {
try {
const parent = await store.create({
cwd: path.join(userDataDir, 'project'),
backend: 'fake',
llmConnectionSlug: 'e2e',
model: 'claude-sonnet-4-5-20250929',
permissionMode: 'ask',
Expand All @@ -144,7 +143,6 @@ async function seedParentRemovalSessions(userDataDir: string): Promise<void> {
});
await store.createSubagent({
cwd: path.join(userDataDir, 'project'),
backend: 'fake',
llmConnectionSlug: 'e2e',
model: 'claude-sonnet-4-5-20250929',
permissionMode: 'execute',
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/main/__tests__/pending-session-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { pendingSessionView } from '../../renderer/pending-session-view.js';

test('the pending chat view names no connection or model it cannot know', () => {
const view = pendingSessionView({
sessionId: 'session-1',
name: '新任务',
permissionMode: 'ask',
});

// #3211: the placeholder used to claim `backend: 'fake'` / `model:
// 'fake-model'`, borrowing a retired backend to mean "not loaded".
assert.equal(view.backend, 'ai-sdk');
assert.equal(view.id, 'session-1');
assert.equal(view.permissionMode, 'ask');
assert.equal(view.connectionLocked, false);

// The empty pair is load-bearing: this fallback covers any active id whose
// summary has not arrived, so naming a plausible connection/model would let
// the model switcher drop a real switch onto that model as a no-op against a
// session that was never on it.
assert.equal(view.llmConnectionSlug, '');
assert.equal(view.model, '');
});

test('the pending chat view matches no offered model choice', () => {
const view = pendingSessionView({
sessionId: 'session-2',
name: '新任务',
permissionMode: 'execute',
});
const offered = [
{ connectionSlug: 'anthropic', model: 'claude-sonnet-4-5-20250929' },
{ connectionSlug: 'openai', model: 'gpt-5' },
];

assert.equal(
offered.some(
(choice) =>
choice.connectionSlug === view.llmConnectionSlug && choice.model === view.model,
),
false,
);
});
21 changes: 14 additions & 7 deletions apps/desktop/src/main/__tests__/stale-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { deriveStaleSessionIds } from '../../renderer/stale-sessions.js';

test('derives stale rows from each Session Host readiness projection', () => {
const sessions = [
session('local-ready'),
session('remote-missing'),
session('remote-rebind'),
session('legacy-fake', 'fake'),
{ id: 'local-ready' },
{ id: 'remote-missing' },
{ id: 'remote-rebind' },
{ id: 'legacy-fake' },
];

assert.deepEqual(
Expand All @@ -25,12 +25,19 @@ test('derives stale rows from each Session Host readiness projection', () => {
connectionSlug: 'replacement',
model: 'model',
},
// #3211: a retired backend reaches the rail as a projection reason like
// any other. The row is no longer identified by reading its `backend`.
'legacy-fake': {
kind: 'blocked',
reason: 'fake_backend',
connectionLocked: false,
},
},
})],
['remote-missing', 'legacy-fake'],
);
});

function session(id: string, backend = 'ai-sdk') {
return { id, backend };
}
test('a row whose readiness has not arrived yet is not called stale', () => {
assert.deepEqual([...deriveStaleSessionIds({ sessions: [{ id: 'unknown' }], sendOutcomes: {} })], []);
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,6 @@ export function registerRuntimeHostSessionCatalogIpc(
pendingCleanup.add(sessionId);
});
ipcMain.handle('sessions:create', async (_event, input?: CreateSessionRequestInput) => {
if (input?.backend !== undefined && input.backend !== 'ai-sdk') {
throw new Error('Unsupported Runtime Host Session backend');
}
const request = resolveCreateSessionRequest(input);
const workspace = await deps.resolveCreateProject({
...(input?.cwd === undefined ? {} : { cwd: input.cwd }),
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/src/main/search/thread-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
* UserMessage / AssistantMessage / ToolCallMessage / ToolResultMessage.
* Excluded: SystemNoteMessage / TokenUsageMessage / TurnStateMessage /
* PermissionDecisionMessage.
* - Excludes sessions with `backend === 'fake'` (e2e-fixture fixtures) and
* archived sessions (managed in Settings › 活动 › 已归档任务).
* - Excludes sessions with `backend === 'fake'` (retired local simulation,
* plus the e2e fixtures that still seed it) and archived sessions
* (managed in Settings › 活动 › 已归档任务).
* - Snippets are redacted via `@maka/core/redaction.redactSecrets()`.
* - `ToolResultMessage.content` is JSON-serialized for scan and capped to
* the first `TOOL_RESULT_SCAN_CAP_BYTES` bytes (worst-case bound).
Expand Down Expand Up @@ -160,8 +161,11 @@ export async function runThreadSearch(
const maxResults = limitResult.value;

const sessions = collapseSessionRevisions(await deps.listSessions())
// Exclude fake-backend sessions — e2e-fixture fixtures and
// similar dev-only state should not surface as real chat hits.
// Exclude fake-backend sessions. The rail still shows them (marked
// stale) because they are task records, but their transcripts are
// simulator output — returning fabricated text as a hit on the user's own
// history is worse than returning nothing. Retiring the backend (#3211)
// did not make that content real, so the filter stays.
//
// Archived tasks are excluded for the same reason the command palette
// skips them: archiving a task says it is out of the working set, and a
Expand Down
23 changes: 7 additions & 16 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import { useShellAppearance } from './use-shell-appearance';
import { useShellSearch } from './use-shell-search';
import { useSessionGoal } from './use-session-goal';
import { deriveStaleSessionIds } from './stale-sessions';
import { pendingSessionView } from './pending-session-view';
import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping';
import { deriveSessionRail } from './session-rail';
import { useAppShellTurnPresentation } from './app-shell-turn-view-model';
Expand Down Expand Up @@ -1270,26 +1271,16 @@ function AppShellContent({
};
}, [railParentSession]);

// Transient placeholder while the real SessionSummary loads, so the composer
// does not flash a value the session never had.
const activeSessionForView: SessionSummary | undefined =
activeSession ??
(activeId
? {
id: activeId,
? pendingSessionView({
sessionId: activeId,
name: shellCopy.newConversation,
isFlagged: false,
isArchived: false,
labels: [],
hasUnread: false,
status: 'active',
backend: 'fake',
llmConnectionSlug: 'default',
connectionLocked: false,
model: 'fake-model',
// Transient placeholder while the real SessionSummary loads --
// matches the configured default so the composer doesn't flash a
// hardcoded value before the real session data settles.
permissionMode: defaultPermissionMode,
}
permissionMode: defaultPermissionMode,
})
Comment thread
Astro-Han marked this conversation as resolved.
: undefined);
const {
boundary: activeExecutionBoundary,
Expand Down
8 changes: 3 additions & 5 deletions apps/desktop/src/renderer/model-catalog-choices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS,
PROVIDER_DEFAULTS,
connectionEnabledModelIds,
providerDefaultsOf,
} from '@maka/core/llm-connections';
import type { LlmConnection, ProviderType } from '@maka/core/llm-connections';
import type { UiLocale } from '@maka/core/ui-locale';
Expand Down Expand Up @@ -138,13 +139,10 @@ function dailyReviewModelDisplayLabel(
}

function isModelConsumerConnection(connection: Pick<LlmConnection, 'enabled' | 'providerType'>): boolean {
const defaults = PROVIDER_DEFAULTS[connection.providerType];
// Unknown providerType (legacy seed, or a connection persisted on a branch
// that registers a provider this build doesn't know) → not a model consumer.
// Mirrors `isFakeBackend` in connection-readiness.ts; without this guard the
// `.backendKind` read below throws on load for an orphan connection.
if (!connection.enabled || !defaults || defaults.backendKind !== 'ai-sdk') return false;
return true;
// Mirrors `isRealConnection` in connection-readiness.ts.
return connection.enabled && providerDefaultsOf(connection.providerType) !== undefined;
}

function enabledProviderCounts(connections: readonly LlmConnection[]): Map<ProviderType, number> {
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/renderer/pending-session-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { PermissionMode } from '@maka/core/permission';
import type { SessionSummary } from '@maka/core/session';

export interface PendingSessionViewInput {
sessionId: string;
name: string;
permissionMode: PermissionMode;
}

/**
* The `SessionSummary` the chat view shows between "a session id became active"
* and "its real summary arrived".
*
* The connection and model read empty because they are genuinely unknown: this
* fallback covers every active id without a loaded summary, not just a freshly
* created task, so the session behind it may be an existing one bound to any
* model. An empty pair matches no offered choice, which is how the model
* switcher's current-value and no-op comparisons read "not yet known" — naming
* a plausible model instead would let a switch onto that model be silently
* dropped as a no-op against a session that was never on it.
*
* It used to say `backend: 'fake'` / `model: 'fake-model'`, borrowing a retired
* backend (#3211) to mean "not loaded". The unknown-ness is the same; the
* borrowed name is gone.
*/
export function pendingSessionView(input: PendingSessionViewInput): SessionSummary {
return {
id: input.sessionId,
name: input.name,
isFlagged: false,
isArchived: false,
labels: [],
hasUnread: false,
status: 'active',
backend: 'ai-sdk',
llmConnectionSlug: '',
connectionLocked: false,
model: '',
permissionMode: input.permissionMode,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function ConnectionDetail(props: ConnectionDetailProps) {
// Unknown providerType (a connection persisted on a branch that registers a
// provider this build doesn't know) → render a non-actionable fallback so
// opening the orphan connection doesn't crash on `.authKind`/`.baseUrl`.
// Mirrors `isFakeBackend` in @maka/core/connection-readiness.ts.
// Mirrors `isRealConnection` in @maka/core/connection-readiness.ts.
if (!defaults) return <UnknownConnectionDetail props={props} />;
return <ConnectionDetailInner {...props} />;
}
Expand Down
20 changes: 12 additions & 8 deletions apps/desktop/src/renderer/stale-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export interface StaleSessionsInput {
/** Sessions visible in the sidebar (already filtered + grouped). */
sessions: ReadonlyArray<{
id: string;
backend: string;
}>;
/** Readiness already resolved by each Session's owning Runtime Host. */
sendOutcomes: Readonly<Record<string, SessionSendProjection>>;
Expand All @@ -13,17 +12,22 @@ export interface StaleSessionsInput {
export function deriveStaleSessionIds(input: StaleSessionsInput): Set<string> {
const stale = new Set<string>();
for (const session of input.sessions) {
if (isStale(session, input.sendOutcomes[session.id])) {
if (isStale(input.sendOutcomes[session.id])) {
stale.add(session.id);
}
}
return stale;
}

function isStale(
session: { backend: string },
outcome: SessionSendProjection | undefined,
): boolean {
if (session.backend === 'fake') return true;
return outcome?.kind === 'blocked' && outcome.reason === 'connection_missing';
/**
* A row is stale when its owning Runtime Host says the next send cannot go
* anywhere the user can fix from the rail.
*
* `fake_backend` is read from the projection rather than from `session.backend`
* (#3211): the readiness projection is the single authority on whether a task
* is usable, and a retired backend is one of its answers like any other.
*/
function isStale(outcome: SessionSendProjection | undefined): boolean {
if (outcome?.kind !== 'blocked') return false;
return outcome.reason === 'connection_missing' || outcome.reason === 'fake_backend';
}
13 changes: 10 additions & 3 deletions apps/desktop/src/renderer/use-shell-chat-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,20 @@ export function useShellChatModel(options: {
catalogDefault: catalogDefaultNewChatModel,
choices: chatModelChoices,
});
const activeConnectionLabel = activeSession?.backend === 'fake'
// A task whose backend was retired has no model to name (#3211). That verdict
// comes from the readiness projection, not from reading `activeSession.backend`
// here: the projection is the single authority on whether a task is usable,
// and it already answers `fake_backend` for these rows.
const isRetiredBackend =
options.sessionSendOutcome?.kind === 'blocked' &&
options.sessionSendOutcome.reason === 'fake_backend';
const activeConnectionLabel = isRetiredBackend
? conversationCopy.model.fakeBackendLabel
: activeConnection?.name ?? activeSession?.llmConnectionSlug;
const activeModel = activeSession?.backend === 'fake'
const activeModel = isRetiredBackend
? undefined
: activeSession?.model || activeConnection?.defaultModel;
const activeModelLabel = activeSession?.backend === 'fake'
const activeModelLabel = isRetiredBackend
? undefined
: chatModelChoiceLabel(chatModelChoices, activeSession?.llmConnectionSlug, activeModel);
const activeThinkingLevels = useMemo(
Expand Down
Loading