mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
fix(tui): synchronize shared session clients
This commit is contained in:
@@ -709,6 +709,37 @@ export function useNanobotStream(
|
||||
setMessages((prev) => transitionTurnDelivery(prev, turnId, "accepted"));
|
||||
}
|
||||
if (ev.event === "message_accepted") return;
|
||||
if (ev.event === "user_message") {
|
||||
setMessages((prev) => {
|
||||
if (ev.turn_id && prev.some((message) => (
|
||||
message.role === "user" && message.turnId === ev.turn_id
|
||||
))) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: ev.text,
|
||||
...(ev.turn_id ? { turnId: ev.turn_id } : {}),
|
||||
turnPhase: "user",
|
||||
turnSeq: 0,
|
||||
deliveryStatus: "accepted",
|
||||
createdAt: Date.now(),
|
||||
...(ev.media_urls?.length ? { media: ev.media_urls } : {}),
|
||||
...(ev.cli_apps?.length ? { cliApps: ev.cli_apps } : {}),
|
||||
...(ev.mcp_presets?.length ? { mcpPresets: ev.mcp_presets } : {}),
|
||||
...(ev.session_mentions?.length
|
||||
? { sessionMentions: ev.session_mentions }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (ev.active_turn_id || ev.starts_turn) {
|
||||
setIsStreaming(true);
|
||||
if (typeof ev.started_at === "number") setRunStartedAt(ev.started_at);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sideChannelEvent = isSideChannelEvent(ev);
|
||||
if (
|
||||
streamEndTimerRef.current !== null
|
||||
|
||||
@@ -589,6 +589,34 @@ export class NanobotClient {
|
||||
pending.state = "accepted";
|
||||
}
|
||||
|
||||
private recordCanonicalTurnOwnership(
|
||||
ev: Extract<InboundEvent, { event: "message_accepted" | "user_message" }>,
|
||||
): void {
|
||||
const activeTurnId = ev.active_turn_id;
|
||||
if (!activeTurnId) return;
|
||||
|
||||
// Two clients can optimistically submit while the chat still looks idle.
|
||||
// The gateway admits exactly one owner and classifies the other message as
|
||||
// steering. Replace the local guess before its ACK can preserve the wrong
|
||||
// run identity.
|
||||
if (ev.turn_id && ev.turn_id !== activeTurnId) {
|
||||
const pending = this.pendingMessageSends.get(this.runSendKey(ev.chat_id, ev.turn_id));
|
||||
if (pending?.startsNewRun) this.settleRunTurn(ev.chat_id, ev.turn_id);
|
||||
}
|
||||
if (this.latestRunTurnIdByChatId.get(ev.chat_id) !== activeTurnId) {
|
||||
this.advanceRunGeneration(ev.chat_id, activeTurnId);
|
||||
}
|
||||
if (typeof ev.started_at === "number") {
|
||||
this.runStartedAtByTurnKey.set(
|
||||
this.runSendKey(ev.chat_id, activeTurnId),
|
||||
ev.started_at,
|
||||
);
|
||||
const previous = this.runStartedAtByChatId.get(ev.chat_id);
|
||||
this.runStartedAtByChatId.set(ev.chat_id, ev.started_at);
|
||||
if (previous !== ev.started_at) this.emitRunStatus(ev.chat_id, ev.started_at);
|
||||
}
|
||||
}
|
||||
|
||||
private recordRunRejection(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
const rejectedLatest = this.latestRunTurnIdByChatId.get(chatId) === turnId;
|
||||
@@ -1097,6 +1125,9 @@ export class NanobotClient {
|
||||
const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string"
|
||||
? parsed.turn_id
|
||||
: null;
|
||||
if (parsed.event === "message_accepted" || parsed.event === "user_message") {
|
||||
this.recordCanonicalTurnOwnership(parsed);
|
||||
}
|
||||
if (parsed.event === "message_accepted") {
|
||||
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
|
||||
if (!isSystemCommandTurnId(turnId)) {
|
||||
|
||||
+21
-1
@@ -1207,7 +1207,27 @@ export interface InboundTurnMetadata {
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string; temporary?: boolean }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| {
|
||||
event: "message_accepted";
|
||||
chat_id: string;
|
||||
turn_id: string;
|
||||
starts_turn?: boolean;
|
||||
active_turn_id?: string;
|
||||
started_at?: number;
|
||||
}
|
||||
| {
|
||||
event: "user_message";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
turn_id?: string;
|
||||
active_turn_id?: string;
|
||||
starts_turn: boolean;
|
||||
started_at?: number;
|
||||
media_urls?: UIMediaAttachment[];
|
||||
cli_apps?: UICliAppAttachment[];
|
||||
mcp_presets?: UIMcpPresetAttachment[];
|
||||
session_mentions?: SessionMention[];
|
||||
}
|
||||
| ({
|
||||
event: "message";
|
||||
chat_id: string;
|
||||
|
||||
@@ -71,6 +71,35 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("reconciles simultaneous client submissions to the gateway-owned turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
client.sendMessage("shared-chat", "from terminal B", undefined, {
|
||||
turnId: "turn-b",
|
||||
});
|
||||
expect(client.getRunTurnId("shared-chat")).toBe("turn-b");
|
||||
|
||||
socket.fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "shared-chat",
|
||||
turn_id: "turn-b",
|
||||
active_turn_id: "turn-a",
|
||||
starts_turn: false,
|
||||
started_at: 1_700_000_000,
|
||||
});
|
||||
|
||||
expect(client.getRunTurnId("shared-chat")).toBe("turn-a");
|
||||
expect(client.getRunStartedAt("shared-chat")).toBe(1_700_000_000);
|
||||
expect(client.hasUnsettledRun("shared-chat")).toBe(true);
|
||||
});
|
||||
|
||||
it("correlates successful WebUI mutation replies by request id", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -1869,6 +1869,56 @@ describe("useNanobotStream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects a user turn submitted from another attached client exactly once", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-shared", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
const event: InboundEvent = {
|
||||
event: "user_message",
|
||||
chat_id: "chat-shared",
|
||||
text: "hello from terminal A",
|
||||
turn_id: "remote-turn",
|
||||
active_turn_id: "remote-turn",
|
||||
starts_turn: true,
|
||||
started_at: 1_700_000_000,
|
||||
media_urls: [{ kind: "file", url: "/api/media/sig/report", name: "report.pdf" }],
|
||||
cli_apps: [{ name: "drawio", display_name: "Draw.io" }],
|
||||
mcp_presets: [{ name: "github", display_name: "GitHub" }],
|
||||
session_mentions: [{
|
||||
name: "pricing",
|
||||
session_key: "websocket:pricing",
|
||||
title: "Pricing",
|
||||
}],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-shared", event);
|
||||
fake.emit("chat-shared", event);
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "hello from terminal A",
|
||||
turnId: "remote-turn",
|
||||
deliveryStatus: "accepted",
|
||||
createdAt: expect.any(Number),
|
||||
media: [{ kind: "file", url: "/api/media/sig/report", name: "report.pdf" }],
|
||||
cliApps: [{ name: "drawio", display_name: "Draw.io" }],
|
||||
mcpPresets: [{ name: "github", display_name: "GitHub" }],
|
||||
sessionMentions: [{
|
||||
name: "pricing",
|
||||
session_key: "websocket:pricing",
|
||||
title: "Pricing",
|
||||
}],
|
||||
}),
|
||||
]);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.runStartedAt).toBe(1_700_000_000);
|
||||
});
|
||||
|
||||
it("marks only the optimistic turn named by a correlated rejection as failed", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
|
||||
Reference in New Issue
Block a user