Skip to content

Commit ed7698d

Browse files
committed
fix(unity): wait for bridge readiness after recompile
1 parent 95b4646 commit ed7698d

8 files changed

Lines changed: 139 additions & 22 deletions

File tree

src-tauri/src/unity_bridge/mod.rs

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,6 +1347,56 @@ pub async fn unity_execute_code(project_path: &str, code: &str) -> Result<String
13471347
unity_execute_code_with_progress(project_path, code, |_| {}).await
13481348
}
13491349

1350+
async fn wait_for_unity_bridge_ready_after_recompile(project_path: &str) -> Result<(), String> {
1351+
let max_wait = Duration::from_secs(30);
1352+
let start = std::time::Instant::now();
1353+
1354+
loop {
1355+
let detail =
1356+
match send_message_with_timeout(project_path, "status", "", Duration::from_secs(5))
1357+
.await
1358+
{
1359+
Ok(resp) if resp.ok => return Ok(()),
1360+
Ok(resp) => resp
1361+
.error
1362+
.unwrap_or_else(|| "Unity status returned ok=false".to_string()),
1363+
Err(error) => error,
1364+
};
1365+
1366+
if start.elapsed() > max_wait {
1367+
return Err(format!(
1368+
"Timed out waiting for Unity bridge to become ready after recompile (30s): {}",
1369+
detail
1370+
));
1371+
}
1372+
1373+
tokio::time::sleep(Duration::from_millis(500)).await;
1374+
}
1375+
}
1376+
1377+
async fn refresh_unity_type_index_after_recompile(project_path: &str) -> Result<(), String> {
1378+
const MAX_ATTEMPTS: u32 = 3;
1379+
let mut last_error = String::new();
1380+
1381+
for attempt in 1..=MAX_ATTEMPTS {
1382+
match refresh_unity_type_index(project_path).await {
1383+
Ok(_) => return Ok(()),
1384+
Err(error) => {
1385+
last_error = error;
1386+
eprintln!(
1387+
"[Locus] Unity type index refresh after recompile attempt {}/{} failed: {}",
1388+
attempt, MAX_ATTEMPTS, last_error
1389+
);
1390+
if attempt < MAX_ATTEMPTS {
1391+
tokio::time::sleep(Duration::from_secs(1)).await;
1392+
}
1393+
}
1394+
}
1395+
}
1396+
1397+
Err(last_error)
1398+
}
1399+
13501400
/// Trigger a Unity recompile and wait until the new domain is ready.
13511401
///
13521402
/// Flow:
@@ -1376,7 +1426,10 @@ pub async fn recompile_and_wait(project_path: &str) -> Result<String, String> {
13761426
);
13771427
}
13781428

1379-
let resp = send_message(project_path, "request_recompile", "").await?;
1429+
let resp = match send_message(project_path, "request_recompile", "").await {
1430+
Ok(resp) => resp,
1431+
Err(error) => return finish(Err(error)),
1432+
};
13801433
if !resp.ok {
13811434
return finish(Err(resp
13821435
.error
@@ -1400,12 +1453,13 @@ pub async fn recompile_and_wait(project_path: &str) -> Result<String, String> {
14001453
Ok(resp) if resp.ok => {
14011454
eprintln!("[Locus] Unity reconnected after domain reload");
14021455
crate::unity_type_index::invalidate_cached_type_index(project_path).await;
1403-
transport::disconnect_with_reason(
1404-
project_path,
1405-
"Unity reconnected after domain reload",
1406-
)
1407-
.await;
1408-
if let Err(error) = refresh_unity_type_index(project_path).await {
1456+
if let Err(error) =
1457+
wait_for_unity_bridge_ready_after_recompile(project_path).await
1458+
{
1459+
return finish(Err(error));
1460+
}
1461+
if let Err(error) = refresh_unity_type_index_after_recompile(project_path).await
1462+
{
14091463
eprintln!(
14101464
"[Locus] Unity type index refresh after recompile skipped: {}",
14111465
error
@@ -1429,12 +1483,14 @@ pub async fn recompile_and_wait(project_path: &str) -> Result<String, String> {
14291483
"ok" => {
14301484
crate::unity_type_index::invalidate_cached_type_index(project_path)
14311485
.await;
1432-
transport::disconnect_with_reason(
1433-
project_path,
1434-
"Unity recompile completed",
1435-
)
1436-
.await;
1437-
if let Err(error) = refresh_unity_type_index(project_path).await {
1486+
if let Err(error) =
1487+
wait_for_unity_bridge_ready_after_recompile(project_path).await
1488+
{
1489+
return finish(Err(error));
1490+
}
1491+
if let Err(error) =
1492+
refresh_unity_type_index_after_recompile(project_path).await
1493+
{
14381494
eprintln!(
14391495
"[Locus] Unity type index refresh after recompile skipped: {}",
14401496
error

src-tauri/src/unity_bridge/transport.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,13 @@ mod windows_impl {
170170
}
171171
};
172172

173-
if let Some(reply_to) = env.reply_to.clone() {
173+
let reply_to = env
174+
.reply_to
175+
.clone()
176+
.map(|value| value.trim().to_string())
177+
.filter(|value| !value.is_empty());
178+
179+
if let Some(reply_to) = reply_to {
174180
let tx = {
175181
let mut pending = conn.pending.lock().await;
176182
pending.remove(&reply_to)

src/__tests__/chatStatusIndicators.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe("chat status indicators", () => {
2525
expect(chatView).toContain('@launch-unity-project="emit(\'launchUnityProject\')"');
2626
expect(chatView).toContain(':unity-launching="unityLaunching"');
2727
expect(chatView).toContain(':unity-launch-state="unityLaunchState"');
28+
expect(chatView).toContain(':unity-recompiling="unityRecompileActive"');
2829
expect(chatView).toContain("workingDir?: string;");
2930
expect(chatView).toContain(':working-dir="workingDir"');
3031
expect(workspace).toContain(':working-dir="projectStore.workingDir"');
@@ -99,8 +100,11 @@ describe("chat status indicators", () => {
99100
expect(indicators).toContain("unityLaunching?: boolean;");
100101
expect(indicators).toContain('type UnityLaunchState = "idle" | "starting" | "waitingConnection";');
101102
expect(indicators).toContain("unityLaunchState?: UnityLaunchState;");
103+
expect(indicators).toContain("unityRecompiling?: boolean;");
102104
expect(indicators).toContain('const unityCanLaunch = computed(() =>');
103-
expect(indicators).toContain('!props.unityConnected && !props.unityPluginStatus');
105+
expect(indicators).toContain('&& !props.unityConnected');
106+
expect(indicators).toContain('&& !props.unityPluginStatus');
107+
expect(indicators).toContain('&& !unityRecompileWaitingConnection.value');
104108
expect(indicators).toContain('const effectiveUnityLaunchState = computed<UnityLaunchState>(() =>');
105109
expect(indicators).toContain('if (effectiveUnityLaunchState.value === "starting") return t("chat.unity.launching");');
106110
expect(indicators).toContain('return t("chat.status.unity.waitingConnection");');
@@ -119,4 +123,20 @@ describe("chat status indicators", () => {
119123
expect(zh).toContain('"chat.status.unity.waitingConnection": "等待连接"');
120124
expect(zh).toContain('"chat.status.unity.launchTitle": "启动 Unity 项目"');
121125
});
126+
127+
it("shows Unity recompile reconnect waits as the accent connection state", () => {
128+
const chatView = read("src/components/ChatView.vue");
129+
const indicators = read("src/components/chat/ChatStatusIndicators.vue");
130+
const zh = read("src/language/zh.json");
131+
const en = read("src/language/en.json");
132+
133+
expect(chatView).toContain("function hasRunningUnityRecompile(calls: ToolCallDisplay[] | undefined): boolean");
134+
expect(chatView).toContain('call.name === "unity_recompile" && call.status === "running"');
135+
expect(chatView).toContain("const unityRecompileActive = computed(() => hasRunningUnityRecompile(props.activeToolCalls));");
136+
expect(indicators).toContain("const unityRecompileWaitingConnection = computed(() =>");
137+
expect(indicators).toContain('if (unityRecompileWaitingConnection.value) return t("chat.unity.waitingRecompileConnection");');
138+
expect(indicators).toContain('unityRecompileWaitingConnection.value || effectiveUnityLaunchState.value !== "idle"');
139+
expect(zh).toContain('"chat.unity.waitingRecompileConnection": "Unity 重编译中,等待重连"');
140+
expect(en).toContain('"chat.unity.waitingRecompileConnection": "Unity recompiling, waiting for reconnect"');
141+
});
122142
});

src/__tests__/unityBridgeCompatibility.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,15 @@ describe("unityBridgeCompatibility", () => {
2020
expect(bridge).toContain("server.WaitForConnection();");
2121
expect(bridge).toContain("ct.Register(delegate");
2222
});
23+
24+
it("keeps the Unity bridge connection stable after recompilation", () => {
25+
const bridge = read("src-tauri/src/unity_bridge/mod.rs");
26+
const transport = read("src-tauri/src/unity_bridge/transport.rs");
27+
28+
expect(bridge).toContain("wait_for_unity_bridge_ready_after_recompile");
29+
expect(bridge).toContain("refresh_unity_type_index_after_recompile");
30+
expect(bridge).toContain("Unity reconnected after domain reload");
31+
expect(bridge).not.toContain("Unity recompile completed");
32+
expect(transport).toContain(".filter(|value| !value.is_empty())");
33+
});
2334
});

src/components/ChatView.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ const props = defineProps<{
187187
sessionPanelStorageScope?: string;
188188
}>();
189189
190+
function hasRunningUnityRecompile(calls: ToolCallDisplay[] | undefined): boolean {
191+
return !!calls?.some((call) =>
192+
(call.name === "unity_recompile" && call.status === "running")
193+
|| hasRunningUnityRecompile(call.nestedToolCalls),
194+
);
195+
}
196+
197+
const unityRecompileActive = computed(() => hasRunningUnityRecompile(props.activeToolCalls));
190198
191199
const emit = defineEmits<{
192200
send: [text: string, images: ImageAttachment[], assetRefs: AssetRefAttachment[], overrides?: { displayText?: string; mode?: string; userIntent?: UserIntentMeta | null }];
@@ -1484,6 +1492,7 @@ onUnmounted(() => {
14841492
:unity-plugin-installing="unityPluginInstalling"
14851493
:unity-launching="unityLaunching"
14861494
:unity-launch-state="unityLaunchState"
1495+
:unity-recompiling="unityRecompileActive"
14871496
:working-dir="workingDir"
14881497
:is-unity-project="isUnityProject"
14891498
:scan-phase="scanPhase"

src/components/chat/ChatStatusIndicators.vue

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const props = defineProps<{
4242
unityPluginInstalling?: boolean;
4343
unityLaunching?: boolean;
4444
unityLaunchState?: UnityLaunchState;
45+
unityRecompiling?: boolean;
4546
workingDir?: string;
4647
isUnityProject?: boolean;
4748
scanPhase?: AssetDbScanEvent | null;
@@ -124,8 +125,15 @@ const effectiveUnityLaunchState = computed<UnityLaunchState>(() => {
124125
return props.unityLaunching ? "starting" : "idle";
125126
});
126127
128+
const unityRecompileWaitingConnection = computed(() =>
129+
!!props.unityRecompiling
130+
&& !props.unityConnected
131+
&& !props.unityPluginStatus,
132+
);
133+
127134
const unitySummary = computed(() => {
128135
if (unityPluginLabel.value) return unityPluginLabel.value;
136+
if (unityRecompileWaitingConnection.value) return t("chat.unity.waitingRecompileConnection");
129137
if (effectiveUnityLaunchState.value === "starting") return t("chat.unity.launching");
130138
if (effectiveUnityLaunchState.value === "waitingConnection") return t("chat.unity.waitingConnection");
131139
return props.unityConnected ? t("chat.unity.connected") : t("chat.unity.disconnected");
@@ -136,13 +144,16 @@ const unityTone = computed<StatusTone>(() =>
136144
? "danger"
137145
: props.unityConnected
138146
? "success"
139-
: effectiveUnityLaunchState.value !== "idle"
147+
: unityRecompileWaitingConnection.value || effectiveUnityLaunchState.value !== "idle"
140148
? "accent"
141149
: "danger",
142150
);
143151
144152
const unityCanLaunch = computed(() =>
145-
!!props.isUnityProject && !props.unityConnected && !props.unityPluginStatus,
153+
!!props.isUnityProject
154+
&& !props.unityConnected
155+
&& !props.unityPluginStatus
156+
&& !unityRecompileWaitingConnection.value,
146157
);
147158
148159
const unityActionLabel = computed(() => {
@@ -282,7 +293,9 @@ const statusItems = computed<StatusItem[]>(() => [
282293
actionTitle: unityActionTitle.value,
283294
actionDisabled: props.unityPluginStatus
284295
? props.unityPluginInstalling
285-
: effectiveUnityLaunchState.value !== "idle" || !props.isUnityProject,
296+
: unityRecompileWaitingConnection.value
297+
|| effectiveUnityLaunchState.value !== "idle"
298+
|| !props.isUnityProject,
286299
actionVariant: props.unityPluginStatus ? "neutral" : "primary",
287300
},
288301
]);

src/language/en.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@
205205
"chat.unity.disconnected": "Unity Editor disconnected",
206206
"chat.unity.launching": "Unity Editor launching",
207207
"chat.unity.waitingConnection": "Unity Editor launched, waiting for connection",
208+
"chat.unity.waitingRecompileConnection": "Unity recompiling, waiting for reconnect",
208209
"chat.sceneObject.sceneNotLoaded": "Scene not loaded: {0}",
209210
"chat.sceneObject.objectMissing": "Object not found: {0}",
210211
"chat.sceneObject.openFailed": "Failed to open scene object: {0}",
@@ -672,8 +673,8 @@
672673
"settings.custom.reasoningOpenaiChat": "reasoning_effort",
673674
"settings.custom.reasoningOpenaiResponses": "reasoning.effort",
674675
"settings.custom.reasoningAnthropic": "Anthropic thinking",
675-
"settings.custom.replayReasoningContent": "Replay reasoning_content",
676-
"settings.custom.replayReasoningContentHint": "Include prior reasoning in multi-turn tool calls",
676+
"settings.custom.replayReasoningContent": "Replay reasoning",
677+
"settings.custom.replayReasoningContentHint": "Include historical reasoning in multi-turn tool calls",
677678
"settings.custom.betaFlags": "Beta Flags",
678679
"settings.custom.betaFlagsHint": "Anthropic beta headers to include in requests",
679680
"settings.custom.betaContext1m": "Enable 1M context window",

src/language/zh.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@
205205
"chat.unity.disconnected": "Unity编辑器未连接",
206206
"chat.unity.launching": "Unity编辑器启动中",
207207
"chat.unity.waitingConnection": "Unity编辑器已启动,等待连接",
208+
"chat.unity.waitingRecompileConnection": "Unity 重编译中,等待重连",
208209
"chat.sceneObject.sceneNotLoaded": "场景未加载:{0}",
209210
"chat.sceneObject.objectMissing": "对象不存在:{0}",
210211
"chat.sceneObject.openFailed": "无法打开场景对象:{0}",
@@ -672,8 +673,8 @@
672673
"settings.custom.reasoningOpenaiChat": "reasoning_effort",
673674
"settings.custom.reasoningOpenaiResponses": "reasoning.effort",
674675
"settings.custom.reasoningAnthropic": "Anthropic thinking",
675-
"settings.custom.replayReasoningContent": "回放 reasoning_content",
676-
"settings.custom.replayReasoningContentHint": "多轮工具调用时带回上一轮推理内容",
676+
"settings.custom.replayReasoningContent": "回放推理内容",
677+
"settings.custom.replayReasoningContentHint": "多轮工具调用时带回历史推理内容",
677678
"settings.custom.betaFlags": "Beta 标志",
678679
"settings.custom.betaFlagsHint": "请求中附带的 Anthropic beta 头",
679680
"settings.custom.betaContext1m": "启用 1M 上下文窗口",

0 commit comments

Comments
 (0)