mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(tui): preserve draft scope until first message
This commit is contained in:
@@ -1217,7 +1217,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if session_mentions:
|
if session_mentions:
|
||||||
metadata["session_mentions"] = session_mentions
|
metadata["session_mentions"] = session_mentions
|
||||||
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
||||||
self._workspaces.persist_scope(cid, scope)
|
|
||||||
is_webui = metadata.get("webui") is True
|
is_webui = metadata.get("webui") is True
|
||||||
queued_owner = None
|
queued_owner = None
|
||||||
if is_webui and not is_user_shell and builtin_command_starts_agent_turn(content):
|
if is_webui and not is_user_shell and builtin_command_starts_agent_turn(content):
|
||||||
@@ -1272,6 +1271,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
else False
|
else False
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self._workspaces.persist_scope(cid, scope)
|
||||||
accepted = True
|
accepted = True
|
||||||
finally:
|
finally:
|
||||||
if not accepted and queued_owner is not None:
|
if not accepted and queued_owner is not None:
|
||||||
|
|||||||
@@ -1563,6 +1563,49 @@ async def test_new_chat_without_message_does_not_create_session(
|
|||||||
assert sessions.list_sessions() == []
|
assert sessions.list_sessions() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_first_message_does_not_persist_draft_session(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
sessions = SessionManager(tmp_path / "sessions")
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||||
|
)
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.remote_address = ("127.0.0.1", 50123)
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"tui-client",
|
||||||
|
{
|
||||||
|
"type": "new_chat",
|
||||||
|
"workspace_scope": {
|
||||||
|
"project_path": str(tmp_path),
|
||||||
|
"access_mode": "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
chat_id = json.loads(conn.send.await_args_list[0].args[0])["chat_id"]
|
||||||
|
bus.publish_inbound.side_effect = RuntimeError("queue unavailable")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="queue unavailable"):
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"tui-client",
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"content": "hello",
|
||||||
|
"webui": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sessions.list_sessions() == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_workspace_scope_change_invalidates_other_attached_clients(
|
async def test_workspace_scope_change_invalidates_other_attached_clients(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
|
|||||||
@@ -336,12 +336,12 @@ class WebUIWorkspaceController:
|
|||||||
|
|
||||||
def persist_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
|
def persist_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
|
||||||
session_key = f"websocket:{chat_id}"
|
session_key = f"websocket:{chat_id}"
|
||||||
self._draft_scopes.pop(session_key, None)
|
|
||||||
if self._sessions is not None:
|
if self._sessions is not None:
|
||||||
session = self._sessions.get_or_create(session_key)
|
session = self._sessions.get_or_create(session_key)
|
||||||
session.metadata["webui"] = True
|
session.metadata["webui"] = True
|
||||||
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
||||||
self._sessions.save(session)
|
self._sessions.save(session)
|
||||||
|
self._draft_scopes.pop(session_key, None)
|
||||||
|
|
||||||
def stage_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
|
def stage_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
|
||||||
"""Keep a new chat's scope transient until its first accepted message."""
|
"""Keep a new chat's scope transient until its first accepted message."""
|
||||||
|
|||||||
@@ -356,14 +356,34 @@ describe("gateway protocol", () => {
|
|||||||
socket.emit("message", {
|
socket.emit("message", {
|
||||||
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
|
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
|
||||||
})
|
})
|
||||||
|
socket.emit("message", {
|
||||||
|
data: JSON.stringify({ event: "attached", chat_id: "draft-chat" }),
|
||||||
|
})
|
||||||
|
// A gateway restart re-sends ready while the TUI keeps the draft chat alive.
|
||||||
|
socket.emit("message", {
|
||||||
|
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
|
||||||
|
})
|
||||||
|
client.send("hello")
|
||||||
|
|
||||||
expect(socket.sent.map((value) => JSON.parse(value))).toEqual([{
|
expect(socket.sent.map((value) => JSON.parse(value))).toEqual([
|
||||||
type: "new_chat",
|
{
|
||||||
workspace_scope: {
|
type: "new_chat",
|
||||||
project_path: "/tmp/launch-project",
|
workspace_scope: {
|
||||||
access_mode: "restricted",
|
project_path: "/tmp/launch-project",
|
||||||
|
access_mode: "restricted",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}])
|
{ type: "attach", chat_id: "draft-chat" },
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "message",
|
||||||
|
chat_id: "draft-chat",
|
||||||
|
content: "hello",
|
||||||
|
workspace_scope: {
|
||||||
|
project_path: "/tmp/launch-project",
|
||||||
|
access_mode: "restricted",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ type OutboundEvent =
|
|||||||
content: string
|
content: string
|
||||||
turn_id: string
|
turn_id: string
|
||||||
webui: true
|
webui: true
|
||||||
|
workspace_scope?: WorkspaceScopePayload
|
||||||
cli_apps?: Array<{ name: string }>
|
cli_apps?: Array<{ name: string }>
|
||||||
mcp_presets?: Array<{ name: string }>
|
mcp_presets?: Array<{ name: string }>
|
||||||
session_mentions?: SessionMention[]
|
session_mentions?: SessionMention[]
|
||||||
@@ -903,6 +904,7 @@ export async function fetchGatewayConnection(
|
|||||||
export class NanobotClient {
|
export class NanobotClient {
|
||||||
private socket: WebSocket | null = null
|
private socket: WebSocket | null = null
|
||||||
private chatId = ""
|
private chatId = ""
|
||||||
|
private workspaceScope?: WorkspaceScopePayload
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private reconnectAttempt = 0
|
private reconnectAttempt = 0
|
||||||
private closedByClient = false
|
private closedByClient = false
|
||||||
@@ -1002,6 +1004,7 @@ export class NanobotClient {
|
|||||||
content,
|
content,
|
||||||
turn_id: turnId,
|
turn_id: turnId,
|
||||||
webui: true,
|
webui: true,
|
||||||
|
...(this.workspaceScope ? { workspace_scope: this.workspaceScope } : {}),
|
||||||
...(options.userShell ? { user_shell: true } : {}),
|
...(options.userShell ? { user_shell: true } : {}),
|
||||||
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
||||||
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
||||||
@@ -1014,10 +1017,12 @@ export class NanobotClient {
|
|||||||
|
|
||||||
attach(chatId: string): void {
|
attach(chatId: string): void {
|
||||||
if (!chatId) throw new Error("chat id is required")
|
if (!chatId) throw new Error("chat id is required")
|
||||||
|
this.workspaceScope = undefined
|
||||||
this.write({ type: "attach", chat_id: chatId })
|
this.write({ type: "attach", chat_id: chatId })
|
||||||
}
|
}
|
||||||
|
|
||||||
newChat(scope?: WorkspaceScopePayload): void {
|
newChat(scope?: WorkspaceScopePayload): void {
|
||||||
|
this.workspaceScope = scope
|
||||||
this.write({ type: "new_chat", ...(scope ? { workspace_scope: scope } : {}) })
|
this.write({ type: "new_chat", ...(scope ? { workspace_scope: scope } : {}) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,6 +1037,7 @@ export class NanobotClient {
|
|||||||
|
|
||||||
setWorkspaceScope(scope: WorkspaceScopePayload): void {
|
setWorkspaceScope(scope: WorkspaceScopePayload): void {
|
||||||
if (!this.chatId) throw new Error("chat is not ready")
|
if (!this.chatId) throw new Error("chat is not ready")
|
||||||
|
this.workspaceScope = scope
|
||||||
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
|
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,6 +1137,8 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
} else if (event.event === "attached") {
|
} else if (event.event === "attached") {
|
||||||
this.chatId = event.chat_id
|
this.chatId = event.chat_id
|
||||||
|
} else if (event.event === "session_updated" && event.workspace_scope) {
|
||||||
|
this.workspaceScope = event.workspace_scope
|
||||||
}
|
}
|
||||||
this.options.onEvent(event)
|
this.options.onEvent(event)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user