Skip to content

Commit c21bb51

Browse files
committed
Fix Hermes cron delivery and rename errors
1 parent dadeaf8 commit c21bb51

6 files changed

Lines changed: 149 additions & 17 deletions

File tree

apps/web/src/components/Sidebar.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra
7575
import { isElectron } from "../env";
7676
import { useOpenPrLink } from "../lib/openPullRequestLink";
7777
import { isTerminalFocused } from "../lib/terminalFocus";
78+
import { describeThreadRenameFailure } from "../lib/threadRenameFailure";
7879
import { isMacPlatform } from "../lib/utils";
7980
import {
8081
readThreadShell,
@@ -2059,18 +2060,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
20592060
finishRename();
20602061
return;
20612062
}
2063+
finishRename();
20622064
const result = await renameThreadTitle(threadRef, trimmed);
2063-
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2064-
const error = squashAtomCommandFailure(result);
2065+
const failureMessage = describeThreadRenameFailure(result);
2066+
if (failureMessage) {
20652067
toastManager.add(
20662068
stackedThreadToast({
20672069
type: "error",
2068-
title: "Failed to rename thread",
2069-
description: error instanceof Error ? error.message : "An error occurred.",
2070+
...failureMessage,
20702071
}),
20712072
);
20722073
}
2073-
finishRename();
20742074
},
20752075
[renameThreadTitle],
20762076
);

apps/web/src/components/SidebarV2.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
import { useShortcutModifierState } from "../shortcutModifierState";
5757
import { isTerminalFocused } from "../lib/terminalFocus";
5858
import { isModelPickerOpen } from "../modelPickerVisibility";
59+
import { describeThreadRenameFailure } from "../lib/threadRenameFailure";
5960
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
6061
import { isMacPlatform } from "~/lib/utils";
6162
import { useOpenPrLink } from "../lib/openPullRequestLink";
@@ -1343,13 +1344,12 @@ export default function SidebarV2() {
13431344
}
13441345
if (trimmed === originalTitle) return;
13451346
const result = await renameThreadTitle(threadRef, trimmed);
1346-
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1347-
const error = squashAtomCommandFailure(result);
1347+
const failureMessage = describeThreadRenameFailure(result);
1348+
if (failureMessage) {
13481349
toastManager.add(
13491350
stackedThreadToast({
13501351
type: "error",
1351-
title: "Failed to rename thread",
1352-
description: error instanceof Error ? error.message : "An error occurred.",
1352+
...failureMessage,
13531353
}),
13541354
);
13551355
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import * as Cause from "effect/Cause";
3+
import { AsyncResult } from "effect/unstable/reactivity";
4+
5+
import { describeThreadRenameFailure } from "./threadRenameFailure";
6+
7+
describe("describeThreadRenameFailure", () => {
8+
it("explains an interrupted Hermes rename instead of hiding it", () => {
9+
const message = describeThreadRenameFailure(AsyncResult.failure(Cause.interrupt()));
10+
11+
expect(message).toEqual({
12+
title: "Thread rename interrupted",
13+
description: "The connection changed before Hermes confirmed the new title. Try again.",
14+
});
15+
});
16+
17+
it("preserves an ordinary rename failure message", () => {
18+
const message = describeThreadRenameFailure(
19+
AsyncResult.failure(Cause.fail(new Error("Hermes rejected the title"))),
20+
);
21+
22+
expect(message).toEqual({
23+
title: "Failed to rename thread",
24+
description: "Hermes rejected the title",
25+
});
26+
});
27+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
2+
import {
3+
isAtomCommandInterrupted,
4+
squashAtomCommandFailure,
5+
} from "@t3tools/client-runtime/state/runtime";
6+
7+
export interface ThreadRenameFailureMessage {
8+
readonly title: string;
9+
readonly description: string;
10+
}
11+
12+
export function describeThreadRenameFailure(
13+
result: AtomCommandResult<unknown, unknown>,
14+
): ThreadRenameFailureMessage | null {
15+
if (result._tag !== "Failure") {
16+
return null;
17+
}
18+
if (isAtomCommandInterrupted(result)) {
19+
return {
20+
title: "Thread rename interrupted",
21+
description: "The connection changed before Hermes confirmed the new title. Try again.",
22+
};
23+
}
24+
const error = squashAtomCommandFailure(result);
25+
return {
26+
title: "Failed to rename thread",
27+
description: error instanceof Error ? error.message : "An error occurred.",
28+
};
29+
}

integrations/hermes/t3agent/adapter.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,23 +2199,41 @@ async def _post_event(
21992199
"Content-Type": "application/json",
22002200
"Idempotency-Key": request_id,
22012201
}
2202-
active_client = client or self._client
2203-
if active_client is None:
2202+
configured_client = client or self._client
2203+
if configured_client is None or configured_client.closed:
22042204
return False, {}, "T3 Agent bridge is not connected"
2205-
try:
2205+
2206+
async def post_event(
2207+
active_client: ClientSession,
2208+
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
22062209
async with active_client.post(target, json=frame, headers=headers) as response:
22072210
if not 200 <= response.status < 300:
2208-
return False, {}, f"T3 Agent bridge returned HTTP {response.status}"
2211+
return None, f"T3 Agent bridge returned HTTP {response.status}"
22092212
try:
2210-
body = await response.json()
2213+
response_body = await response.json()
22112214
except Exception:
2212-
return False, {}, "T3 Agent bridge returned invalid JSON"
2215+
return None, "T3 Agent bridge returned invalid JSON"
2216+
if not isinstance(response_body, dict):
2217+
return None, "T3 Agent bridge returned invalid JSON"
2218+
return response_body, None
2219+
2220+
try:
2221+
client_loop = getattr(configured_client, "_loop", None)
2222+
if client_loop is asyncio.get_running_loop():
2223+
body, request_error = await post_event(configured_client)
2224+
else:
2225+
# Hermes cron delivery can call the connected adapter from its
2226+
# scheduler loop. aiohttp sessions are bound to the loop where
2227+
# they were created, so use a request-local session here.
2228+
timeout = ClientTimeout(total=self.timeout_seconds)
2229+
async with ClientSession(timeout=timeout) as active_client:
2230+
body, request_error = await post_event(active_client)
22132231
except asyncio.CancelledError:
22142232
raise
22152233
except (ClientError, asyncio.TimeoutError):
22162234
return False, {}, "T3 Agent bridge request failed"
2217-
if not isinstance(body, dict):
2218-
return False, {}, "T3 Agent bridge returned invalid JSON"
2235+
if body is None:
2236+
return False, {}, request_error or "T3 Agent bridge request failed"
22192237
if body.get("protocolVersion") != PROTOCOL_VERSION:
22202238
return False, body, "T3 Agent bridge returned a mismatched protocol version"
22212239
if body.get("requestId") != request_id:

integrations/hermes/t3agent/tests/test_adapter.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import json
55
import os
6+
import threading
67
from types import SimpleNamespace
78
from typing import Any, Dict, Iterator, List
89

@@ -1147,6 +1148,63 @@ async def receive(request: web.Request) -> web.Response:
11471148
await server.close()
11481149

11491150

1151+
@pytest.mark.asyncio
1152+
async def test_send_uses_current_loop_when_connected_client_belongs_to_gateway_loop(
1153+
fake_platform: SimpleNamespace,
1154+
) -> None:
1155+
received: List[Dict[str, Any]] = []
1156+
1157+
async def receive(request: web.Request) -> web.Response:
1158+
frame = await request.json()
1159+
received.append(frame)
1160+
return web.json_response(
1161+
{
1162+
"protocolVersion": 1,
1163+
"requestId": frame["requestId"],
1164+
"deliveryId": frame["deliveryId"],
1165+
"status": "accepted",
1166+
}
1167+
)
1168+
1169+
app = web.Application()
1170+
app.router.add_post("/api/hermes/hermes-test/events", receive)
1171+
server = TestServer(app)
1172+
await server.start_server()
1173+
1174+
gateway_loop = asyncio.new_event_loop()
1175+
gateway_thread = threading.Thread(target=gateway_loop.run_forever)
1176+
gateway_thread.start()
1177+
1178+
async def create_gateway_client() -> ClientSession:
1179+
return ClientSession()
1180+
1181+
gateway_client = asyncio.run_coroutine_threadsafe(
1182+
create_gateway_client(), gateway_loop
1183+
).result()
1184+
adapter = adapter_module.T3AgentAdapter(
1185+
make_config(bridge_url=str(server.make_url("/")))
1186+
)
1187+
adapter.bridge_url = adapter.bridge_url.rstrip("/")
1188+
adapter._client = gateway_client
1189+
try:
1190+
result = await adapter.send(
1191+
"t3agent",
1192+
"scheduled result",
1193+
metadata={"threadId": "thread-1", "final": True},
1194+
)
1195+
1196+
assert result.success is True
1197+
assert received[0]["content"] == "scheduled result"
1198+
assert received[0]["threadId"] == "thread-1"
1199+
finally:
1200+
asyncio.run_coroutine_threadsafe(gateway_client.close(), gateway_loop).result()
1201+
adapter._client = None
1202+
gateway_loop.call_soon_threadsafe(gateway_loop.stop)
1203+
gateway_thread.join()
1204+
gateway_loop.close()
1205+
await server.close()
1206+
1207+
11501208
@pytest.mark.asyncio
11511209
async def test_stream_metadata_marks_preview_then_routes_final_edit(
11521210
fake_platform: SimpleNamespace,

0 commit comments

Comments
 (0)