fix(acp): harden session-page connection and localize backend errors
- Session-page connect never triggers download/install; returns SdkNotInstalled immediately and prompts the user to install from Agent Settings instead - Binary agents now accept any cached version via find_best_cached_binary_for_agent so stale caches still connect - Bound Initialize handshake with a 60s timeout and convert it to AcpError::InitializeTimeout via a sentinel in run_connection - Spawn background task owns ConnectionManager map insertion and removes the entry on exit through an RAII guard that survives panics, preventing leaked stale entries - AcpError gains SdkNotInstalled and InitializeTimeout variants plus a stable code() identifier; AcpEvent::Error carries code so the frontend can render localized messages by key - Frontend preflight now runs for all connect sources; error event handler switches on code to show translated text for initialize_timeout, sdk_not_installed, platform_not_supported, process_exited, spawn_failed and download_failed - Remove ConnectionStatus::Downloading enum variant, all frontend branches, and i18n strings; drop obsolete autoLinkFailedTitle, autoLinkPreflightFailed, preflightCheckFailedDefault and preflightFailedTitle keys across 10 locales - Add backendErrors.* translations in 10 languages - Diagnostic logging: always log agent stderr plus binary path/size/args/env keys and Initialize timing; gate stdin/stdout JSON-RPC tracing behind CODEG_ACP_DEBUG to avoid persisting user content into OS log files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1106,19 +1106,12 @@ export function useConnectionStore(): ConnectionStoreApi {
|
||||
|
||||
// ── Actions context (unchanged interface) ──
|
||||
|
||||
export type ConnectSource = "manual" | "auto_link"
|
||||
|
||||
export interface ConnectOptions {
|
||||
source?: ConnectSource
|
||||
}
|
||||
|
||||
export interface AcpActionsValue {
|
||||
connect(
|
||||
contextKey: string,
|
||||
agentType: AgentType,
|
||||
workingDir?: string,
|
||||
sessionId?: string,
|
||||
options?: ConnectOptions
|
||||
sessionId?: string
|
||||
): Promise<void>
|
||||
disconnect(contextKey: string): Promise<void>
|
||||
disconnectAll(): Promise<void>
|
||||
@@ -1211,7 +1204,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
// Keys whose disconnect was requested while connect was still in flight
|
||||
const abandonedKeysRef = useRef(new Set<string>())
|
||||
|
||||
type AutoLinkBlockState =
|
||||
type ConnectBlockState =
|
||||
| { kind: "none"; reason: "" }
|
||||
| {
|
||||
kind: "missing_config" | "disabled" | "unavailable" | "sdk_missing"
|
||||
@@ -1236,8 +1229,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
[t]
|
||||
)
|
||||
|
||||
const resolveAutoLinkBlockState = useCallback(
|
||||
(agent: AcpAgentStatus | null): AutoLinkBlockState => {
|
||||
const resolveConnectBlockState = useCallback(
|
||||
(agent: AcpAgentStatus | null): ConnectBlockState => {
|
||||
if (!agent) {
|
||||
return { kind: "missing_config", reason: t("blocked.missingConfig") }
|
||||
}
|
||||
@@ -1714,23 +1707,54 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
case "error": {
|
||||
flushStreamingQueue()
|
||||
dispatch({ type: "ERROR", contextKey, message: e.message })
|
||||
pushAlertRef.current("error", t("eventErrorTitle"), e.message)
|
||||
// Send OS notification for agent errors
|
||||
{
|
||||
const nc = storeRef.current.connections.get(contextKey)
|
||||
if (nc) {
|
||||
const agentLabel = AGENT_LABELS[nc.agentType]
|
||||
const fn = folderNameRef.current
|
||||
const title = fn ? `${fn} - Codeg` : "Codeg"
|
||||
sendSystemNotification(
|
||||
title,
|
||||
t("notificationError", {
|
||||
const nc = storeRef.current.connections.get(contextKey)
|
||||
const agentLabel = nc
|
||||
? AGENT_LABELS[nc.agentType]
|
||||
: (e.agent_type as string)
|
||||
|
||||
// Localize backend errors via their stable `code` identifier.
|
||||
// Unknown codes fall back to the raw English message so we
|
||||
// never swallow a useful stack trace.
|
||||
const localizedMessage = (() => {
|
||||
switch (e.code) {
|
||||
case "initialize_timeout":
|
||||
return t("backendErrors.initializeTimeout", {
|
||||
agent: agentLabel,
|
||||
})
|
||||
case "sdk_not_installed":
|
||||
return t("blocked.sdkMissing", { agent: agentLabel })
|
||||
case "platform_not_supported":
|
||||
return t("blocked.unavailable", { agent: agentLabel })
|
||||
case "process_exited":
|
||||
return t("backendErrors.processExited", { agent: agentLabel })
|
||||
case "spawn_failed":
|
||||
return t("backendErrors.spawnFailed", {
|
||||
agent: agentLabel,
|
||||
message: e.message,
|
||||
})
|
||||
).catch(() => {})
|
||||
case "download_failed":
|
||||
return t("backendErrors.downloadFailed", {
|
||||
agent: agentLabel,
|
||||
message: e.message,
|
||||
})
|
||||
default:
|
||||
return e.message
|
||||
}
|
||||
})()
|
||||
|
||||
dispatch({ type: "ERROR", contextKey, message: localizedMessage })
|
||||
pushAlertRef.current("error", t("eventErrorTitle"), localizedMessage)
|
||||
// Send OS notification for agent errors
|
||||
if (nc) {
|
||||
const fn = folderNameRef.current
|
||||
const title = fn ? `${fn} - Codeg` : "Codeg"
|
||||
sendSystemNotification(
|
||||
title,
|
||||
t("notificationError", {
|
||||
agent: agentLabel,
|
||||
message: localizedMessage,
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1821,11 +1845,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
for (const [contextKey, conn] of storeRef.current.connections) {
|
||||
if (contextKey === currentActiveKey) continue
|
||||
if (currentOpenTabKeys.has(contextKey)) continue
|
||||
if (
|
||||
conn.status === "prompting" ||
|
||||
conn.status === "connecting" ||
|
||||
conn.status === "downloading"
|
||||
) {
|
||||
if (conn.status === "prompting" || conn.status === "connecting") {
|
||||
continue
|
||||
}
|
||||
if (conn.status !== "connected") continue
|
||||
@@ -1865,60 +1885,54 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
contextKey: string,
|
||||
agentType: AgentType,
|
||||
workingDir?: string,
|
||||
sessionId?: string,
|
||||
options?: ConnectOptions
|
||||
sessionId?: string
|
||||
) => {
|
||||
const source = options?.source ?? "manual"
|
||||
const isAutoLink = source === "auto_link"
|
||||
|
||||
if (connectingKeysRef.current.has(contextKey)) return
|
||||
connectingKeysRef.current.add(contextKey)
|
||||
|
||||
try {
|
||||
if (isAutoLink) {
|
||||
let configuredAgent: AcpAgentStatus | null = null
|
||||
try {
|
||||
configuredAgent = await acpGetAgentStatus(agentType)
|
||||
} catch (error) {
|
||||
const reason = t("unableReadAgentConfig", {
|
||||
message: normalizeErrorMessage(error),
|
||||
})
|
||||
const autoLinkFailedTitle = t("autoLinkFailedTitle", {
|
||||
agent: AGENT_LABELS[agentType],
|
||||
})
|
||||
pushAlertRef.current(
|
||||
"error",
|
||||
autoLinkFailedTitle,
|
||||
`${reason}\n${t("agentsSetupHint")}`,
|
||||
[buildOpenAgentsSettingsAction(agentType)]
|
||||
)
|
||||
throw createAlertedError(reason)
|
||||
}
|
||||
// Preflight: read agent status and block if the SDK / binary is
|
||||
// not installed. The session page must never trigger a download
|
||||
// or install — if the agent is not ready, prompt the user to
|
||||
// install it from Agent Settings instead.
|
||||
let configuredAgent: AcpAgentStatus | null = null
|
||||
try {
|
||||
configuredAgent = await acpGetAgentStatus(agentType)
|
||||
} catch (error) {
|
||||
const reason = t("unableReadAgentConfig", {
|
||||
message: normalizeErrorMessage(error),
|
||||
})
|
||||
const failedTitle = t("connectFailedTitle", {
|
||||
agent: AGENT_LABELS[agentType],
|
||||
})
|
||||
pushAlertRef.current(
|
||||
"error",
|
||||
failedTitle,
|
||||
`${reason}\n${t("agentsSetupHint")}`,
|
||||
[buildOpenAgentsSettingsAction(agentType)]
|
||||
)
|
||||
throw createAlertedError(reason)
|
||||
}
|
||||
|
||||
const blocked = resolveAutoLinkBlockState(configuredAgent)
|
||||
if (blocked.kind !== "none") {
|
||||
const autoLinkFailedTitle = t("autoLinkFailedTitle", {
|
||||
agent: AGENT_LABELS[agentType],
|
||||
})
|
||||
const detail =
|
||||
blocked.kind === "sdk_missing"
|
||||
? t("withSetupHint", {
|
||||
message: blocked.reason,
|
||||
hint: t("agentsSetupHint"),
|
||||
})
|
||||
: `${blocked.reason}\n${t("agentsSetupHint")}`
|
||||
pushAlertRef.current(
|
||||
"error",
|
||||
blocked.kind === "sdk_missing"
|
||||
? blocked.reason
|
||||
: autoLinkFailedTitle,
|
||||
detail,
|
||||
[buildOpenAgentsSettingsAction(agentType)]
|
||||
)
|
||||
throw createAlertedError(
|
||||
blocked.kind === "sdk_missing" ? blocked.reason : blocked.reason
|
||||
)
|
||||
}
|
||||
const blocked = resolveConnectBlockState(configuredAgent)
|
||||
if (blocked.kind !== "none") {
|
||||
const failedTitle = t("connectFailedTitle", {
|
||||
agent: AGENT_LABELS[agentType],
|
||||
})
|
||||
const detail =
|
||||
blocked.kind === "sdk_missing"
|
||||
? t("withSetupHint", {
|
||||
message: blocked.reason,
|
||||
hint: t("agentsSetupHint"),
|
||||
})
|
||||
: `${blocked.reason}\n${t("agentsSetupHint")}`
|
||||
pushAlertRef.current(
|
||||
"error",
|
||||
blocked.kind === "sdk_missing" ? blocked.reason : failedTitle,
|
||||
detail,
|
||||
[buildOpenAgentsSettingsAction(agentType)]
|
||||
)
|
||||
throw createAlertedError(blocked.reason)
|
||||
}
|
||||
|
||||
const existing = storeRef.current.connections.get(contextKey)
|
||||
@@ -1970,11 +1984,37 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
} catch (err) {
|
||||
if (!isAlertedError(err)) {
|
||||
const message = normalizeErrorMessage(err)
|
||||
pushAlertRef.current(
|
||||
"error",
|
||||
t("connectFailedTitle", { agent: agentType }),
|
||||
message
|
||||
)
|
||||
const agentLabel = AGENT_LABELS[agentType]
|
||||
// Backend safety net: if the agent turned out to be not
|
||||
// installed (e.g. the binary was removed between preflight
|
||||
// and spawn), surface the same install prompt with a direct
|
||||
// "Open Agent Settings" action. Title is localized via the
|
||||
// same i18n key the preflight path uses.
|
||||
//
|
||||
// INVARIANT: `AcpError::SdkNotInstalled` renders its payload
|
||||
// unchanged, and both producers
|
||||
// (`src-tauri/src/commands/acp.rs::verify_agent_installed`
|
||||
// and `src-tauri/src/acp/connection.rs::build_agent` Binary
|
||||
// branch) format the message with the literal English
|
||||
// substring "is not installed". Do NOT translate those two
|
||||
// format strings — this branch matches on them as a stable
|
||||
// identifier, since `AcpError::Serialize` flattens to a bare
|
||||
// message string and does not expose the error `code` for
|
||||
// synchronous Tauri command rejections.
|
||||
if (message.includes("is not installed")) {
|
||||
pushAlertRef.current(
|
||||
"error",
|
||||
t("blocked.sdkMissing", { agent: agentLabel }),
|
||||
t("agentsSetupHint"),
|
||||
[buildOpenAgentsSettingsAction(agentType)]
|
||||
)
|
||||
} else {
|
||||
pushAlertRef.current(
|
||||
"error",
|
||||
t("connectFailedTitle", { agent: agentLabel }),
|
||||
message
|
||||
)
|
||||
}
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
@@ -1987,7 +2027,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
consumeBufferedEvents,
|
||||
dispatch,
|
||||
handleMappedEvent,
|
||||
resolveAutoLinkBlockState,
|
||||
resolveConnectBlockState,
|
||||
t,
|
||||
waitForListenerReady,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user