mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(tui): track canonical model presets
This commit is contained in:
@@ -52,6 +52,7 @@ from nanobot.security.workspace_access import (
|
||||
WorkspaceScopeError,
|
||||
)
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.model_selection import model_preset_from_metadata
|
||||
from nanobot.session.webui_turns import (
|
||||
clear_websocket_turn_if_current,
|
||||
clear_websocket_turns,
|
||||
@@ -433,6 +434,19 @@ class WebSocketChannel(BaseChannel):
|
||||
self._subs.setdefault(chat_id, set()).add(connection)
|
||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||
|
||||
def _attached_model_fields(self, chat_id: str) -> dict[str, str | None]:
|
||||
"""Expose the session's canonical preset on the attach handshake."""
|
||||
sessions = self.gateway.session_manager
|
||||
if sessions is None:
|
||||
return {}
|
||||
snapshot = sessions.read_session_metadata(f"websocket:{chat_id}")
|
||||
metadata = snapshot.get("metadata") if isinstance(snapshot, dict) else None
|
||||
try:
|
||||
return {"model_preset": model_preset_from_metadata(metadata)}
|
||||
except ValueError:
|
||||
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
||||
return {"model_preset": None}
|
||||
|
||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||
chats = self._conn_chats.get(connection)
|
||||
if chats is not None:
|
||||
@@ -478,7 +492,12 @@ class WebSocketChannel(BaseChannel):
|
||||
"""Attach and hydrate a newly created WebUI chat fork."""
|
||||
scope = self._workspaces.scope_for_session_key(fork_key)
|
||||
self._attach(connection, fork_id)
|
||||
await self._send_event(connection, "attached", chat_id=fork_id)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"attached",
|
||||
chat_id=fork_id,
|
||||
**self._attached_model_fields(fork_id),
|
||||
)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"session_updated",
|
||||
@@ -810,7 +829,12 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
self._workspaces.persist_scope(new_id, scope)
|
||||
self._attach(connection, new_id)
|
||||
await self._send_event(connection, "attached", chat_id=new_id)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"attached",
|
||||
chat_id=new_id,
|
||||
**self._attached_model_fields(new_id),
|
||||
)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"session_updated",
|
||||
@@ -861,7 +885,12 @@ class WebSocketChannel(BaseChannel):
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
self._attach(connection, cid)
|
||||
await self._send_event(connection, "attached", chat_id=cid)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"attached",
|
||||
chat_id=cid,
|
||||
**self._attached_model_fields(cid),
|
||||
)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
return
|
||||
if t == "set_sidebar_state":
|
||||
|
||||
@@ -45,6 +45,7 @@ from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOUR
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
from nanobot.webui.http_utils import (
|
||||
http_error as _http_error,
|
||||
@@ -262,6 +263,33 @@ async def _new_temporary_chat(
|
||||
return payload["chat_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_exposes_the_session_canonical_model_preset(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
session = sessions.get_or_create("websocket:pinned-model")
|
||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "Deep Research"
|
||||
sessions.save(session)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"tui-client",
|
||||
{"type": "attach", "chat_id": "pinned-model"},
|
||||
)
|
||||
|
||||
payload = json.loads(connection.send.await_args_list[0].args[0])
|
||||
assert payload == {
|
||||
"event": "attached",
|
||||
"chat_id": "pinned-model",
|
||||
"model_preset": "Deep Research",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
|
||||
@@ -70,6 +70,7 @@ def launch_tui(
|
||||
"NANOBOT_TUI_API_URL": lease.base_url,
|
||||
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
|
||||
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
||||
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
|
||||
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
|
||||
"NANOBOT_TUI_VERSION": __version__,
|
||||
"NANOBOT_TUI_ACCESS": (
|
||||
|
||||
@@ -17,8 +17,9 @@ from nanobot.cli.tui_launcher import (
|
||||
_resolve_source_tui_command,
|
||||
_resolve_tui_command,
|
||||
_websocket_chat_id,
|
||||
launch_tui,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
|
||||
|
||||
def test_authenticated_ws_url_preserves_existing_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -63,6 +64,55 @@ def test_default_tui_resumes_but_explicit_session_wins(tmp_path: Path) -> None:
|
||||
assert _initial_tui_chat_id("websocket:chosen", path) == "chosen"
|
||||
|
||||
|
||||
def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
|
||||
config.agents.defaults.model_preset = "Deep Research"
|
||||
closed: list[bool] = []
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._ensure_gateway",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
base_url="http://127.0.0.1:8765",
|
||||
close=lambda: closed.append(True),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._fetch_bootstrap",
|
||||
lambda *args, **kwargs: {
|
||||
"ws_url": "ws://127.0.0.1:8765/ws",
|
||||
"token": "socket-token",
|
||||
"api_token": "api-token",
|
||||
},
|
||||
)
|
||||
|
||||
def run(command: list[str], *, env: dict[str, str], check: bool) -> subprocess.CompletedProcess:
|
||||
assert command == ["nanobot-tui"]
|
||||
assert check is False
|
||||
captured.update(env)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", run)
|
||||
|
||||
result = launch_tui(
|
||||
config,
|
||||
config_path=tmp_path / "config.json",
|
||||
workspace_override=None,
|
||||
session_id=None,
|
||||
theme="auto",
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
|
||||
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_explicit_tui_binary_must_exist(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -14,6 +14,7 @@ const options: AppOptions = {
|
||||
apiUrl: "",
|
||||
apiToken: "",
|
||||
model: "test/model",
|
||||
modelPreset: "default",
|
||||
workspace: "/tmp/nanobot-workspace",
|
||||
version: "test",
|
||||
access: "workspace access",
|
||||
@@ -284,6 +285,7 @@ describe("NanobotTui layout", () => {
|
||||
title: "Release checklist",
|
||||
preview: "Prepare stable release",
|
||||
updated_at: "2026-08-12T10:00:00Z",
|
||||
model_preset: "Deep Research",
|
||||
},
|
||||
],
|
||||
})))) as unknown as typeof fetch
|
||||
@@ -302,6 +304,7 @@ describe("NanobotTui layout", () => {
|
||||
composer: TextareaRenderable
|
||||
sessionMenu: { visible: boolean }
|
||||
titleText: { plainText: string }
|
||||
modelText: { plainText: string }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -315,6 +318,8 @@ describe("NanobotTui layout", () => {
|
||||
await waitUntil(() => attached.length === 1)
|
||||
expect(attached).toEqual(["other"])
|
||||
expect(ui.titleText.plainText).toContain("Release checklist")
|
||||
expect(ui.modelText.plainText).toContain("Deep Research")
|
||||
expect(ui.modelText.plainText).not.toContain("test/model")
|
||||
|
||||
app.accept({ event: "attached", chat_id: "other" })
|
||||
await Bun.sleep(1)
|
||||
@@ -323,6 +328,105 @@ describe("NanobotTui layout", () => {
|
||||
await waitUntil(() => newChats.length === 1)
|
||||
expect(newChats).toEqual(["new"])
|
||||
expect(ui.titleText.plainText).toContain("New chat")
|
||||
expect(ui.modelText.plainText).toContain("test/model")
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
})
|
||||
|
||||
test("tracks canonical presets without overwriting a session override", async () => {
|
||||
setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
const ui = app as unknown as { modelText: { plainText: string } }
|
||||
|
||||
app.accept({ event: "attached", chat_id: "chat", model_preset: "Codex" })
|
||||
app.accept({
|
||||
event: "turn_model_updated",
|
||||
chat_id: "chat",
|
||||
model_name: "openai/gpt-5.6",
|
||||
model_preset: "Codex",
|
||||
})
|
||||
await setup.flush()
|
||||
expect(ui.modelText.plainText).toContain("Codex · openai/gpt-5.6")
|
||||
|
||||
app.accept({
|
||||
event: "runtime_model_updated",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
model_preset: "DeepSeek",
|
||||
})
|
||||
await setup.flush()
|
||||
expect(ui.modelText.plainText).toContain("Codex · openai/gpt-5.6")
|
||||
expect(ui.modelText.plainText).not.toContain("DeepSeek")
|
||||
})
|
||||
|
||||
test("returns a default-following chat to the canonical default preset", async () => {
|
||||
setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" })
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
{ ...options, model: "openai/gpt-5.6", modelPreset: "Codex" },
|
||||
client(),
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const ui = app as unknown as { modelText: { plainText: string } }
|
||||
|
||||
app.accept({ event: "attached", chat_id: "chat", model_preset: null })
|
||||
app.accept({
|
||||
event: "runtime_model_updated",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
model_preset: null,
|
||||
})
|
||||
await setup.flush()
|
||||
|
||||
expect(ui.modelText.plainText).toContain("deepseek/deepseek-chat")
|
||||
expect(ui.modelText.plainText).not.toContain("Codex")
|
||||
})
|
||||
|
||||
test("refreshes the canonical preset after the model command completes", async () => {
|
||||
setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" })
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = ((input: string | URL | Request) => {
|
||||
if (String(input).endsWith("/api/webui/sidebar-state")) {
|
||||
return Promise.resolve(new Response(JSON.stringify({})))
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
sessions: [{ key: "websocket:chat", model_preset: "Deep Research" }],
|
||||
})))
|
||||
}) as typeof fetch
|
||||
const sent: string[] = []
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
|
||||
client(sent),
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const ui = app as unknown as {
|
||||
composer: TextareaRenderable
|
||||
commandMenu: { setCommands(commands: SlashCommand[]): void }
|
||||
modelText: { plainText: string }
|
||||
}
|
||||
|
||||
try {
|
||||
app.accept({ event: "attached", chat_id: "chat", model_preset: null })
|
||||
ui.commandMenu.setCommands([{
|
||||
command: "/model",
|
||||
title: "Model",
|
||||
description: "Show or switch model presets",
|
||||
argHint: "[preset]",
|
||||
lifecycle: "side_channel",
|
||||
acceptsArgs: true,
|
||||
}])
|
||||
ui.composer.setText("/model deep research")
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => sent.length === 1)
|
||||
app.accept({
|
||||
event: "message",
|
||||
chat_id: "chat",
|
||||
text: "Switched model preset to Deep Research.",
|
||||
turn_id: "turn",
|
||||
})
|
||||
await waitUntil(() => ui.modelText.plainText.includes("Deep Research"))
|
||||
|
||||
expect(sent).toEqual(["/model deep research"])
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
|
||||
+67
-7
@@ -60,6 +60,7 @@ interface AppOptions {
|
||||
apiToken: string
|
||||
chatId?: string
|
||||
model: string
|
||||
modelPreset: string
|
||||
workspace: string
|
||||
version: string
|
||||
access: string
|
||||
@@ -324,7 +325,11 @@ export class NanobotTui {
|
||||
private readonly promptHistory: string[] = []
|
||||
private historyCursor = 0
|
||||
private historyDraft = ""
|
||||
private defaultModelName: string
|
||||
private defaultModelPreset: string
|
||||
private modelName: string
|
||||
private modelPreset: string
|
||||
private sessionModelPreset: string | null | undefined
|
||||
private sessionTitle = ""
|
||||
private sessionMetadataId = 0
|
||||
private contextTokens: number | null = null
|
||||
@@ -336,6 +341,7 @@ export class NanobotTui {
|
||||
private sessionLoadId = 0
|
||||
private sessionLoading = false
|
||||
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
||||
private readonly modelCommandTurns = new Set<string>()
|
||||
private currentFileEdits: FileEditEvent[] = []
|
||||
private lastFileEdits: FileEditEvent[] = []
|
||||
|
||||
@@ -346,7 +352,11 @@ export class NanobotTui {
|
||||
treeSitterClient = getTreeSitterClient(),
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.defaultModelName = options.model
|
||||
this.defaultModelPreset = options.modelPreset
|
||||
this.modelName = options.model
|
||||
this.modelPreset = options.modelPreset
|
||||
this.sessionModelPreset = options.chatId ? undefined : null
|
||||
this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
|
||||
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
|
||||
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
|
||||
@@ -623,7 +633,12 @@ export class NanobotTui {
|
||||
accept(event: InboundEvent): void {
|
||||
if (event.event === "attached") {
|
||||
void rememberChat(this.options.statePath, event.chat_id)
|
||||
if (event.model_preset !== undefined) {
|
||||
this.applyModelPreset(event.model_preset)
|
||||
this.updateTitle()
|
||||
}
|
||||
this.commandTurns.clear()
|
||||
this.modelCommandTurns.clear()
|
||||
const restoring = this.attachedOnce
|
||||
this.attachedOnce = true
|
||||
if (restoring) this.setActive(false)
|
||||
@@ -663,6 +678,9 @@ export class NanobotTui {
|
||||
const lifecycle = this.commandTurns.get(event.turn_id)
|
||||
if (lifecycle !== "agent_turn") {
|
||||
this.commandTurns.delete(event.turn_id)
|
||||
if (this.modelCommandTurns.delete(event.turn_id)) {
|
||||
void this.refreshSessionMetadata(event.chat_id)
|
||||
}
|
||||
this.transcript.assistant(event.text)
|
||||
if (!this.activeTurn) this.status.content = "Ready"
|
||||
return
|
||||
@@ -698,7 +716,10 @@ export class NanobotTui {
|
||||
}
|
||||
return
|
||||
case "turn_end":
|
||||
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||
if (event.turn_id) {
|
||||
this.commandTurns.delete(event.turn_id)
|
||||
this.modelCommandTurns.delete(event.turn_id)
|
||||
}
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.finishActivity()
|
||||
if (this.currentFileEdits.length) this.lastFileEdits = this.currentFileEdits
|
||||
@@ -723,10 +744,10 @@ export class NanobotTui {
|
||||
case "goal_state":
|
||||
return
|
||||
case "turn_model_updated":
|
||||
this.setModel(event.model_name)
|
||||
this.setTurnModel(event.model_name, event.model_preset)
|
||||
return
|
||||
case "runtime_model_updated":
|
||||
this.setModel(event.model_name)
|
||||
this.setDefaultModel(event.model_name, event.model_preset)
|
||||
return
|
||||
case "session_updated":
|
||||
if (
|
||||
@@ -740,7 +761,10 @@ export class NanobotTui {
|
||||
return
|
||||
case "error":
|
||||
const commandLifecycle = event.turn_id ? this.commandTurns.get(event.turn_id) : undefined
|
||||
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||
if (event.turn_id) {
|
||||
this.commandTurns.delete(event.turn_id)
|
||||
this.modelCommandTurns.delete(event.turn_id)
|
||||
}
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||
if (!commandLifecycle || commandLifecycle === "agent_turn") {
|
||||
@@ -766,7 +790,7 @@ export class NanobotTui {
|
||||
this.historyHasMore = false
|
||||
this.historyLoadingOlder = false
|
||||
this.transcript.reset({
|
||||
model: this.modelName,
|
||||
model: this.modelName || this.modelPreset,
|
||||
workspace: this.options.workspace,
|
||||
version: this.options.version,
|
||||
access: this.options.access,
|
||||
@@ -1096,17 +1120,45 @@ export class NanobotTui {
|
||||
: ""
|
||||
}
|
||||
|
||||
private setModel(model: string): void {
|
||||
private setTurnModel(model: string, preset?: string | null): void {
|
||||
this.modelName = model
|
||||
this.modelPreset = preset?.trim() || "default"
|
||||
this.updateTitle()
|
||||
}
|
||||
|
||||
private setDefaultModel(model: string, preset?: string | null): void {
|
||||
this.defaultModelName = model
|
||||
this.defaultModelPreset = preset?.trim() || "default"
|
||||
if (this.sessionModelPreset === null) {
|
||||
this.modelName = this.defaultModelName
|
||||
this.modelPreset = this.defaultModelPreset
|
||||
this.updateTitle()
|
||||
}
|
||||
}
|
||||
|
||||
private applySessionModel(session: SessionSummary): void {
|
||||
this.applyModelPreset(session.modelPreset)
|
||||
}
|
||||
|
||||
private applyModelPreset(preset: string | null): void {
|
||||
const currentModel = this.modelName
|
||||
const currentPreset = this.modelPreset
|
||||
this.sessionModelPreset = preset
|
||||
this.modelPreset = preset || this.defaultModelPreset
|
||||
this.modelName = this.modelPreset === this.defaultModelPreset
|
||||
? this.defaultModelName
|
||||
: this.modelPreset === currentPreset ? currentModel : ""
|
||||
}
|
||||
|
||||
private updateTitle(): void {
|
||||
const identity = this.sessionTitle.trim() || "nanobot"
|
||||
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
|
||||
this.titleText.content = identity
|
||||
const context = this.contextTokens === null ? "" : ` · ~${formatTokenCount(this.contextTokens)} ctx`
|
||||
this.modelText.content = ` · ${this.modelName}${context}`
|
||||
const runtime = this.modelPreset !== "default"
|
||||
? [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
|
||||
: this.modelName
|
||||
this.modelText.content = ` · ${runtime}${context}`
|
||||
}
|
||||
|
||||
private resizeComposer(): void {
|
||||
@@ -1205,6 +1257,7 @@ export class NanobotTui {
|
||||
const current = sessions.find((session) => session.chatId === this.client.activeChatId)
|
||||
if (current) {
|
||||
this.sessionTitle = sessionLabel(current)
|
||||
this.applySessionModel(current)
|
||||
this.updateTitle()
|
||||
}
|
||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||
@@ -1227,6 +1280,7 @@ export class NanobotTui {
|
||||
}
|
||||
if (session.chatId === this.client.activeChatId) {
|
||||
this.sessionTitle = sessionLabel(session)
|
||||
this.applySessionModel(session)
|
||||
this.updateTitle()
|
||||
this.closeSessions()
|
||||
this.status.content = this.readyStatus()
|
||||
@@ -1241,6 +1295,7 @@ export class NanobotTui {
|
||||
this.ready = false
|
||||
this.sessionMetadataId += 1
|
||||
this.sessionTitle = sessionLabel(session)
|
||||
this.applySessionModel(session)
|
||||
this.contextTokens = null
|
||||
this.updateTitle()
|
||||
this.status.content = "Opening session…"
|
||||
@@ -1267,6 +1322,9 @@ export class NanobotTui {
|
||||
this.ready = false
|
||||
this.sessionMetadataId += 1
|
||||
this.sessionTitle = "New chat"
|
||||
this.sessionModelPreset = null
|
||||
this.modelName = this.defaultModelName
|
||||
this.modelPreset = this.defaultModelPreset
|
||||
this.contextTokens = null
|
||||
this.updateTitle()
|
||||
this.status.content = "Starting a new chat…"
|
||||
@@ -1296,6 +1354,7 @@ export class NanobotTui {
|
||||
return
|
||||
}
|
||||
this.commandTurns.set(turnId, lifecycle)
|
||||
if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId)
|
||||
this.clearComposer()
|
||||
this.commandMenu.hide()
|
||||
if (lifecycle !== "stop_active_turn") this.transcript.user(content)
|
||||
@@ -1389,6 +1448,7 @@ export class NanobotTui {
|
||||
const session = sessions.find((candidate) => candidate.chatId === chatId)
|
||||
if (!session) return
|
||||
this.sessionTitle = sessionLabel(session)
|
||||
this.applySessionModel(session)
|
||||
this.updateTitle()
|
||||
} catch {
|
||||
// Session metadata is decorative; conversation transport stays authoritative.
|
||||
|
||||
@@ -18,6 +18,7 @@ const options: AppOptions = {
|
||||
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
|
||||
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
|
||||
model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model",
|
||||
modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default",
|
||||
workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "",
|
||||
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
|
||||
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
|
||||
|
||||
@@ -70,7 +70,13 @@ 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: "terminal" }) })
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({
|
||||
event: "attached",
|
||||
chat_id: "terminal",
|
||||
model_preset: "Deep Research",
|
||||
}),
|
||||
})
|
||||
client.send("hello")
|
||||
client.attach("other-chat")
|
||||
client.newChat()
|
||||
@@ -83,6 +89,11 @@ describe("gateway protocol", () => {
|
||||
expect(outbound[2]).toEqual({ type: "attach", chat_id: "other-chat" })
|
||||
expect(outbound[3]).toEqual({ type: "new_chat" })
|
||||
expect(events.map((event) => event.event)).toEqual(["ready", "attached"])
|
||||
expect(events[1]).toEqual({
|
||||
event: "attached",
|
||||
chat_id: "terminal",
|
||||
model_preset: "Deep Research",
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
@@ -127,13 +138,16 @@ describe("gateway protocol", () => {
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: 42 }),
|
||||
})
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ event: "attached", chat_id: "one", model_preset: 42 }),
|
||||
})
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: "metadata" }),
|
||||
})
|
||||
socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) })
|
||||
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
|
||||
expect(statuses).toContain("error:gateway sent an invalid event")
|
||||
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(5)
|
||||
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(6)
|
||||
expect(events).toContainEqual({
|
||||
event: "session_updated",
|
||||
chat_id: "one",
|
||||
@@ -383,6 +397,7 @@ describe("gateway protocol", () => {
|
||||
created_at: "2026-08-12T10:00:00Z",
|
||||
updated_at: "2026-08-13T10:00:00Z",
|
||||
run_started_at: 123,
|
||||
model_preset: "Deep Research",
|
||||
},
|
||||
{ key: "cli:direct", title: "Not a WebUI session" },
|
||||
{ key: 42 },
|
||||
@@ -398,6 +413,7 @@ describe("gateway protocol", () => {
|
||||
createdAt: "2026-08-12T10:00:00Z",
|
||||
updatedAt: "2026-08-13T10:00:00Z",
|
||||
runStartedAt: 123,
|
||||
modelPreset: "Deep Research",
|
||||
pinned: true,
|
||||
archived: false,
|
||||
}])
|
||||
|
||||
+24
-3
@@ -38,7 +38,7 @@ export interface FileDiff {
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| { event: "attached"; chat_id: string; model_preset?: string | null }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| {
|
||||
event: "message"
|
||||
@@ -72,7 +72,12 @@ export type InboundEvent =
|
||||
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
|
||||
| { event: "session_updated"; chat_id: string; scope?: string }
|
||||
| { event: "runtime_model_updated"; model_name: string; model_preset?: string | null }
|
||||
| { event: "turn_model_updated"; chat_id: string; model_name: string }
|
||||
| {
|
||||
event: "turn_model_updated"
|
||||
chat_id: string
|
||||
model_name: string
|
||||
model_preset?: string | null
|
||||
}
|
||||
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
|
||||
|
||||
type OutboundEvent =
|
||||
@@ -135,6 +140,7 @@ export interface SessionSummary {
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
runStartedAt: number | null
|
||||
modelPreset: string | null
|
||||
pinned: boolean
|
||||
archived: boolean
|
||||
}
|
||||
@@ -232,6 +238,12 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
}
|
||||
if (!CHAT_EVENTS.has(name)) return undefined // Forward-compatible additive event.
|
||||
if (typeof record.chat_id !== "string") return null
|
||||
if (
|
||||
name === "attached"
|
||||
&& record.model_preset !== undefined
|
||||
&& record.model_preset !== null
|
||||
&& typeof record.model_preset !== "string"
|
||||
) return null
|
||||
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
|
||||
return null
|
||||
}
|
||||
@@ -253,7 +265,13 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
||||
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
|
||||
if (name === "session_updated" && !optional(record.scope, "string")) return null
|
||||
if (name === "turn_model_updated" && typeof record.model_name !== "string") return null
|
||||
if (
|
||||
name === "turn_model_updated"
|
||||
&& (typeof record.model_name !== "string"
|
||||
|| (record.model_preset !== undefined
|
||||
&& record.model_preset !== null
|
||||
&& typeof record.model_preset !== "string"))
|
||||
) return null
|
||||
return value as InboundEvent
|
||||
}
|
||||
|
||||
@@ -411,6 +429,9 @@ export async function fetchSessions(
|
||||
createdAt: typeof value.created_at === "string" ? value.created_at : null,
|
||||
updatedAt: typeof value.updated_at === "string" ? value.updated_at : null,
|
||||
runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null,
|
||||
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
|
||||
? value.model_preset.trim()
|
||||
: null,
|
||||
pinned: pinned.has(value.key),
|
||||
archived: archived.has(value.key),
|
||||
}]
|
||||
|
||||
@@ -12,6 +12,7 @@ const sessions: SessionSummary[] = [
|
||||
createdAt: "2026-08-12T10:00:00Z",
|
||||
updatedAt: "2026-08-13T10:00:00Z",
|
||||
runStartedAt: null,
|
||||
modelPreset: "Codex",
|
||||
pinned: true,
|
||||
archived: false,
|
||||
},
|
||||
@@ -22,6 +23,7 @@ const sessions: SessionSummary[] = [
|
||||
createdAt: "2026-08-11T10:00:00Z",
|
||||
updatedAt: "2026-08-12T10:00:00Z",
|
||||
runStartedAt: null,
|
||||
modelPreset: null,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
},
|
||||
|
||||
+11
-2
@@ -29,11 +29,20 @@ export class SessionMenu {
|
||||
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
|
||||
this.picker = new PickerMenu(renderer, theme, {
|
||||
id: "nanobot-tui-session-menu",
|
||||
searchText: (session) => `${sessionLabel(session)} ${session.preview} ${session.chatId}`,
|
||||
searchText: (session) => [
|
||||
sessionLabel(session),
|
||||
session.modelPreset || "",
|
||||
session.preview,
|
||||
session.chatId,
|
||||
].join(" "),
|
||||
render: (session) => {
|
||||
const age = updatedLabel(session.updatedAt)
|
||||
const preview = session.preview.trim()
|
||||
const detail = [age, preview && preview !== sessionLabel(session) ? preview : ""]
|
||||
const detail = [
|
||||
session.modelPreset,
|
||||
age,
|
||||
preview && preview !== sessionLabel(session) ? preview : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
|
||||
|
||||
Reference in New Issue
Block a user