merge: isolate concurrent OpenCode chat runs

This commit is contained in:
2026-08-17 22:28:19 +08:00
34 changed files with 5159 additions and 434 deletions

View File

@@ -202,6 +202,7 @@ export function ProvidersSettings() {
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [syncingUserModels, setSyncingUserModels] = useState(false);
const [autoUserModelSyncAttempted, setAutoUserModelSyncAttempted] = useState(false);
const [runtimeRefreshRequired, setRuntimeRefreshRequired] = useState(false);
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
const existingVendorIds = new Set(accounts.map((account) => account.vendorId));
const displayProviders = useMemo(
@@ -214,7 +215,10 @@ export function ProvidersSettings() {
refreshProviderSnapshot();
}, [refreshProviderSnapshot]);
const handleImportUserModelConfig = useCallback(async (options: { silent?: boolean } = {}) => {
const handleImportUserModelConfig = useCallback(async (options: {
silent?: boolean;
runtimeRefresh: 'apply' | 'defer';
}) => {
if (!accessToken) {
if (!options.silent) {
toast.error(t('aiProviders.toast.loginRequiredForSync', 'Sign in before syncing Makelore models.'));
@@ -224,7 +228,12 @@ export function ProvidersSettings() {
setSyncingUserModels(true);
try {
await importUserModelConfig(accessToken);
const result = await importUserModelConfig(accessToken, {
runtimeRefresh: options.runtimeRefresh,
});
setRuntimeRefreshRequired(
options.runtimeRefresh === 'defer' && result.runtimeRefreshRequired,
);
if (!options.silent) {
toast.success(t('aiProviders.toast.syncedUserModels', 'Synced Makelore models'));
}
@@ -244,7 +253,7 @@ export function ProvidersSettings() {
return;
}
setAutoUserModelSyncAttempted(true);
void handleImportUserModelConfig({ silent: true });
void handleImportUserModelConfig({ silent: true, runtimeRefresh: 'defer' });
}, [
accessToken,
autoUserModelSyncAttempted,
@@ -324,7 +333,7 @@ export function ProvidersSettings() {
<Button
data-testid="providers-sync-user-models-button"
variant="outline"
onClick={() => void handleImportUserModelConfig()}
onClick={() => void handleImportUserModelConfig({ runtimeRefresh: 'apply' })}
disabled={!accessToken || syncingUserModels}
className="rounded-full px-4 h-9 shadow-none font-medium text-meta"
>
@@ -333,7 +342,7 @@ export function ProvidersSettings() {
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
{t('aiProviders.syncUserModels', 'Sync Makelore models')}
{t('aiProviders.syncUserModels', 'Sync and apply Makelore models')}
</Button>
<Button data-testid="providers-add-button" onClick={() => setShowAddDialog(true)} className="rounded-full px-5 h-9 shadow-none font-medium text-meta">
<Plus className="h-4 w-4 mr-2" />
@@ -342,6 +351,18 @@ export function ProvidersSettings() {
</div>
</div>
{runtimeRefreshRequired ? (
<p
data-testid="providers-runtime-refresh-pending"
className="rounded-xl border border-brand/20 bg-brand-soft px-4 py-3 text-sm text-foreground"
>
{t(
'aiProviders.runtimeRefreshPending',
'The model list is up to date. Use “Sync and apply Makelore models” when you are ready to restart the runtime.',
)}
</p>
) : null}
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground bg-surface-subtle rounded-3xl border border-transparent border-dashed">
<Loader2 className="h-6 w-6 animate-spin" />

View File

@@ -824,9 +824,10 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const sessions = useOpencodeStore((state) => state.sessions);
const sessionMessagesBySessionId = useOpencodeStore((state) => state.sessionMessagesBySessionId);
const selectedSessionId = useOpencodeStore((state) => state.selectedSessionId);
const selectedSessionRunError = useOpencodeStore((state) => (
selectedSessionId ? state.sessionRunStates[selectedSessionId]?.error ?? null : null
));
const sessionRunStates = useOpencodeStore((state) => state.sessionRunStates);
const selectedSessionRunError = selectedSessionId
? sessionRunStates[selectedSessionId]?.error ?? null
: null;
const sessionStatuses = useOpencodeStore((state) => state.sessionStatuses);
const sessionTranscript = useOpencodeStore((state) => (
selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined
@@ -950,7 +951,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const lastCompactionAnnouncementSessionRef = useRef<string | null>(null);
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const composerStopArmTimerRef = useRef<number | null>(null);
const userModelConfigSyncRef = useRef<Promise<void> | null>(null);
const userModelConfigSyncRef = useRef<Promise<boolean> | null>(null);
const localProxyModelConfigSyncedRef = useRef(false);
const voiceRecorderRef = useRef<MediaRecorder | null>(null);
const voiceStreamRef = useRef<MediaStream | null>(null);
@@ -1055,8 +1056,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
&& !loading;
const quotaExhaustedRetryActive = selectedSessionStatus?.type === 'retry'
&& isQuotaExhaustedRetryMessage(selectedSessionStatus.message);
const quotaExhaustedError = getOpencodeErrorKind(selectedSessionRunError) === 'quota_exhausted'
? selectedSessionRunError
const projectedError = error ?? selectedSessionRunError;
const projectedErrorKind = error
? errorKind ?? getOpencodeErrorKind(error)
: getOpencodeErrorKind(selectedSessionRunError);
const quotaExhaustedError = projectedErrorKind === 'quota_exhausted'
? projectedError
: null;
const quotaExhaustedRetryError = quotaExhaustedRetryActive
? selectedSessionStatus.message ?? 'quota_exhausted'
@@ -1067,24 +1072,23 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const tokenBalanceExhaustedActive = isOpencodeTokenBalanceExhausted(activeQuotaExhaustedError);
const authenticationInvalidRetryActive = selectedSessionStatus?.type === 'retry'
&& getOpencodeErrorKind(selectedSessionStatus.message) === 'authentication_invalid';
const authenticationInvalidActive = (errorKind === 'authentication_invalid' && Boolean(error))
const authenticationInvalidActive = (projectedErrorKind === 'authentication_invalid' && Boolean(projectedError))
|| authenticationInvalidRetryActive;
const authenticationFailureKey = authenticationInvalidActive
? `${selectedSessionId ?? 'global'}:${error ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
? `${error ? 'global' : selectedSessionId ?? 'global'}:${projectedError ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
: null;
const quotaFailureKey = rawQuotaExhaustedActive
? `${selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
? `${error ? 'global' : selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
: null;
const quotaExhaustedErrorActive = quotaFailureKey !== null
&& confirmedQuotaFailureKey === quotaFailureKey;
const quotaUpgradePromptOpen = quotaFailureKey !== null
&& quotaUpgradePromptKey === quotaFailureKey;
const displayErrorKind = errorKind ?? getOpencodeErrorKind(error);
const displayError = error
&& displayErrorKind !== 'authentication_invalid'
&& displayErrorKind !== 'quota_exhausted'
&& !isUpstreamSaturatedError(error)
? error
const displayError = projectedError
&& projectedErrorKind !== 'authentication_invalid'
&& projectedErrorKind !== 'quota_exhausted'
&& !isUpstreamSaturatedError(projectedError)
? projectedError
: null;
const visibleTranscriptMessages = useMemo(() => {
return buildVisibleTranscriptMessages(
@@ -1521,21 +1525,27 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const userModelAccount = providerAccounts.find((account) => account.id === NIANCODE_USER_MODEL_ACCOUNT_ID);
const shouldSyncLocalProxy = userModelAccount?.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE
&& !localProxyModelConfigSyncedRef.current;
if (!shouldRefreshWorksSquareUserModelConfig(providerAccounts) && !shouldSyncLocalProxy) return;
if (!shouldRefreshWorksSquareUserModelConfig(providerAccounts) && !shouldSyncLocalProxy) return true;
if (userModelConfigSyncRef.current) {
await userModelConfigSyncRef.current;
return;
return await userModelConfigSyncRef.current;
}
const syncTask = (async () => {
const validAccessToken = await getValidAccessToken();
if (!validAccessToken) return;
if (!validAccessToken) return true;
try {
await importUserModelConfig(validAccessToken);
const result = await importUserModelConfig(validAccessToken, {
runtimeRefresh: 'defer',
});
if (result.runtimeRefreshRequired) {
toast.error('模型配置已更新,请手动重启运行时后重试。');
return false;
}
if (shouldSyncLocalProxy) {
localProxyModelConfigSyncedRef.current = true;
}
return true;
} catch (error) {
if (!isUnauthorizedWorksError(error)) {
throw error;
@@ -1544,25 +1554,34 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
if (!refreshedAccessToken) {
throw error;
}
await importUserModelConfig(refreshedAccessToken);
const result = await importUserModelConfig(refreshedAccessToken, {
runtimeRefresh: 'defer',
});
if (result.runtimeRefreshRequired) {
toast.error('模型配置已更新,请手动重启运行时后重试。');
return false;
}
if (shouldSyncLocalProxy) {
localProxyModelConfigSyncedRef.current = true;
}
return true;
}
})()
.catch((error) => {
console.warn('[chat] Failed to refresh Works Square user model config', error);
toast.error('模型配置同步失败,请稍后重试。');
return false;
})
.finally(() => {
userModelConfigSyncRef.current = null;
});
userModelConfigSyncRef.current = syncTask;
await syncTask;
return await syncTask;
}, [getValidAccessToken, importUserModelConfig, providerAccounts, refreshSession]);
const ensureChatModelConfigured = useCallback(async () => {
await refreshWorksSquareUserModelConfigIfNeeded();
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return false;
if (selectedProjectAgent?.model?.trim()) return true;
setModelConfigPromptOpen(true);
return false;
@@ -1611,7 +1630,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
return;
}
const configuredAgentModel = submissionAgent.model?.trim();
await refreshWorksSquareUserModelConfigIfNeeded();
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return;
if (!sessionStillMatches()) return;
const latestProviderState = useProviderStore.getState();
const latestConfiguredChatModelOptions = buildConfiguredModelOptions(
@@ -2053,6 +2072,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
}, []);
const runCompact = useCallback(async () => {
if (!(await ensureChatModelConfigured())) {
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
}
const owner = submissionSessionRef.current;
const sessionId = owner.id;
if (!sessionId) throw new Error('请先选择会话');
@@ -2061,7 +2083,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
owner,
submissionSessionRef.current,
);
}, [compactSession, selectedProjectAgent?.model]);
}, [compactSession, ensureChatModelConfigured, selectedProjectAgent?.model]);
const runShare = useCallback(async () => {
const owner = submissionSessionRef.current;
@@ -2230,6 +2252,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
rawName: string;
arguments: string;
}): Promise<{ status: 'executed' | 'cancelled' }> => {
if (!(await ensureChatModelConfigured())) {
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
}
const owner = submissionSessionRef.current;
const submissionSessionId = owner.id;
if (!submissionSessionId) throw new Error('请先选择会话');
@@ -2261,6 +2286,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
}, [
composer,
currentSession?.agent,
ensureChatModelConfigured,
executeProjectCommand,
selectedProjectAgent?.id,
selectedProjectAgent?.model,

View File

@@ -812,6 +812,7 @@ export function CharacterScene() {
const [providerSnapshotLoaded, setProviderSnapshotLoaded] = useState(false);
const [autoUserModelSyncAttempted, setAutoUserModelSyncAttempted] = useState(false);
const [syncingUserModels, setSyncingUserModels] = useState(false);
const [runtimeRefreshRequired, setRuntimeRefreshRequired] = useState(false);
const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeFile[]>(knowledgeFiles);
const [knowledgeNotice, setKnowledgeNotice] = useState<string | null>(null);
const [knowledgeDragActive, setKnowledgeDragActive] = useState(false);
@@ -909,7 +910,10 @@ export function CharacterScene() {
setAutoUserModelSyncAttempted(true);
setSyncingUserModels(true);
void importUserModelConfig(accessToken)
void importUserModelConfig(accessToken, { runtimeRefresh: 'defer' })
.then((result) => {
setRuntimeRefreshRequired(result.runtimeRefreshRequired);
})
.catch((error) => {
console.warn('[makelore] Failed to auto-sync current user model config', error);
})
@@ -940,7 +944,7 @@ export function CharacterScene() {
let result: Awaited<ReturnType<typeof importUserModelConfig>>;
try {
result = await importUserModelConfig(validAccessToken);
result = await importUserModelConfig(validAccessToken, { runtimeRefresh: 'apply' });
} catch (error) {
if (!isUnauthorizedModelConfigError(error)) {
throw error;
@@ -949,7 +953,7 @@ export function CharacterScene() {
if (!refreshedAccessToken) {
throw error;
}
result = await importUserModelConfig(refreshedAccessToken);
result = await importUserModelConfig(refreshedAccessToken, { runtimeRefresh: 'apply' });
}
const importedModelCount = new Set([
@@ -958,6 +962,7 @@ export function CharacterScene() {
...result.importedModels,
].filter(Boolean)).size;
setProviderSnapshotLoaded(true);
setRuntimeRefreshRequired(false);
toast.success('已重新拉取远端模型配置', {
description: importedModelCount > 0 ? `同步 ${importedModelCount} 个模型` : '模型列表已更新',
});
@@ -1253,9 +1258,17 @@ export function CharacterScene() {
onClick={handleRefreshUserModelConfig}
>
<RefreshCw className={cn('h-4 w-4', syncingUserModels && 'animate-spin')} />
{syncingUserModels ? '重新拉取中...' : '重新拉取远端模型配置'}
{syncingUserModels ? '应用中...' : '重新拉取并应用模型配置'}
</Button>
</div>
{runtimeRefreshRequired ? (
<p
data-testid="makelore-runtime-refresh-pending"
className="mb-4 rounded-lg border border-brand/20 bg-brand-soft px-3 py-2 text-sm text-foreground"
>
</p>
) : null}
<h3 className="mb-2 font-semibold"></h3>
<div className="space-y-3">
{approvedModels.map((model) => (

View File

@@ -143,6 +143,7 @@ export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt
switch (event.type) {
case 'post_accepted':
return state;
case 'remote_busy':
return {
...state,
@@ -151,6 +152,7 @@ export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt
error: null,
};
case 'remote_idle':
if (state.phase === 'posting') return state;
return {
...state,
phase: 'idle',
@@ -178,6 +180,7 @@ export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'failed',
error: event.error,
suppressNextAbortError: false,

View File

@@ -254,6 +254,8 @@ interface OpencodeState {
const initialStatus: OpencodeStatus = { state: 'stopped', port: 4096 };
const POLL_INTERVAL_MS = 750;
const PROMPT_START_CONFIRMATION_TIMEOUT_MS = 10_000;
const PROMPT_START_UNCONFIRMED_ERROR = 'SESSION_START_UNCONFIRMED: 运行时未确认该会话已开始,本次消息不会自动重发,请检查运行时后手动重试。';
const IDLE_SESSION_STATUS: OpencodeSessionStatus = { type: 'idle' };
const activeSessionEventSources = new Map<string, EventSource>();
const activeProjectEventSources = new Map<string, EventSource>();
@@ -277,6 +279,11 @@ function removeEventListenerIfSupported(
let projectEventReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let projectEventReconnectAttempt = 0;
const activeSessionRunTokens = new Map<string, number>();
const activePromptStartWatchdogs = new Map<string, {
runToken: number;
timer: ReturnType<typeof setTimeout>;
reject: (reason: Error) => void;
}>();
const pendingSessionRunStreamBatches = new Map<string, PendingSessionRunStreamBatch>();
const drainingAbortedSessionRunIds = new Map<string, number>();
const ignoredSuppressedPolledAbortKeys = new Map<string, { runId: number; keys: Set<string> }>();
@@ -307,6 +314,58 @@ type PendingSessionRunStreamBatch = {
timer: ReturnType<typeof setTimeout> | null;
};
class PromptStartWatchdogCancelledError extends Error {}
function rejectPromptStartWatchdog(
sessionId: string,
runToken: number,
reason: Error,
): void {
const watchdog = activePromptStartWatchdogs.get(sessionId);
if (!watchdog || watchdog.runToken !== runToken) return;
activePromptStartWatchdogs.delete(sessionId);
clearTimeout(watchdog.timer);
watchdog.reject(reason);
}
function cancelPromptStartWatchdog(sessionId: string, runToken: number): void {
rejectPromptStartWatchdog(
sessionId,
runToken,
new PromptStartWatchdogCancelledError(),
);
}
function startPromptStartWatchdog(sessionId: string, runToken: number): Promise<never> {
const existing = activePromptStartWatchdogs.get(sessionId);
if (existing) cancelPromptStartWatchdog(sessionId, existing.runToken);
let rejectWatchdog!: (reason: Error) => void;
const promise = new Promise<never>((_resolve, reject) => {
rejectWatchdog = reject;
});
void promise.catch(() => undefined);
const timer = setTimeout(() => {
rejectPromptStartWatchdog(
sessionId,
runToken,
new Error(PROMPT_START_UNCONFIRMED_ERROR),
);
}, PROMPT_START_CONFIRMATION_TIMEOUT_MS);
activePromptStartWatchdogs.set(sessionId, {
runToken,
timer,
reject: rejectWatchdog,
});
return promise;
}
function cancelAllPromptStartWatchdogs(): void {
for (const [sessionId, watchdog] of activePromptStartWatchdogs) {
cancelPromptStartWatchdog(sessionId, watchdog.runToken);
}
}
function beginSessionRun(sessionId: string): number {
sessionRunNonce += 1;
const nextToken = sessionRunNonce;
@@ -316,6 +375,10 @@ function beginSessionRun(sessionId: string): number {
function invalidateSessionRun(sessionId: string): void {
ignoredSuppressedPolledAbortKeys.delete(sessionId);
const activeRunToken = activeSessionRunTokens.get(sessionId);
if (activeRunToken !== undefined) {
cancelPromptStartWatchdog(sessionId, activeRunToken);
}
beginSessionRun(sessionId);
}
@@ -324,6 +387,7 @@ function isSessionRunCurrent(sessionId: string, token: number): boolean {
}
function closeActiveSessionEventSource(): void {
cancelAllPromptStartWatchdogs();
activeSessionRunTokens.clear();
for (const sessionId of pendingSessionRunStreamBatches.keys()) {
cancelSessionRunStreamBatch(sessionId);
@@ -2426,7 +2490,6 @@ function finishSessionRunSuccessfully(
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
...errorState(null),
...(completedTranscript && completedTranscript !== currentTranscript
? {
sessionTranscriptBySessionId: {
@@ -2552,7 +2615,6 @@ async function runSessionSubmission(
...clearSessionStreamingPatch(state, sessionId),
...runStatePatch,
selectedSessionId,
...errorState(null),
sendingSessionIds,
sendingSessionId: selectedSessionId === sessionId ? sessionId : state.sendingSessionId,
sessionStatuses: {
@@ -2571,6 +2633,42 @@ async function runSessionSubmission(
});
let liveSessionError: Error | null = null;
let promptStartAcknowledged = submission.kind !== 'prompt';
let promptStartWatchdog: Promise<never> | null = null;
const acknowledgePromptStart = () => {
if (promptStartAcknowledged) return;
promptStartAcknowledged = true;
cancelPromptStartWatchdog(sessionId, runToken);
};
const awaitDuringPromptStart = async <T>(operation: Promise<T>): Promise<T> => {
if (promptStartAcknowledged || !promptStartWatchdog) return await operation;
try {
return await Promise.race([operation, promptStartWatchdog]);
} catch (error) {
if (error instanceof PromptStartWatchdogCancelledError && promptStartAcknowledged) {
return await operation;
}
throw error;
}
};
const dispatchObservedIdle = (state: OpencodeState) => {
const runState = getSessionRunStateForSession(state, sessionId);
if (submission.kind === 'prompt' || runState.phase !== 'posting') {
return dispatchSessionRunEvent(state, sessionId, {
type: 'remote_idle',
runId: runToken,
});
}
const startedPatch = dispatchSessionRunEvent(state, sessionId, {
type: 'remote_busy',
runId: runToken,
});
return dispatchSessionRunEvent(
{ ...state, ...startedPatch },
sessionId,
{ type: 'remote_idle', runId: runToken },
);
};
try {
try {
@@ -2586,20 +2684,32 @@ async function runSessionSubmission(
if (!isSessionRunCurrent(sessionId, runToken)) return;
if (getOpencodeEventSessionId(payload) !== sessionId) return;
const status = normalizeSessionStatus(payload.status);
const acknowledgesStart = status.type === 'busy' || status.type === 'retry';
if (acknowledgesStart) acknowledgePromptStart();
if (status.type === 'idle') {
flushSessionRunStreamBatch(set, sessionId, runToken);
}
set((state) => ({
...getSessionTranscriptPatch(state, 'session.status', payload),
...dispatchSessionRunEvent(state, sessionId, {
type: status.type === 'idle' ? 'remote_idle' : 'remote_busy',
runId: runToken,
}),
sessionStatuses: {
...state.sessionStatuses,
[sessionId]: status,
},
}));
set((state) => {
const runState = getSessionRunStateForSession(state, sessionId);
const ignoreUnconfirmedIdle = submission.kind === 'prompt'
&& status.type === 'idle'
&& runState.phase === 'posting';
return {
...getSessionTranscriptPatch(state, 'session.status', payload),
...(status.type === 'idle'
? dispatchObservedIdle(state)
: dispatchSessionRunEvent(state, sessionId, {
type: 'remote_busy',
runId: runToken,
})),
sessionStatuses: {
...state.sessionStatuses,
[sessionId]: ignoreUnconfirmedIdle
? state.sessionStatuses[sessionId] ?? { type: 'busy' }
: status,
},
};
});
});
registerSessionRunEventListener(sessionId, source, 'session.idle', (event) => {
@@ -2607,17 +2717,21 @@ async function runSessionSubmission(
if (!isSessionRunCurrent(sessionId, runToken)) return;
if (getOpencodeEventSessionId(payload) !== sessionId) return;
flushSessionRunStreamBatch(set, sessionId, runToken);
set((state) => ({
...getSessionTranscriptPatch(state, 'session.idle', payload),
...dispatchSessionRunEvent(state, sessionId, {
type: 'remote_idle',
runId: runToken,
}),
sessionStatuses: {
...state.sessionStatuses,
[sessionId]: IDLE_SESSION_STATUS,
},
}));
set((state) => {
const runState = getSessionRunStateForSession(state, sessionId);
const ignoreUnconfirmedIdle = submission.kind === 'prompt'
&& runState.phase === 'posting';
return {
...getSessionTranscriptPatch(state, 'session.idle', payload),
...dispatchObservedIdle(state),
sessionStatuses: {
...state.sessionStatuses,
[sessionId]: ignoreUnconfirmedIdle
? state.sessionStatuses[sessionId] ?? { type: 'busy' }
: IDLE_SESSION_STATUS,
},
};
});
});
registerSessionRunEventListener(sessionId, source, 'session.compacted', (event) => {
@@ -2674,7 +2788,6 @@ async function runSessionSubmission(
return {
...transcriptPatch,
...runStatePatch,
...errorState(null),
};
}
const sendingSessionIds = withoutKey(state.sendingSessionIds, sessionId);
@@ -2703,7 +2816,6 @@ async function runSessionSubmission(
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
...errorState(null),
};
});
if (shouldSuppress) return;
@@ -2713,6 +2825,7 @@ async function runSessionSubmission(
}
liveSessionError = new Error(message);
rejectPromptStartWatchdog(sessionId, runToken, liveSessionError);
closeSessionEventSource(sessionId);
set((state) => {
const transcriptPatch = getSessionTranscriptPatch(state, 'session.error', payload);
@@ -2744,7 +2857,6 @@ async function runSessionSubmission(
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
...errorState(message),
};
});
});
@@ -2754,7 +2866,12 @@ async function runSessionSubmission(
if (!isSessionRunCurrent(sessionId, runToken)) return;
const question = normalizeQuestionRequest(payload);
if (!question || question.sessionID !== sessionId) return;
acknowledgePromptStart();
set((state) => ({
...dispatchSessionRunEvent(state, sessionId, {
type: 'remote_busy',
runId: runToken,
}),
pendingQuestions: mergePendingQuestion(state.pendingQuestions, question),
}));
});
@@ -2794,7 +2911,12 @@ async function runSessionSubmission(
if (!isSessionRunCurrent(sessionId, runToken)) return;
const permission = normalizePermissionRequest(payload);
if (!permission || permission.sessionID !== sessionId) return;
acknowledgePromptStart();
set((state) => ({
...dispatchSessionRunEvent(state, sessionId, {
type: 'remote_busy',
runId: runToken,
}),
pendingPermissions: mergePendingPermission(state.pendingPermissions, permission),
}));
});
@@ -2803,6 +2925,13 @@ async function runSessionSubmission(
const payload = JSON.parse((event as MessageEvent<string>).data) as Record<string, unknown>;
if (!isSessionRunCurrent(sessionId, runToken)) return;
if (getOpencodeEventSessionId(payload) !== sessionId) return;
if (createStreamingMessageFromEvent(payload)?.role === 'assistant') {
acknowledgePromptStart();
set((state) => dispatchSessionRunEvent(state, sessionId, {
type: 'remote_busy',
runId: runToken,
}));
}
queueSessionRunStreamEvent(set, sessionId, runToken, 'message.updated', payload);
});
@@ -2849,6 +2978,9 @@ async function runSessionSubmission(
if (!response.success) {
throw new Error(response.error || `Failed to start ${submission.kind}`);
}
if (submission.kind === 'prompt' && !promptStartAcknowledged && !liveSessionError) {
promptStartWatchdog = startPromptStartWatchdog(sessionId, runToken);
}
try {
options?.onHostAccepted?.();
} catch {
@@ -2857,6 +2989,10 @@ async function runSessionSubmission(
if (liveSessionError) {
throw liveSessionError;
}
set((state) => dispatchSessionRunEvent(state, sessionId, {
type: submission.kind === 'prompt' ? 'post_accepted' : 'remote_busy',
runId: runToken,
}));
if (submission.completion === 'post-success') {
if (!isSessionRunCurrent(sessionId, runToken)) {
return getSessionMessagesForState(get(), sessionId);
@@ -2880,11 +3016,6 @@ async function runSessionSubmission(
if (!isSessionRunCurrent(sessionId, runToken)) {
return getSessionMessagesForState(get(), sessionId);
}
set((state) => dispatchSessionRunEvent(state, sessionId, {
type: 'post_accepted',
runId: runToken,
}));
let messages: RawMessage[] = getSessionMessagesForState(get(), sessionId);
let status: OpencodeSessionStatus = { type: 'busy' };
const useBaselineInference = submission.kind !== 'prompt';
@@ -2914,7 +3045,9 @@ async function runSessionSubmission(
|| eventStatus?.type === 'idle';
let statuses: OpencodeSessionStatusMap = {};
if (shouldPollStatus) {
const statusResponse = await hostApiFetch<OpencodeSessionStatusActionResponse>('/api/opencode/sessions/status');
const statusResponse = await awaitDuringPromptStart(
hostApiFetch<OpencodeSessionStatusActionResponse>('/api/opencode/sessions/status'),
);
statuses = normalizeSessionStatusMap(statusResponse.statuses);
nextStatusFallbackPollAt = Date.now() + SESSION_STATUS_FALLBACK_POLL_MS;
}
@@ -2926,7 +3059,7 @@ async function runSessionSubmission(
|| observedStatus?.type === 'idle';
let refreshedMessages = messages;
if (shouldRefreshMessages) {
refreshedMessages = await fetchSessionMessages(sessionId);
refreshedMessages = await awaitDuringPromptStart(fetchSessionMessages(sessionId));
nextMessageFallbackPollAt = Date.now() + SESSION_MESSAGE_FALLBACK_POLL_MS;
}
if (liveSessionError) {
@@ -2952,6 +3085,13 @@ async function runSessionSubmission(
: sendingUserMessage
? getAssistantErrorAfterPrompt(refreshedMessages, sendingUserMessage)
: null;
const assistantResponded = submission.kind === 'prompt'
&& sendingUserMessage
&& hasAssistantResponseAfterPrompt(refreshedMessages, sendingUserMessage);
const explicitBusy = httpStatus?.type === 'busy' || httpStatus?.type === 'retry';
if (explicitBusy || assistantResponded) {
acknowledgePromptStart();
}
const inferredStatus = useBaselineInference
? inferMissingPolledStatusAfterBaseline(refreshedMessages, baseline)
: sendingUserMessage
@@ -2960,13 +3100,16 @@ async function runSessionSubmission(
// A missing HTTP status is common while the runtime is transitioning.
// Prefer a newly observed assistant response over the optimistic/event
// busy marker, otherwise a completed run can remain busy forever.
const polledStatus = submission.kind === 'compact'
const observedPolledStatus = submission.kind === 'compact'
? shouldPollStatus
? httpStatus ?? eventStatus ?? status
: eventStatus ?? status
: shouldPollStatus
? httpStatus ?? inferredStatus
: eventStatus ?? status ?? inferredStatus;
const polledStatus = submission.kind === 'prompt' && !promptStartAcknowledged
? { type: 'busy' as const }
: observedPolledStatus;
status = assistantError || assistantAborted ? IDLE_SESSION_STATUS : polledStatus;
const currentMessages = getSessionMessagesForState(get(), sessionId);
messages = assistantAborted || status.type === 'idle'
@@ -2996,7 +3139,6 @@ async function runSessionSubmission(
...statuses,
[sessionId]: polledStatus,
},
...errorState(null),
};
}
@@ -3026,13 +3168,12 @@ async function runSessionSubmission(
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
...errorState(null),
};
});
if (shouldSuppressAbort) {
rememberIgnoredSuppressedPolledAbortKey(sessionId, runToken, assistantAbortKey);
await sleep(POLL_INTERVAL_MS);
await awaitDuringPromptStart(sleep(POLL_INTERVAL_MS));
continue;
}
@@ -3045,6 +3186,12 @@ async function runSessionSubmission(
if (shouldRefreshMessages || shouldPollStatus || assistantError) {
set((state) => ({
...getSessionMessagePatch(state, sessionId, messages),
...(promptStartAcknowledged
? dispatchSessionRunEvent(state, sessionId, {
type: 'remote_busy',
runId: runToken,
})
: {}),
...(assistantError
? dispatchSessionRunEvent(state, sessionId, {
type: 'remote_failed',
@@ -3057,7 +3204,6 @@ async function runSessionSubmission(
...statuses,
[sessionId]: status,
},
...errorState(assistantError ?? null),
}));
}
@@ -3087,7 +3233,7 @@ async function runSessionSubmission(
return messages;
}
await sleep(POLL_INTERVAL_MS);
await awaitDuringPromptStart(sleep(POLL_INTERVAL_MS));
}
} catch (error) {
if (!isSessionRunCurrent(sessionId, runToken)) {
@@ -3132,12 +3278,13 @@ async function runSessionSubmission(
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
...errorState(errorMessage),
};
});
invalidateSessionRun(sessionId);
clearDrainingAbortedRunIfForPreviousRun(sessionId, runToken);
throw error;
} finally {
cancelPromptStartWatchdog(sessionId, runToken);
}
}
@@ -3653,7 +3800,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: true,
...errorState(null),
};
});
try {
@@ -3692,7 +3838,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
return {
...clearSessionRunAbortSuppression(state, sessionId),
loading: false,
...errorState(null),
};
}
@@ -3715,7 +3860,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
...errorState(null),
};
});
} catch (error) {
@@ -3731,7 +3875,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
})
: clearSessionRunAbortSuppression(state, sessionId)),
loading: false,
...errorState(errorMessage),
}));
throw error;
}
@@ -4249,7 +4392,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
return {
...getSessionMessagePatch(currentState, sessionId, messages),
...runStatePatch,
...errorState(null),
};
});
try {

View File

@@ -38,9 +38,13 @@ interface ProviderState {
refreshProviderSnapshot: () => Promise<void>;
createAccount: (account: ProviderAccount, apiKey?: string) => Promise<void>;
removeAccount: (accountId: string) => Promise<void>;
importUserModelConfig: (accessToken: string) => Promise<{
importUserModelConfig: (
accessToken: string,
options?: { runtimeRefresh?: 'apply' | 'defer' },
) => Promise<{
account: ProviderAccount;
importedModels: string[];
runtimeRefreshRequired: boolean;
}>;
validateAccountApiKey: (
accountId: string,
@@ -218,16 +222,20 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
deleteAccount: async (accountId) => get().removeAccount(accountId),
importUserModelConfig: async (accessToken) => {
importUserModelConfig: async (accessToken, options) => {
try {
const result = await hostApiFetch<{
success: boolean;
error?: string;
account?: ProviderAccount;
importedModels?: string[];
runtimeRefreshRequired?: boolean;
}>('/api/provider-accounts/import-user-model-config', {
method: 'POST',
body: JSON.stringify({ accessToken }),
body: JSON.stringify({
accessToken,
runtimeRefresh: options?.runtimeRefresh ?? 'apply',
}),
});
if (!result.success || !result.account) {
@@ -238,6 +246,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
return {
account: result.account,
importedModels: result.importedModels ?? [],
runtimeRefreshRequired: result.runtimeRefreshRequired === true,
};
} catch (error) {
console.error('Failed to import current user model config:', error);