fix(tui): preserve draft scope until first message

This commit is contained in:
Xubin Ren
2026-08-24 10:40:16 +08:00
parent d50a2fab32
commit baa0233377
5 changed files with 79 additions and 8 deletions
+1 -1
View File
@@ -1217,7 +1217,6 @@ class WebSocketChannel(BaseChannel):
if session_mentions:
metadata["session_mentions"] = session_mentions
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._workspaces.persist_scope(cid, scope)
is_webui = metadata.get("webui") is True
queued_owner = None
if is_webui and not is_user_shell and builtin_command_starts_agent_turn(content):
@@ -1272,6 +1271,7 @@ class WebSocketChannel(BaseChannel):
else False
),
)
self._workspaces.persist_scope(cid, scope)
accepted = True
finally:
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() == []
@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
async def test_workspace_scope_change_invalidates_other_attached_clients(
bus: MagicMock,
+1 -1
View File
@@ -336,12 +336,12 @@ class WebUIWorkspaceController:
def persist_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
session_key = f"websocket:{chat_id}"
self._draft_scopes.pop(session_key, None)
if self._sessions is not None:
session = self._sessions.get_or_create(session_key)
session.metadata["webui"] = True
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._sessions.save(session)
self._draft_scopes.pop(session_key, None)
def stage_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
"""Keep a new chat's scope transient until its first accepted message."""
+26 -6
View File
@@ -356,14 +356,34 @@ describe("gateway protocol", () => {
socket.emit("message", {
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([{
type: "new_chat",
workspace_scope: {
project_path: "/tmp/launch-project",
access_mode: "restricted",
expect(socket.sent.map((value) => JSON.parse(value))).toEqual([
{
type: "new_chat",
workspace_scope: {
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 {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
+8
View File
@@ -163,6 +163,7 @@ type OutboundEvent =
content: string
turn_id: string
webui: true
workspace_scope?: WorkspaceScopePayload
cli_apps?: Array<{ name: string }>
mcp_presets?: Array<{ name: string }>
session_mentions?: SessionMention[]
@@ -903,6 +904,7 @@ export async function fetchGatewayConnection(
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
private workspaceScope?: WorkspaceScopePayload
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0
private closedByClient = false
@@ -1002,6 +1004,7 @@ export class NanobotClient {
content,
turn_id: turnId,
webui: true,
...(this.workspaceScope ? { workspace_scope: this.workspaceScope } : {}),
...(options.userShell ? { user_shell: true } : {}),
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
@@ -1014,10 +1017,12 @@ export class NanobotClient {
attach(chatId: string): void {
if (!chatId) throw new Error("chat id is required")
this.workspaceScope = undefined
this.write({ type: "attach", chat_id: chatId })
}
newChat(scope?: WorkspaceScopePayload): void {
this.workspaceScope = scope
this.write({ type: "new_chat", ...(scope ? { workspace_scope: scope } : {}) })
}
@@ -1032,6 +1037,7 @@ export class NanobotClient {
setWorkspaceScope(scope: WorkspaceScopePayload): void {
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 })
}
@@ -1131,6 +1137,8 @@ export class NanobotClient {
}
} else if (event.event === "attached") {
this.chatId = event.chat_id
} else if (event.event === "session_updated" && event.workspace_scope) {
this.workspaceScope = event.workspace_scope
}
this.options.onEvent(event)
}