mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1a4e5af8 | ||
|
|
080f80dfba | ||
|
|
ac7fbe7aef | ||
|
|
43480141ed | ||
|
|
e329127722 | ||
|
|
f194395e01 | ||
|
|
6fc5794dc2 | ||
|
|
476bc7f4dc | ||
|
|
0f4c9956a8 | ||
|
|
009a1a6e6f | ||
|
|
ffa58aa5ef | ||
|
|
1e9d46fb36 | ||
|
|
ab7351be63 | ||
|
|
cfc872fb52 | ||
|
|
cfc1fae8b5 | ||
|
|
9807e9cf37 | ||
|
|
961b1fdd7d | ||
|
|
3852956a71 | ||
|
|
bd3f4d7a74 | ||
|
|
e21e71c55b | ||
|
|
cfe75dbb8d | ||
|
|
7afc6a1b33 | ||
|
|
48eea29313 |
@@ -209,7 +209,13 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
|||||||
nanobot agent
|
nanobot agent
|
||||||
```
|
```
|
||||||
|
|
||||||
This opens the native terminal client with the configured model and tools, using the launch directory as its workspace. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. Each launch starts a new session; `--session` selects an existing WebSocket session, while `--workspace` overrides the launch directory. Use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `/detach` to close the TUI while keeping the gateway and any active agent turn running in the background; after the terminal is restored, nanobot prints the exact `nanobot gateway stop` command for that config and workspace. Use `nanobot gateway --background` to start persistently before opening a client. Type `exit` or press `Ctrl+C` when you are done; after the terminal is restored, nanobot prints a ready-to-run `nanobot agent --session ...` command that resumes the session. Use `nanobot agent --classic` for the legacy Python prompt.
|
This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI.
|
||||||
|
|
||||||
|
- Type `/` to discover commands, `/sessions` to switch conversations, or `@` to mention an app, MCP server, or saved session.
|
||||||
|
- Press `Enter` to send or steer, `Tab` to queue a follow-up, and `Shift+Enter` to add a newline (`Ctrl+J` works in terminals that cannot distinguish modified Enter keys).
|
||||||
|
- Use `/detach` to leave the current task running, or start with `nanobot gateway --background` when nanobot should stay online after all local clients exit.
|
||||||
|
|
||||||
|
Each launch starts a new session by default. Use `--session` to resume one and `--workspace` to choose another workspace. See the [CLI reference](./docs/cli-reference.md#agent-cli) for session branching, diffs, history, shortcuts, gateway lifecycle, and compatibility options.
|
||||||
|
|
||||||
For one request and an immediate exit, use:
|
For one request and an immediate exit, use:
|
||||||
|
|
||||||
@@ -328,15 +334,386 @@ Use nanobot for a real task, report what broke, and then pick a focused improvem
|
|||||||
- Browse [open issues](https://github.com/HKUDS/nanobot/issues) for problems to investigate.
|
- Browse [open issues](https://github.com/HKUDS/nanobot/issues) for problems to investigate.
|
||||||
- Open a [pull request](https://github.com/HKUDS/nanobot/pulls) for a focused fix or integration.
|
- Open a [pull request](https://github.com/HKUDS/nanobot/pulls) for a focused fix or integration.
|
||||||
|
|
||||||
## Contact
|
## Maintainers
|
||||||
|
|
||||||
Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/re-bin"><img src="https://avatars.githubusercontent.com/u/52506698?v=4&s=80" width="80" height="80" alt="Xubin Ren"><br><strong>Xubin Ren</strong></a><br><a href="https://x.com/xubinrencs"><img src="https://img.shields.io/badge/@xubinrencs-000000?style=flat&logo=x&logoColor=white" alt="Xubin Ren on X"></a></td>
|
||||||
|
<td align="center"><a href="https://github.com/chengyongru"><img src="https://avatars.githubusercontent.com/u/61816729?v=4&s=80" width="80" height="80" alt="Yongru Chen"><br><strong>Yongru Chen</strong></a><br><a href="https://x.com/chengyongru"><img src="https://img.shields.io/badge/@chengyongru-000000?style=flat&logo=x&logoColor=white" alt="Yongru Chen on X"></a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Contributors
|
## Community Contributors
|
||||||
|
|
||||||
<a href="https://github.com/HKUDS/nanobot/graphs/contributors">
|
<!-- contributors:start -->
|
||||||
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
|
<p>
|
||||||
</a>
|
<a href="https://github.com/Athemis"><img src="https://avatars.githubusercontent.com/u/552653?v=4&s=48" width="48" height="48" alt="Athemis"></a>
|
||||||
|
<a href="https://github.com/axelray-dev"><img src="https://avatars.githubusercontent.com/u/110029405?v=4&s=48" width="48" height="48" alt="axelray-dev"></a>
|
||||||
|
<a href="https://github.com/yorkhellen"><img src="https://avatars.githubusercontent.com/u/8706550?v=4&s=48" width="48" height="48" alt="yorkhellen"></a>
|
||||||
|
<a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4&s=48" width="48" height="48" alt="04cb"></a>
|
||||||
|
<a href="https://github.com/santhreal"><img src="https://avatars.githubusercontent.com/u/64453045?v=4&s=48" width="48" height="48" alt="santhreal"></a>
|
||||||
|
<a href="https://github.com/yu-xin-c"><img src="https://avatars.githubusercontent.com/u/175149126?v=4&s=48" width="48" height="48" alt="yu-xin-c"></a>
|
||||||
|
<a href="https://github.com/xcosmosbox"><img src="https://avatars.githubusercontent.com/u/56502269?v=4&s=48" width="48" height="48" alt="xcosmosbox"></a>
|
||||||
|
<a href="https://github.com/kunalk16"><img src="https://avatars.githubusercontent.com/u/5303824?v=4&s=48" width="48" height="48" alt="kunalk16"></a>
|
||||||
|
<a href="https://github.com/chaohuang-ai"><img src="https://avatars.githubusercontent.com/u/204865953?v=4&s=48" width="48" height="48" alt="chaohuang-ai"></a>
|
||||||
|
<a href="https://github.com/zayfod"><img src="https://avatars.githubusercontent.com/u/1811339?v=4&s=48" width="48" height="48" alt="zayfod"></a>
|
||||||
|
<a href="https://github.com/nikolasdehor"><img src="https://avatars.githubusercontent.com/u/116851567?v=4&s=48" width="48" height="48" alt="nikolasdehor"></a>
|
||||||
|
<a href="https://github.com/JiajunBernoulli"><img src="https://avatars.githubusercontent.com/u/45968640?v=4&s=48" width="48" height="48" alt="JiajunBernoulli"></a>
|
||||||
|
<a href="https://github.com/flobo3"><img src="https://avatars.githubusercontent.com/u/268352850?v=4&s=48" width="48" height="48" alt="flobo3"></a>
|
||||||
|
<a href="https://github.com/hamb1y"><img src="https://avatars.githubusercontent.com/u/88080063?v=4&s=48" width="48" height="48" alt="hamb1y"></a>
|
||||||
|
<a href="https://github.com/SergioSV96"><img src="https://avatars.githubusercontent.com/u/20419761?v=4&s=48" width="48" height="48" alt="SergioSV96"></a>
|
||||||
|
<a href="https://github.com/KDB-Wind"><img src="https://avatars.githubusercontent.com/u/271925278?v=4&s=48" width="48" height="48" alt="KDB-Wind"></a>
|
||||||
|
<a href="https://github.com/morandot"><img src="https://avatars.githubusercontent.com/u/274257964?v=4&s=48" width="48" height="48" alt="morandot"></a>
|
||||||
|
<a href="https://github.com/coldxiangyu163"><img src="https://avatars.githubusercontent.com/u/134986317?v=4&s=48" width="48" height="48" alt="coldxiangyu163"></a>
|
||||||
|
<a href="https://github.com/boogieLing"><img src="https://avatars.githubusercontent.com/u/64551706?v=4&s=48" width="48" height="48" alt="boogieLing"></a>
|
||||||
|
<a href="https://github.com/michaelxer"><img src="https://avatars.githubusercontent.com/u/52305679?v=4&s=48" width="48" height="48" alt="michaelxer"></a>
|
||||||
|
<a href="https://github.com/aiguozhi123456"><img src="https://avatars.githubusercontent.com/u/126325311?v=4&s=48" width="48" height="48" alt="aiguozhi123456"></a>
|
||||||
|
<a href="https://github.com/pinhua33"><img src="https://avatars.githubusercontent.com/u/251483507?v=4&s=48" width="48" height="48" alt="pinhua33"></a>
|
||||||
|
<a href="https://github.com/pixan-ai"><img src="https://avatars.githubusercontent.com/u/218441143?v=4&s=48" width="48" height="48" alt="pixan-ai"></a>
|
||||||
|
<a href="https://github.com/hussein1362"><img src="https://avatars.githubusercontent.com/u/49703886?v=4&s=48" width="48" height="48" alt="hussein1362"></a>
|
||||||
|
<a href="https://github.com/alekwo"><img src="https://avatars.githubusercontent.com/u/24917047?v=4&s=48" width="48" height="48" alt="alekwo"></a>
|
||||||
|
<a href="https://github.com/haosenwang1018"><img src="https://avatars.githubusercontent.com/u/167664334?v=4&s=48" width="48" height="48" alt="haosenwang1018"></a>
|
||||||
|
<a href="https://github.com/IlyaGusev"><img src="https://avatars.githubusercontent.com/u/2670295?v=4&s=48" width="48" height="48" alt="IlyaGusev"></a>
|
||||||
|
<a href="https://github.com/T3chC0wb0y"><img src="https://avatars.githubusercontent.com/u/68530847?v=4&s=48" width="48" height="48" alt="T3chC0wb0y"></a>
|
||||||
|
<a href="https://github.com/VITOHJL"><img src="https://avatars.githubusercontent.com/u/166518988?v=4&s=48" width="48" height="48" alt="VITOHJL"></a>
|
||||||
|
<a href="https://github.com/macroadster"><img src="https://avatars.githubusercontent.com/u/328366?v=4&s=48" width="48" height="48" alt="macroadster"></a>
|
||||||
|
<a href="https://github.com/Hinotoi-agent"><img src="https://avatars.githubusercontent.com/u/275430060?v=4&s=48" width="48" height="48" alt="Hinotoi-agent"></a>
|
||||||
|
<a href="https://github.com/kingassune"><img src="https://avatars.githubusercontent.com/u/6126851?v=4&s=48" width="48" height="48" alt="kingassune"></a>
|
||||||
|
<a href="https://github.com/goodtiding5"><img src="https://avatars.githubusercontent.com/u/179489?v=4&s=48" width="48" height="48" alt="goodtiding5"></a>
|
||||||
|
<a href="https://github.com/kiplangatkorir"><img src="https://avatars.githubusercontent.com/u/153384040?v=4&s=48" width="48" height="48" alt="kiplangatkorir"></a>
|
||||||
|
<a href="https://github.com/elkaix"><img src="https://avatars.githubusercontent.com/u/197959891?v=4&s=48" width="48" height="48" alt="elkaix"></a>
|
||||||
|
<a href="https://github.com/KimGLee"><img src="https://avatars.githubusercontent.com/u/150593189?v=4&s=48" width="48" height="48" alt="KimGLee"></a>
|
||||||
|
<a href="https://github.com/m11y"><img src="https://avatars.githubusercontent.com/u/1625837?v=4&s=48" width="48" height="48" alt="m11y"></a>
|
||||||
|
<a href="https://github.com/LingaoM"><img src="https://avatars.githubusercontent.com/u/26378606?v=4&s=48" width="48" height="48" alt="LingaoM"></a>
|
||||||
|
<a href="https://github.com/DaryeDev"><img src="https://avatars.githubusercontent.com/u/54469750?v=4&s=48" width="48" height="48" alt="DaryeDev"></a>
|
||||||
|
<a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST"></a>
|
||||||
|
<a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="xzq-xu"></a>
|
||||||
|
<a href="https://github.com/pikaxinge"><img src="https://avatars.githubusercontent.com/u/68273313?v=4&s=48" width="48" height="48" alt="pikaxinge"></a>
|
||||||
|
<a href="https://github.com/arcdrake22"><img src="https://avatars.githubusercontent.com/u/204617897?v=4&s=48" width="48" height="48" alt="arcdrake22"></a>
|
||||||
|
<a href="https://github.com/JackLuguibin"><img src="https://avatars.githubusercontent.com/u/46274946?v=4&s=48" width="48" height="48" alt="JackLuguibin"></a>
|
||||||
|
<a href="https://github.com/HaisamAbbas"><img src="https://avatars.githubusercontent.com/u/95044189?v=4&s=48" width="48" height="48" alt="HaisamAbbas"></a>
|
||||||
|
<a href="https://github.com/anunay999"><img src="https://avatars.githubusercontent.com/u/16853513?v=4&s=48" width="48" height="48" alt="anunay999"></a>
|
||||||
|
<a href="https://github.com/flaviovs"><img src="https://avatars.githubusercontent.com/u/1832699?v=4&s=48" width="48" height="48" alt="flaviovs"></a>
|
||||||
|
<a href="https://github.com/C-Li"><img src="https://avatars.githubusercontent.com/u/20661667?v=4&s=48" width="48" height="48" alt="C-Li"></a>
|
||||||
|
<a href="https://github.com/Ho1yShif"><img src="https://avatars.githubusercontent.com/u/75815862?v=4&s=48" width="48" height="48" alt="Ho1yShif"></a>
|
||||||
|
<a href="https://github.com/pjhoberman"><img src="https://avatars.githubusercontent.com/u/37924?v=4&s=48" width="48" height="48" alt="pjhoberman"></a>
|
||||||
|
<a href="https://github.com/nghiahsgs"><img src="https://avatars.githubusercontent.com/u/24955327?v=4&s=48" width="48" height="48" alt="nghiahsgs"></a>
|
||||||
|
<a href="https://github.com/Bahtya"><img src="https://avatars.githubusercontent.com/u/34988899?v=4&s=48" width="48" height="48" alt="Bahtya"></a>
|
||||||
|
<a href="https://github.com/tangtaizong666"><img src="https://avatars.githubusercontent.com/u/212687958?v=4&s=48" width="48" height="48" alt="tangtaizong666"></a>
|
||||||
|
<a href="https://github.com/XJPeng12"><img src="https://avatars.githubusercontent.com/u/50786186?v=4&s=48" width="48" height="48" alt="XJPeng12"></a>
|
||||||
|
<a href="https://github.com/yanghan-cyber"><img src="https://avatars.githubusercontent.com/u/188783428?v=4&s=48" width="48" height="48" alt="yanghan-cyber"></a>
|
||||||
|
<a href="https://github.com/ZhouJ-sh"><img src="https://avatars.githubusercontent.com/u/9983860?v=4&s=48" width="48" height="48" alt="ZhouJ-sh"></a>
|
||||||
|
<a href="https://github.com/Yuxin-Lou"><img src="https://avatars.githubusercontent.com/u/117000057?v=4&s=48" width="48" height="48" alt="Yuxin-Lou"></a>
|
||||||
|
<a href="https://github.com/LeoFYH"><img src="https://avatars.githubusercontent.com/u/184173704?v=4&s=48" width="48" height="48" alt="LeoFYH"></a>
|
||||||
|
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude"></a>
|
||||||
|
<a href="https://github.com/chris-alexander"><img src="https://avatars.githubusercontent.com/u/2815297?v=4&s=48" width="48" height="48" alt="chris-alexander"></a>
|
||||||
|
<a href="https://github.com/benlenarts"><img src="https://avatars.githubusercontent.com/u/131161?v=4&s=48" width="48" height="48" alt="benlenarts"></a>
|
||||||
|
<a href="https://github.com/outlook84"><img src="https://avatars.githubusercontent.com/u/96007761?v=4&s=48" width="48" height="48" alt="outlook84"></a>
|
||||||
|
<a href="https://github.com/Mrart"><img src="https://avatars.githubusercontent.com/u/5235758?v=4&s=48" width="48" height="48" alt="Mrart"></a>
|
||||||
|
<a href="https://github.com/ramonpaolo"><img src="https://avatars.githubusercontent.com/u/53312850?v=4&s=48" width="48" height="48" alt="ramonpaolo"></a>
|
||||||
|
<a href="https://github.com/huhu-tiger"><img src="https://avatars.githubusercontent.com/u/76894920?v=4&s=48" width="48" height="48" alt="huhu-tiger"></a>
|
||||||
|
<a href="https://github.com/tangjiabin"><img src="https://avatars.githubusercontent.com/u/21021242?v=4&s=48" width="48" height="48" alt="tangjiabin"></a>
|
||||||
|
<a href="https://github.com/yeyitech"><img src="https://avatars.githubusercontent.com/u/231244789?v=4&s=48" width="48" height="48" alt="yeyitech"></a>
|
||||||
|
<a href="https://github.com/Flinn-X"><img src="https://avatars.githubusercontent.com/u/54433526?v=4&s=48" width="48" height="48" alt="Flinn-X"></a>
|
||||||
|
<a href="https://github.com/bingqilinweimaotai"><img src="https://avatars.githubusercontent.com/u/111987281?v=4&s=48" width="48" height="48" alt="bingqilinweimaotai"></a>
|
||||||
|
<a href="https://github.com/Qinnnnnn"><img src="https://avatars.githubusercontent.com/u/14584068?v=4&s=48" width="48" height="48" alt="Qinnnnnn"></a>
|
||||||
|
<a href="https://github.com/HengWeiBin"><img src="https://avatars.githubusercontent.com/u/45145821?v=4&s=48" width="48" height="48" alt="HengWeiBin"></a>
|
||||||
|
<a href="https://github.com/waelantar"><img src="https://avatars.githubusercontent.com/u/70063334?v=4&s=48" width="48" height="48" alt="waelantar"></a>
|
||||||
|
<a href="https://github.com/tanishra"><img src="https://avatars.githubusercontent.com/u/100482827?v=4&s=48" width="48" height="48" alt="tanishra"></a>
|
||||||
|
<a href="https://github.com/olgagaga"><img src="https://avatars.githubusercontent.com/u/75477960?v=4&s=48" width="48" height="48" alt="olgagaga"></a>
|
||||||
|
<a href="https://github.com/masterlyj"><img src="https://avatars.githubusercontent.com/u/167326996?v=4&s=48" width="48" height="48" alt="masterlyj"></a>
|
||||||
|
<a href="https://github.com/xgzlucario"><img src="https://avatars.githubusercontent.com/u/48748794?v=4&s=48" width="48" height="48" alt="xgzlucario"></a>
|
||||||
|
<a href="https://github.com/dzydzydzy7"><img src="https://avatars.githubusercontent.com/u/32220064?v=4&s=48" width="48" height="48" alt="dzydzydzy7"></a>
|
||||||
|
<a href="https://github.com/dajiaohuang"><img src="https://avatars.githubusercontent.com/u/108231307?v=4&s=48" width="48" height="48" alt="dajiaohuang"></a>
|
||||||
|
<a href="https://github.com/concertypin"><img src="https://avatars.githubusercontent.com/u/55056558?v=4&s=48" width="48" height="48" alt="concertypin"></a>
|
||||||
|
<a href="https://github.com/WangCheng0116"><img src="https://avatars.githubusercontent.com/u/111694270?v=4&s=48" width="48" height="48" alt="WangCheng0116"></a>
|
||||||
|
<a href="https://github.com/yarikoptic"><img src="https://avatars.githubusercontent.com/u/39889?v=4&s=48" width="48" height="48" alt="yarikoptic"></a>
|
||||||
|
<a href="https://github.com/lukemilby"><img src="https://avatars.githubusercontent.com/u/966940?v=4&s=48" width="48" height="48" alt="lukemilby"></a>
|
||||||
|
<a href="https://github.com/gongpx20069"><img src="https://avatars.githubusercontent.com/u/21985921?v=4&s=48" width="48" height="48" alt="gongpx20069"></a>
|
||||||
|
<a href="https://github.com/tobrien"><img src="https://avatars.githubusercontent.com/u/36787?v=4&s=48" width="48" height="48" alt="tobrien"></a>
|
||||||
|
<a href="https://github.com/Shiniese"><img src="https://avatars.githubusercontent.com/u/135589327?v=4&s=48" width="48" height="48" alt="Shiniese"></a>
|
||||||
|
<a href="https://github.com/shawnWXN"><img src="https://avatars.githubusercontent.com/u/47786182?v=4&s=48" width="48" height="48" alt="shawnWXN"></a>
|
||||||
|
<a href="https://github.com/sbyinin"><img src="https://avatars.githubusercontent.com/u/2064038?v=4&s=48" width="48" height="48" alt="sbyinin"></a>
|
||||||
|
<a href="https://github.com/nne998"><img src="https://avatars.githubusercontent.com/u/148901?v=4&s=48" width="48" height="48" alt="nne998"></a>
|
||||||
|
<a href="https://github.com/lahuman"><img src="https://avatars.githubusercontent.com/u/6156679?v=4&s=48" width="48" height="48" alt="lahuman"></a>
|
||||||
|
<a href="https://github.com/hlgone"><img src="https://avatars.githubusercontent.com/u/152462991?v=4&s=48" width="48" height="48" alt="hlgone"></a>
|
||||||
|
<a href="https://github.com/franciscomaestre"><img src="https://avatars.githubusercontent.com/u/2027043?v=4&s=48" width="48" height="48" alt="franciscomaestre"></a>
|
||||||
|
<a href="https://github.com/fat-operator"><img src="https://avatars.githubusercontent.com/u/105777951?v=4&s=48" width="48" height="48" alt="fat-operator"></a>
|
||||||
|
<a href="https://github.com/shixi-li"><img src="https://avatars.githubusercontent.com/u/40780706?v=4&s=48" width="48" height="48" alt="shixi-li"></a>
|
||||||
|
<a href="https://github.com/who96"><img src="https://avatars.githubusercontent.com/u/44131846?v=4&s=48" width="48" height="48" alt="who96"></a>
|
||||||
|
<a href="https://github.com/cyzlmh"><img src="https://avatars.githubusercontent.com/u/24603258?v=4&s=48" width="48" height="48" alt="cyzlmh"></a>
|
||||||
|
<a href="https://github.com/zhuzhh"><img src="https://avatars.githubusercontent.com/u/41102272?v=4&s=48" width="48" height="48" alt="zhuzhh"></a>
|
||||||
|
<a href="https://github.com/zpljd258"><img src="https://avatars.githubusercontent.com/u/11162658?v=4&s=48" width="48" height="48" alt="zpljd258"></a>
|
||||||
|
<a href="https://github.com/cms19859230182-lang"><img src="https://avatars.githubusercontent.com/u/276597748?v=4&s=48" width="48" height="48" alt="cms19859230182-lang"></a>
|
||||||
|
<a href="https://github.com/amplifierplus"><img src="https://avatars.githubusercontent.com/u/160200579?v=4&s=48" width="48" height="48" alt="amplifierplus"></a>
|
||||||
|
<a href="https://github.com/LZDQ"><img src="https://avatars.githubusercontent.com/u/45907809?v=4&s=48" width="48" height="48" alt="LZDQ"></a>
|
||||||
|
<a href="https://github.com/wb213"><img src="https://avatars.githubusercontent.com/u/488412?v=4&s=48" width="48" height="48" alt="wb213"></a>
|
||||||
|
<a href="https://github.com/shaun0927"><img src="https://avatars.githubusercontent.com/u/70629228?v=4&s=48" width="48" height="48" alt="shaun0927"></a>
|
||||||
|
<a href="https://github.com/wzrayyy"><img src="https://avatars.githubusercontent.com/u/143233939?v=4&s=48" width="48" height="48" alt="wzrayyy"></a>
|
||||||
|
<a href="https://github.com/LHMQ878"><img src="https://avatars.githubusercontent.com/u/205284459?v=4&s=48" width="48" height="48" alt="LHMQ878"></a>
|
||||||
|
<a href="https://github.com/Michael-lhh"><img src="https://avatars.githubusercontent.com/u/41994684?v=4&s=48" width="48" height="48" alt="Michael-lhh"></a>
|
||||||
|
<a href="https://github.com/Mizarka"><img src="https://avatars.githubusercontent.com/u/253529828?v=4&s=48" width="48" height="48" alt="Mizarka"></a>
|
||||||
|
<a href="https://github.com/rick2047"><img src="https://avatars.githubusercontent.com/u/16410?v=4&s=48" width="48" height="48" alt="rick2047"></a>
|
||||||
|
<a href="https://github.com/kuchazi-yy"><img src="https://avatars.githubusercontent.com/u/73976601?v=4&s=48" width="48" height="48" alt="kuchazi-yy"></a>
|
||||||
|
<a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol-zero-0"></a>
|
||||||
|
<a href="https://github.com/subalkum"><img src="https://avatars.githubusercontent.com/u/180379485?v=4&s=48" width="48" height="48" alt="subalkum"></a>
|
||||||
|
<a href="https://github.com/vystartasv"><img src="https://avatars.githubusercontent.com/u/34380849?v=4&s=48" width="48" height="48" alt="vystartasv"></a>
|
||||||
|
<a href="https://github.com/ZJUCQR"><img src="https://avatars.githubusercontent.com/u/138299253?v=4&s=48" width="48" height="48" alt="ZJUCQR"></a>
|
||||||
|
<a href="https://github.com/ZegWe"><img src="https://avatars.githubusercontent.com/u/22636524?v=4&s=48" width="48" height="48" alt="ZegWe"></a>
|
||||||
|
<a href="https://github.com/ZhangYuanhan-AI"><img src="https://avatars.githubusercontent.com/u/18485270?v=4&s=48" width="48" height="48" alt="ZhangYuanhan-AI"></a>
|
||||||
|
<a href="https://github.com/chtangwin"><img src="https://avatars.githubusercontent.com/u/8316617?v=4&s=48" width="48" height="48" alt="chtangwin"></a>
|
||||||
|
<a href="https://github.com/dxtime"><img src="https://avatars.githubusercontent.com/u/8173810?v=4&s=48" width="48" height="48" alt="dxtime"></a>
|
||||||
|
<a href="https://github.com/ethanclaw"><img src="https://avatars.githubusercontent.com/u/262543029?v=4&s=48" width="48" height="48" alt="ethanclaw"></a>
|
||||||
|
<a href="https://github.com/WufeiHalf"><img src="https://avatars.githubusercontent.com/u/103879607?v=4&s=48" width="48" height="48" alt="WufeiHalf"></a>
|
||||||
|
<a href="https://github.com/stutiredboy"><img src="https://avatars.githubusercontent.com/u/345208?v=4&s=48" width="48" height="48" alt="stutiredboy"></a>
|
||||||
|
<a href="https://github.com/stupidloud"><img src="https://avatars.githubusercontent.com/u/56048681?v=4&s=48" width="48" height="48" alt="stupidloud"></a>
|
||||||
|
<a href="https://github.com/asif786ka"><img src="https://avatars.githubusercontent.com/u/6130514?v=4&s=48" width="48" height="48" alt="asif786ka"></a>
|
||||||
|
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla"></a>
|
||||||
|
<a href="https://github.com/cypggs"><img src="https://avatars.githubusercontent.com/u/3694954?v=4&s=48" width="48" height="48" alt="cypggs"></a>
|
||||||
|
<a href="https://github.com/web-flow"><img src="https://avatars.githubusercontent.com/u/19864447?v=4&s=48" width="48" height="48" alt="web-flow"></a>
|
||||||
|
<a href="https://github.com/eliumusk"><img src="https://avatars.githubusercontent.com/u/123090877?v=4&s=48" width="48" height="48" alt="eliumusk"></a>
|
||||||
|
<a href="https://github.com/mikaku9944"><img src="https://avatars.githubusercontent.com/u/66119379?v=4&s=48" width="48" height="48" alt="mikaku9944"></a>
|
||||||
|
<a href="https://github.com/mamamiyear"><img src="https://avatars.githubusercontent.com/u/14191296?v=4&s=48" width="48" height="48" alt="mamamiyear"></a>
|
||||||
|
<a href="https://github.com/jr551"><img src="https://avatars.githubusercontent.com/u/2920328?v=4&s=48" width="48" height="48" alt="jr551"></a>
|
||||||
|
<a href="https://github.com/invictus-z"><img src="https://avatars.githubusercontent.com/u/108621936?v=4&s=48" width="48" height="48" alt="invictus-z"></a>
|
||||||
|
<a href="https://github.com/imfondof"><img src="https://avatars.githubusercontent.com/u/39022581?v=4&s=48" width="48" height="48" alt="imfondof"></a>
|
||||||
|
<a href="https://github.com/hyoukadev"><img src="https://avatars.githubusercontent.com/u/17965578?v=4&s=48" width="48" height="48" alt="hyoukadev"></a>
|
||||||
|
<a href="https://github.com/hata33"><img src="https://avatars.githubusercontent.com/u/79907651?v=4&s=48" width="48" height="48" alt="hata33"></a>
|
||||||
|
<a href="https://github.com/fengxiaohu"><img src="https://avatars.githubusercontent.com/u/23492381?v=4&s=48" width="48" height="48" alt="fengxiaohu"></a>
|
||||||
|
<a href="https://github.com/vivganes"><img src="https://avatars.githubusercontent.com/u/2035886?v=4&s=48" width="48" height="48" alt="vivganes"></a>
|
||||||
|
<a href="https://github.com/themavik"><img src="https://avatars.githubusercontent.com/u/179817126?v=4&s=48" width="48" height="48" alt="themavik"></a>
|
||||||
|
<a href="https://github.com/flyzstu"><img src="https://avatars.githubusercontent.com/u/94161727?v=4&s=48" width="48" height="48" alt="flyzstu"></a>
|
||||||
|
<a href="https://github.com/pikaqqqqqq"><img src="https://avatars.githubusercontent.com/u/20340136?v=4&s=48" width="48" height="48" alt="pikaqqqqqq"></a>
|
||||||
|
<a href="https://github.com/wyjBot"><img src="https://avatars.githubusercontent.com/u/70993189?v=4&s=48" width="48" height="48" alt="wyjBot"></a>
|
||||||
|
<a href="https://github.com/pblocz"><img src="https://avatars.githubusercontent.com/u/9288574?v=4&s=48" width="48" height="48" alt="pblocz"></a>
|
||||||
|
<a href="https://github.com/niradler"><img src="https://avatars.githubusercontent.com/u/6292980?v=4&s=48" width="48" height="48" alt="niradler"></a>
|
||||||
|
<a href="https://github.com/longle325"><img src="https://avatars.githubusercontent.com/u/140832783?v=4&s=48" width="48" height="48" alt="longle325"></a>
|
||||||
|
<a href="https://github.com/primit1v0"><img src="https://avatars.githubusercontent.com/u/119784372?v=4&s=48" width="48" height="48" alt="primit1v0"></a>
|
||||||
|
<a href="https://github.com/honjiaxuan"><img src="https://avatars.githubusercontent.com/u/13818528?v=4&s=48" width="48" height="48" alt="honjiaxuan"></a>
|
||||||
|
<a href="https://github.com/DeeJ4yNg"><img src="https://avatars.githubusercontent.com/u/99658722?v=4&s=48" width="48" height="48" alt="DeeJ4yNg"></a>
|
||||||
|
<a href="https://github.com/danielphang"><img src="https://avatars.githubusercontent.com/u/1204069?v=4&s=48" width="48" height="48" alt="danielphang"></a>
|
||||||
|
<a href="https://github.com/yanalialiuk"><img src="https://avatars.githubusercontent.com/u/193742981?v=4&s=48" width="48" height="48" alt="yanalialiuk"></a>
|
||||||
|
<a href="https://github.com/zhouzhuojie"><img src="https://avatars.githubusercontent.com/u/658840?v=4&s=48" width="48" height="48" alt="zhouzhuojie"></a>
|
||||||
|
<a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x"></a>
|
||||||
|
<a href="https://github.com/yrk111222"><img src="https://avatars.githubusercontent.com/u/185151020?v=4&s=48" width="48" height="48" alt="yrk111222"></a>
|
||||||
|
<a href="https://github.com/Xerxes-cn"><img src="https://avatars.githubusercontent.com/u/58462889?v=4&s=48" width="48" height="48" alt="Xerxes-cn"></a>
|
||||||
|
<a href="https://github.com/suger-m"><img src="https://avatars.githubusercontent.com/u/240725677?v=4&s=48" width="48" height="48" alt="suger-m"></a>
|
||||||
|
<a href="https://github.com/mengyhang"><img src="https://avatars.githubusercontent.com/u/148381938?v=4&s=48" width="48" height="48" alt="mengyhang"></a>
|
||||||
|
<a href="https://github.com/Liwx1014"><img src="https://avatars.githubusercontent.com/u/186271593?v=4&s=48" width="48" height="48" alt="Liwx1014"></a>
|
||||||
|
<a href="https://github.com/Shizoqua"><img src="https://avatars.githubusercontent.com/u/136805224?v=4&s=48" width="48" height="48" alt="Shizoqua"></a>
|
||||||
|
<a href="https://github.com/KailBug"><img src="https://avatars.githubusercontent.com/u/66873219?v=4&s=48" width="48" height="48" alt="KailBug"></a>
|
||||||
|
<a href="https://github.com/19emtuck"><img src="https://avatars.githubusercontent.com/u/956861?v=4&s=48" width="48" height="48" alt="19emtuck"></a>
|
||||||
|
<a href="https://github.com/tsubasakong"><img src="https://avatars.githubusercontent.com/u/97429702?v=4&s=48" width="48" height="48" alt="tsubasakong"></a>
|
||||||
|
<a href="https://github.com/wseng"><img src="https://avatars.githubusercontent.com/u/6572161?v=4&s=48" width="48" height="48" alt="wseng"></a>
|
||||||
|
<a href="https://github.com/3927o"><img src="https://avatars.githubusercontent.com/u/53431636?v=4&s=48" width="48" height="48" alt="3927o"></a>
|
||||||
|
<a href="https://github.com/FloRainRJY"><img src="https://avatars.githubusercontent.com/u/146079207?v=4&s=48" width="48" height="48" alt="FloRainRJY"></a>
|
||||||
|
<a href="https://github.com/agbocsardi"><img src="https://avatars.githubusercontent.com/u/17645046?v=4&s=48" width="48" height="48" alt="agbocsardi"></a>
|
||||||
|
<a href="https://github.com/JilunSun7274"><img src="https://avatars.githubusercontent.com/u/268303062?v=4&s=48" width="48" height="48" alt="JilunSun7274"></a>
|
||||||
|
<a href="https://github.com/dvejmz"><img src="https://avatars.githubusercontent.com/u/9487006?v=4&s=48" width="48" height="48" alt="dvejmz"></a>
|
||||||
|
<a href="https://github.com/ddadaal"><img src="https://avatars.githubusercontent.com/u/8363856?v=4&s=48" width="48" height="48" alt="ddadaal"></a>
|
||||||
|
<a href="https://github.com/jiehaoZ"><img src="https://avatars.githubusercontent.com/u/51368211?v=4&s=48" width="48" height="48" alt="jiehaoZ"></a>
|
||||||
|
<a href="https://github.com/Lbin91"><img src="https://avatars.githubusercontent.com/u/26209763?v=4&s=48" width="48" height="48" alt="Lbin91"></a>
|
||||||
|
<a href="https://github.com/Alex-yang00"><img src="https://avatars.githubusercontent.com/u/57132813?v=4&s=48" width="48" height="48" alt="Alex-yang00"></a>
|
||||||
|
<a href="https://github.com/xek"><img src="https://avatars.githubusercontent.com/u/107911?v=4&s=48" width="48" height="48" alt="xek"></a>
|
||||||
|
<a href="https://github.com/Harvey-Mackie"><img src="https://avatars.githubusercontent.com/u/38426388?v=4&s=48" width="48" height="48" alt="Harvey-Mackie"></a>
|
||||||
|
<a href="https://github.com/chenyahui"><img src="https://avatars.githubusercontent.com/u/6067594?v=4&s=48" width="48" height="48" alt="chenyahui"></a>
|
||||||
|
<a href="https://github.com/angleyanalbedo"><img src="https://avatars.githubusercontent.com/u/100198247?v=4&s=48" width="48" height="48" alt="angleyanalbedo"></a>
|
||||||
|
<a href="https://github.com/adabarbulescu"><img src="https://avatars.githubusercontent.com/u/94562950?v=4&s=48" width="48" height="48" alt="adabarbulescu"></a>
|
||||||
|
<a href="https://github.com/yoheinishikubo"><img src="https://avatars.githubusercontent.com/u/17715848?v=4&s=48" width="48" height="48" alt="yoheinishikubo"></a>
|
||||||
|
<a href="https://github.com/WormW"><img src="https://avatars.githubusercontent.com/u/24667814?v=4&s=48" width="48" height="48" alt="WormW"></a>
|
||||||
|
<a href="https://github.com/WhalerO"><img src="https://avatars.githubusercontent.com/u/68461696?v=4&s=48" width="48" height="48" alt="WhalerO"></a>
|
||||||
|
<a href="https://github.com/thomya"><img src="https://avatars.githubusercontent.com/u/5235056?v=4&s=48" width="48" height="48" alt="thomya"></a>
|
||||||
|
<a href="https://github.com/Tejas1Koli"><img src="https://avatars.githubusercontent.com/u/181818824?v=4&s=48" width="48" height="48" alt="Tejas1Koli"></a>
|
||||||
|
<a href="https://github.com/Seeratul"><img src="https://avatars.githubusercontent.com/u/126798754?v=4&s=48" width="48" height="48" alt="Seeratul"></a>
|
||||||
|
<a href="https://github.com/SJK-py"><img src="https://avatars.githubusercontent.com/u/201669535?v=4&s=48" width="48" height="48" alt="SJK-py"></a>
|
||||||
|
<a href="https://github.com/RongLei-intel"><img src="https://avatars.githubusercontent.com/u/81341556?v=4&s=48" width="48" height="48" alt="RongLei-intel"></a>
|
||||||
|
<a href="https://github.com/QQQ300kuai"><img src="https://avatars.githubusercontent.com/u/55626566?v=4&s=48" width="48" height="48" alt="QQQ300kuai"></a>
|
||||||
|
<a href="https://github.com/MiguelPF"><img src="https://avatars.githubusercontent.com/u/1163236?v=4&s=48" width="48" height="48" alt="MiguelPF"></a>
|
||||||
|
<a href="https://github.com/mterhar"><img src="https://avatars.githubusercontent.com/u/938684?v=4&s=48" width="48" height="48" alt="mterhar"></a>
|
||||||
|
<a href="https://github.com/Pringlas"><img src="https://avatars.githubusercontent.com/u/28577663?v=4&s=48" width="48" height="48" alt="Pringlas"></a>
|
||||||
|
<a href="https://github.com/pjbakker"><img src="https://avatars.githubusercontent.com/u/1267780?v=4&s=48" width="48" height="48" alt="pjbakker"></a>
|
||||||
|
<a href="https://github.com/luojiaaoo"><img src="https://avatars.githubusercontent.com/u/62821977?v=4&s=48" width="48" height="48" alt="luojiaaoo"></a>
|
||||||
|
<a href="https://github.com/NearlCrews"><img src="https://avatars.githubusercontent.com/u/23341701?v=4&s=48" width="48" height="48" alt="NearlCrews"></a>
|
||||||
|
<a href="https://github.com/yongPhone"><img src="https://avatars.githubusercontent.com/u/29919651?v=4&s=48" width="48" height="48" alt="yongPhone"></a>
|
||||||
|
<a href="https://github.com/ZXGERIC"><img src="https://avatars.githubusercontent.com/u/25354180?v=4&s=48" width="48" height="48" alt="ZXGERIC"></a>
|
||||||
|
<a href="https://github.com/erikmackinnon"><img src="https://avatars.githubusercontent.com/u/40612473?v=4&s=48" width="48" height="48" alt="erikmackinnon"></a>
|
||||||
|
<a href="https://github.com/rickererer"><img src="https://avatars.githubusercontent.com/u/289160634?v=4&s=48" width="48" height="48" alt="rickererer"></a>
|
||||||
|
<a href="https://github.com/ferkans-amir"><img src="https://avatars.githubusercontent.com/u/212877286?v=4&s=48" width="48" height="48" alt="ferkans-amir"></a>
|
||||||
|
<a href="https://github.com/for13to1"><img src="https://avatars.githubusercontent.com/u/115892874?v=4&s=48" width="48" height="48" alt="for13to1"></a>
|
||||||
|
<a href="https://github.com/futurist"><img src="https://avatars.githubusercontent.com/u/159167?v=4&s=48" width="48" height="48" alt="futurist"></a>
|
||||||
|
<a href="https://github.com/Maaannnn"><img src="https://avatars.githubusercontent.com/u/105716414?v=4&s=48" width="48" height="48" alt="Maaannnn"></a>
|
||||||
|
<a href="https://github.com/rubychilds"><img src="https://avatars.githubusercontent.com/u/1305077?v=4&s=48" width="48" height="48" alt="rubychilds"></a>
|
||||||
|
<a href="https://github.com/init-new-world"><img src="https://avatars.githubusercontent.com/u/36530844?v=4&s=48" width="48" height="48" alt="init-new-world"></a>
|
||||||
|
<a href="https://github.com/Idealist17"><img src="https://avatars.githubusercontent.com/u/55554642?v=4&s=48" width="48" height="48" alt="Idealist17"></a>
|
||||||
|
<a href="https://github.com/gola"><img src="https://avatars.githubusercontent.com/u/31429180?v=4&s=48" width="48" height="48" alt="gola"></a>
|
||||||
|
<a href="https://github.com/greyishsong"><img src="https://avatars.githubusercontent.com/u/49446254?v=4&s=48" width="48" height="48" alt="greyishsong"></a>
|
||||||
|
<a href="https://github.com/h4nz4"><img src="https://avatars.githubusercontent.com/u/18464660?v=4&s=48" width="48" height="48" alt="h4nz4"></a>
|
||||||
|
<a href="https://github.com/hoaresky"><img src="https://avatars.githubusercontent.com/u/25839923?v=4&s=48" width="48" height="48" alt="hoaresky"></a>
|
||||||
|
<a href="https://github.com/hcanyz"><img src="https://avatars.githubusercontent.com/u/8407922?v=4&s=48" width="48" height="48" alt="hcanyz"></a>
|
||||||
|
<a href="https://github.com/himax12"><img src="https://avatars.githubusercontent.com/u/122690580?v=4&s=48" width="48" height="48" alt="himax12"></a>
|
||||||
|
<a href="https://github.com/Zeknes"><img src="https://avatars.githubusercontent.com/u/117632598?v=4&s=48" width="48" height="48" alt="Zeknes"></a>
|
||||||
|
<a href="https://github.com/ZhihaoZhang97"><img src="https://avatars.githubusercontent.com/u/31653817?v=4&s=48" width="48" height="48" alt="ZhihaoZhang97"></a>
|
||||||
|
<a href="https://github.com/linziyanleo"><img src="https://avatars.githubusercontent.com/u/24808982?v=4&s=48" width="48" height="48" alt="linziyanleo"></a>
|
||||||
|
<a href="https://github.com/akinolur"><img src="https://avatars.githubusercontent.com/u/126256260?v=4&s=48" width="48" height="48" alt="akinolur"></a>
|
||||||
|
<a href="https://github.com/alairjt"><img src="https://avatars.githubusercontent.com/u/678781?v=4&s=48" width="48" height="48" alt="alairjt"></a>
|
||||||
|
<a href="https://github.com/noki1928"><img src="https://avatars.githubusercontent.com/u/200396425?v=4&s=48" width="48" height="48" alt="noki1928"></a>
|
||||||
|
<a href="https://github.com/barreler126"><img src="https://avatars.githubusercontent.com/u/129291861?v=4&s=48" width="48" height="48" alt="barreler126"></a>
|
||||||
|
<a href="https://github.com/bllackhu"><img src="https://avatars.githubusercontent.com/u/194945539?v=4&s=48" width="48" height="48" alt="bllackhu"></a>
|
||||||
|
<a href="https://github.com/brendanlevy-clarahealth"><img src="https://avatars.githubusercontent.com/u/262887138?v=4&s=48" width="48" height="48" alt="brendanlevy-clarahealth"></a>
|
||||||
|
<a href="https://github.com/cdkey85"><img src="https://avatars.githubusercontent.com/u/7017432?v=4&s=48" width="48" height="48" alt="cdkey85"></a>
|
||||||
|
<a href="https://github.com/ducheng121"><img src="https://avatars.githubusercontent.com/u/134901105?v=4&s=48" width="48" height="48" alt="ducheng121"></a>
|
||||||
|
<a href="https://github.com/SamZhu19921116"><img src="https://avatars.githubusercontent.com/u/39799950?v=4&s=48" width="48" height="48" alt="SamZhu19921116"></a>
|
||||||
|
<a href="https://github.com/justforyoudear"><img src="https://avatars.githubusercontent.com/u/50271514?v=4&s=48" width="48" height="48" alt="justforyoudear"></a>
|
||||||
|
<a href="https://github.com/choiking"><img src="https://avatars.githubusercontent.com/u/13400202?v=4&s=48" width="48" height="48" alt="choiking"></a>
|
||||||
|
<a href="https://github.com/cocolato"><img src="https://avatars.githubusercontent.com/u/35182391?v=4&s=48" width="48" height="48" alt="cocolato"></a>
|
||||||
|
<a href="https://github.com/luomaohao"><img src="https://avatars.githubusercontent.com/u/36148933?v=4&s=48" width="48" height="48" alt="luomaohao"></a>
|
||||||
|
<a href="https://github.com/codedragoncom"><img src="https://avatars.githubusercontent.com/u/5353092?v=4&s=48" width="48" height="48" alt="codedragoncom"></a>
|
||||||
|
<a href="https://github.com/d1ago"><img src="https://avatars.githubusercontent.com/u/143279157?v=4&s=48" width="48" height="48" alt="d1ago"></a>
|
||||||
|
<a href="https://github.com/daliu858"><img src="https://avatars.githubusercontent.com/u/213729810?v=4&s=48" width="48" height="48" alt="daliu858"></a>
|
||||||
|
<a href="https://github.com/danielyangfei"><img src="https://avatars.githubusercontent.com/u/9975680?v=4&s=48" width="48" height="48" alt="danielyangfei"></a>
|
||||||
|
<a href="https://github.com/dingyanyi2019"><img src="https://avatars.githubusercontent.com/u/230930993?v=4&s=48" width="48" height="48" alt="dingyanyi2019"></a>
|
||||||
|
<a href="https://github.com/djmaze"><img src="https://avatars.githubusercontent.com/u/7229?v=4&s=48" width="48" height="48" alt="djmaze"></a>
|
||||||
|
<a href="https://github.com/dulltackle"><img src="https://avatars.githubusercontent.com/u/45963660?v=4&s=48" width="48" height="48" alt="dulltackle"></a>
|
||||||
|
<a href="https://github.com/samyzhh"><img src="https://avatars.githubusercontent.com/u/13554741?v=4&s=48" width="48" height="48" alt="samyzhh"></a>
|
||||||
|
<a href="https://github.com/shen0122"><img src="https://avatars.githubusercontent.com/u/145903102?v=4&s=48" width="48" height="48" alt="shen0122"></a>
|
||||||
|
<a href="https://github.com/shenchengtsi"><img src="https://avatars.githubusercontent.com/u/228445050?v=4&s=48" width="48" height="48" alt="shenchengtsi"></a>
|
||||||
|
<a href="https://github.com/sidkang"><img src="https://avatars.githubusercontent.com/u/6175895?v=4&s=48" width="48" height="48" alt="sidkang"></a>
|
||||||
|
<a href="https://github.com/skiyo"><img src="https://avatars.githubusercontent.com/u/224273?v=4&s=48" width="48" height="48" alt="skiyo"></a>
|
||||||
|
<a href="https://github.com/sontianye"><img src="https://avatars.githubusercontent.com/u/162393000?v=4&s=48" width="48" height="48" alt="sontianye"></a>
|
||||||
|
<a href="https://github.com/spartan077"><img src="https://avatars.githubusercontent.com/u/118879019?v=4&s=48" width="48" height="48" alt="spartan077"></a>
|
||||||
|
<a href="https://github.com/tercerapersona"><img src="https://avatars.githubusercontent.com/u/16053355?v=4&s=48" width="48" height="48" alt="tercerapersona"></a>
|
||||||
|
<a href="https://github.com/tlguszz1010"><img src="https://avatars.githubusercontent.com/u/62739187?v=4&s=48" width="48" height="48" alt="tlguszz1010"></a>
|
||||||
|
<a href="https://github.com/vandazia"><img src="https://avatars.githubusercontent.com/u/56904192?v=4&s=48" width="48" height="48" alt="vandazia"></a>
|
||||||
|
<a href="https://github.com/vincentchen0x2-dev"><img src="https://avatars.githubusercontent.com/u/262490969?v=4&s=48" width="48" height="48" alt="vincentchen0x2-dev"></a>
|
||||||
|
<a href="https://github.com/tianrking"><img src="https://avatars.githubusercontent.com/u/10758833?v=4&s=48" width="48" height="48" alt="tianrking"></a>
|
||||||
|
<a href="https://github.com/wcmolin"><img src="https://avatars.githubusercontent.com/u/11606262?v=4&s=48" width="48" height="48" alt="wcmolin"></a>
|
||||||
|
<a href="https://github.com/dynames0098"><img src="https://avatars.githubusercontent.com/u/16553686?v=4&s=48" width="48" height="48" alt="dynames0098"></a>
|
||||||
|
<a href="https://github.com/knightconnorp"><img src="https://avatars.githubusercontent.com/u/143191129?v=4&s=48" width="48" height="48" alt="knightconnorp"></a>
|
||||||
|
<a href="https://github.com/wymcmh"><img src="https://avatars.githubusercontent.com/u/5070729?v=4&s=48" width="48" height="48" alt="wymcmh"></a>
|
||||||
|
<a href="https://github.com/weijun-xia"><img src="https://avatars.githubusercontent.com/u/293320877?v=4&s=48" width="48" height="48" alt="weijun-xia"></a>
|
||||||
|
<a href="https://github.com/yaotutu"><img src="https://avatars.githubusercontent.com/u/21394924?v=4&s=48" width="48" height="48" alt="yaotutu"></a>
|
||||||
|
<a href="https://github.com/yeounhyeok"><img src="https://avatars.githubusercontent.com/u/141844100?v=4&s=48" width="48" height="48" alt="yeounhyeok"></a>
|
||||||
|
<a href="https://github.com/Endeavour-Yuan"><img src="https://avatars.githubusercontent.com/u/50094541?v=4&s=48" width="48" height="48" alt="Endeavour-Yuan"></a>
|
||||||
|
<a href="https://github.com/ziuus"><img src="https://avatars.githubusercontent.com/u/64656661?v=4&s=48" width="48" height="48" alt="ziuus"></a>
|
||||||
|
<a href="https://github.com/dsxyy"><img src="https://avatars.githubusercontent.com/u/8911760?v=4&s=48" width="48" height="48" alt="dsxyy"></a>
|
||||||
|
<a href="https://github.com/azhengzz"><img src="https://avatars.githubusercontent.com/u/30361780?v=4&s=48" width="48" height="48" alt="azhengzz"></a>
|
||||||
|
<a href="https://github.com/jhkim43"><img src="https://avatars.githubusercontent.com/u/139941582?v=4&s=48" width="48" height="48" alt="jhkim43"></a>
|
||||||
|
<a href="https://github.com/kimkitsuragi26"><img src="https://avatars.githubusercontent.com/u/263307076?v=4&s=48" width="48" height="48" alt="kimkitsuragi26"></a>
|
||||||
|
<a href="https://github.com/kinchahoy"><img src="https://avatars.githubusercontent.com/u/6504381?v=4&s=48" width="48" height="48" alt="kinchahoy"></a>
|
||||||
|
<a href="https://github.com/A11Might"><img src="https://avatars.githubusercontent.com/u/38397074?v=4&s=48" width="48" height="48" alt="A11Might"></a>
|
||||||
|
<a href="https://github.com/kronk307"><img src="https://avatars.githubusercontent.com/u/264627887?v=4&s=48" width="48" height="48" alt="kronk307"></a>
|
||||||
|
<a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo"></a>
|
||||||
|
<a href="https://github.com/lang07123"><img src="https://avatars.githubusercontent.com/u/7733095?v=4&s=48" width="48" height="48" alt="lang07123"></a>
|
||||||
|
<a href="https://github.com/tetratorus"><img src="https://avatars.githubusercontent.com/u/4226174?v=4&s=48" width="48" height="48" alt="tetratorus"></a>
|
||||||
|
<a href="https://github.com/spinvettel"><img src="https://avatars.githubusercontent.com/u/82635206?v=4&s=48" width="48" height="48" alt="spinvettel"></a>
|
||||||
|
<a href="https://github.com/li-yazhou"><img src="https://avatars.githubusercontent.com/u/17548940?v=4&s=48" width="48" height="48" alt="li-yazhou"></a>
|
||||||
|
<a href="https://github.com/ALIZE126"><img src="https://avatars.githubusercontent.com/u/79365356?v=4&s=48" width="48" height="48" alt="ALIZE126"></a>
|
||||||
|
<a href="https://github.com/Rheasilvia"><img src="https://avatars.githubusercontent.com/u/29389840?v=4&s=48" width="48" height="48" alt="Rheasilvia"></a>
|
||||||
|
<a href="https://github.com/mru4913"><img src="https://avatars.githubusercontent.com/u/31579276?v=4&s=48" width="48" height="48" alt="mru4913"></a>
|
||||||
|
<a href="https://github.com/mt-huerta"><img src="https://avatars.githubusercontent.com/u/5499466?v=4&s=48" width="48" height="48" alt="mt-huerta"></a>
|
||||||
|
<a href="https://github.com/mytechdream"><img src="https://avatars.githubusercontent.com/u/114465679?v=4&s=48" width="48" height="48" alt="mytechdream"></a>
|
||||||
|
<a href="https://github.com/nikube"><img src="https://avatars.githubusercontent.com/u/63295277?v=4&s=48" width="48" height="48" alt="nikube"></a>
|
||||||
|
<a href="https://github.com/npodbielski"><img src="https://avatars.githubusercontent.com/u/796782?v=4&s=48" width="48" height="48" alt="npodbielski"></a>
|
||||||
|
<a href="https://github.com/oriengy"><img src="https://avatars.githubusercontent.com/u/50244473?v=4&s=48" width="48" height="48" alt="oriengy"></a>
|
||||||
|
<a href="https://github.com/popcell"><img src="https://avatars.githubusercontent.com/u/70359868?v=4&s=48" width="48" height="48" alt="popcell"></a>
|
||||||
|
<a href="https://github.com/qixinbo"><img src="https://avatars.githubusercontent.com/u/6218739?v=4&s=48" width="48" height="48" alt="qixinbo"></a>
|
||||||
|
<a href="https://github.com/qulllee"><img src="https://avatars.githubusercontent.com/u/113170232?v=4&s=48" width="48" height="48" alt="qulllee"></a>
|
||||||
|
<a href="https://github.com/rav-melisono"><img src="https://avatars.githubusercontent.com/u/165779938?v=4&s=48" width="48" height="48" alt="rav-melisono"></a>
|
||||||
|
<a href="https://github.com/razzh7"><img src="https://avatars.githubusercontent.com/u/67299806?v=4&s=48" width="48" height="48" alt="razzh7"></a>
|
||||||
|
<a href="https://github.com/rise2689"><img src="https://avatars.githubusercontent.com/u/268597299?v=4&s=48" width="48" height="48" alt="rise2689"></a>
|
||||||
|
<a href="https://github.com/hlibr"><img src="https://avatars.githubusercontent.com/u/5793607?v=4&s=48" width="48" height="48" alt="hlibr"></a>
|
||||||
|
<a href="https://github.com/gthieleb"><img src="https://avatars.githubusercontent.com/u/21332468?v=4&s=48" width="48" height="48" alt="gthieleb"></a>
|
||||||
|
<a href="https://github.com/Rafa-Ross"><img src="https://avatars.githubusercontent.com/u/279471146?v=4&s=48" width="48" height="48" alt="Rafa-Ross"></a>
|
||||||
|
<a href="https://github.com/korruz"><img src="https://avatars.githubusercontent.com/u/79794883?v=4&s=48" width="48" height="48" alt="korruz"></a>
|
||||||
|
<a href="https://github.com/hyudryu"><img src="https://avatars.githubusercontent.com/u/22283864?v=4&s=48" width="48" height="48" alt="hyudryu"></a>
|
||||||
|
<a href="https://github.com/breitburg"><img src="https://avatars.githubusercontent.com/u/25728414?v=4&s=48" width="48" height="48" alt="breitburg"></a>
|
||||||
|
<a href="https://github.com/IlyaSemenov"><img src="https://avatars.githubusercontent.com/u/128121?v=4&s=48" width="48" height="48" alt="IlyaSemenov"></a>
|
||||||
|
<a href="https://github.com/Tevkanbot"><img src="https://avatars.githubusercontent.com/u/143351134?v=4&s=48" width="48" height="48" alt="Tevkanbot"></a>
|
||||||
|
<a href="https://github.com/JakeRowe19"><img src="https://avatars.githubusercontent.com/u/117069245?v=4&s=48" width="48" height="48" alt="JakeRowe19"></a>
|
||||||
|
<a href="https://github.com/JamesWrigley"><img src="https://avatars.githubusercontent.com/u/5361518?v=4&s=48" width="48" height="48" alt="JamesWrigley"></a>
|
||||||
|
<a href="https://github.com/La-Volpe"><img src="https://avatars.githubusercontent.com/u/5852615?v=4&s=48" width="48" height="48" alt="La-Volpe"></a>
|
||||||
|
<a href="https://github.com/JavisPeng"><img src="https://avatars.githubusercontent.com/u/18676680?v=4&s=48" width="48" height="48" alt="JavisPeng"></a>
|
||||||
|
<a href="https://github.com/Jefsky"><img src="https://avatars.githubusercontent.com/u/7386165?v=4&s=48" width="48" height="48" alt="Jefsky"></a>
|
||||||
|
<a href="https://github.com/letzdoo-js"><img src="https://avatars.githubusercontent.com/u/12003829?v=4&s=48" width="48" height="48" alt="letzdoo-js"></a>
|
||||||
|
<a href="https://github.com/95256155o"><img src="https://avatars.githubusercontent.com/u/74103710?v=4&s=48" width="48" height="48" alt="95256155o"></a>
|
||||||
|
<a href="https://github.com/joel611"><img src="https://avatars.githubusercontent.com/u/5180124?v=4&s=48" width="48" height="48" alt="joel611"></a>
|
||||||
|
<a href="https://github.com/NiceLargeHuo"><img src="https://avatars.githubusercontent.com/u/306099191?v=4&s=48" width="48" height="48" alt="NiceLargeHuo"></a>
|
||||||
|
<a href="https://github.com/kamalakarrao"><img src="https://avatars.githubusercontent.com/u/15045455?v=4&s=48" width="48" height="48" alt="kamalakarrao"></a>
|
||||||
|
<a href="https://github.com/KEEPSLAMDUNK"><img src="https://avatars.githubusercontent.com/u/155275575?v=4&s=48" width="48" height="48" alt="KEEPSLAMDUNK"></a>
|
||||||
|
<a href="https://github.com/krisLu"><img src="https://avatars.githubusercontent.com/u/92515202?v=4&s=48" width="48" height="48" alt="krisLu"></a>
|
||||||
|
<a href="https://github.com/Krislu1221"><img src="https://avatars.githubusercontent.com/u/258380416?v=4&s=48" width="48" height="48" alt="Krislu1221"></a>
|
||||||
|
<a href="https://github.com/kyya"><img src="https://avatars.githubusercontent.com/u/13448248?v=4&s=48" width="48" height="48" alt="kyya"></a>
|
||||||
|
<a href="https://github.com/rreben"><img src="https://avatars.githubusercontent.com/u/4026131?v=4&s=48" width="48" height="48" alt="rreben"></a>
|
||||||
|
<a href="https://github.com/ATECHPCS"><img src="https://avatars.githubusercontent.com/u/125108010?v=4&s=48" width="48" height="48" alt="ATECHPCS"></a>
|
||||||
|
<a href="https://github.com/adrianhoehne"><img src="https://avatars.githubusercontent.com/u/19731088?v=4&s=48" width="48" height="48" alt="adrianhoehne"></a>
|
||||||
|
<a href="https://github.com/Aisht669"><img src="https://avatars.githubusercontent.com/u/36147411?v=4&s=48" width="48" height="48" alt="Aisht669"></a>
|
||||||
|
<a href="https://github.com/AlbertWang688"><img src="https://avatars.githubusercontent.com/u/36430404?v=4&s=48" width="48" height="48" alt="AlbertWang688"></a>
|
||||||
|
<a href="https://github.com/AlexanderMerkel"><img src="https://avatars.githubusercontent.com/u/105279319?v=4&s=48" width="48" height="48" alt="AlexanderMerkel"></a>
|
||||||
|
<a href="https://github.com/khmylov"><img src="https://avatars.githubusercontent.com/u/1044282?v=4&s=48" width="48" height="48" alt="khmylov"></a>
|
||||||
|
<a href="https://github.com/karimluna"><img src="https://avatars.githubusercontent.com/u/195384419?v=4&s=48" width="48" height="48" alt="karimluna"></a>
|
||||||
|
<a href="https://github.com/Bayern4ever-dot"><img src="https://avatars.githubusercontent.com/u/67447782?v=4&s=48" width="48" height="48" alt="Bayern4ever-dot"></a>
|
||||||
|
<a href="https://github.com/abhinavaditya811"><img src="https://avatars.githubusercontent.com/u/40894851?v=4&s=48" width="48" height="48" alt="abhinavaditya811"></a>
|
||||||
|
<a href="https://github.com/bjoshuanoah"><img src="https://avatars.githubusercontent.com/u/1885253?v=4&s=48" width="48" height="48" alt="bjoshuanoah"></a>
|
||||||
|
<a href="https://github.com/hanouticelina"><img src="https://avatars.githubusercontent.com/u/36770234?v=4&s=48" width="48" height="48" alt="hanouticelina"></a>
|
||||||
|
<a href="https://github.com/quanmou"><img src="https://avatars.githubusercontent.com/u/7821404?v=4&s=48" width="48" height="48" alt="quanmou"></a>
|
||||||
|
<a href="https://github.com/ClaytonWWilson"><img src="https://avatars.githubusercontent.com/u/31804874?v=4&s=48" width="48" height="48" alt="ClaytonWWilson"></a>
|
||||||
|
<a href="https://github.com/kaseru"><img src="https://avatars.githubusercontent.com/u/5975972?v=4&s=48" width="48" height="48" alt="kaseru"></a>
|
||||||
|
<a href="https://github.com/danielemden"><img src="https://avatars.githubusercontent.com/u/265470?v=4&s=48" width="48" height="48" alt="danielemden"></a>
|
||||||
|
<a href="https://github.com/dmarkey"><img src="https://avatars.githubusercontent.com/u/1159924?v=4&s=48" width="48" height="48" alt="dmarkey"></a>
|
||||||
|
<a href="https://github.com/desmondsow"><img src="https://avatars.githubusercontent.com/u/7720601?v=4&s=48" width="48" height="48" alt="desmondsow"></a>
|
||||||
|
<a href="https://github.com/intelliot"><img src="https://avatars.githubusercontent.com/u/81505?v=4&s=48" width="48" height="48" alt="intelliot"></a>
|
||||||
|
<a href="https://github.com/eugenechae"><img src="https://avatars.githubusercontent.com/u/1910247?v=4&s=48" width="48" height="48" alt="eugenechae"></a>
|
||||||
|
<a href="https://github.com/ehs208"><img src="https://avatars.githubusercontent.com/u/109217208?v=4&s=48" width="48" height="48" alt="ehs208"></a>
|
||||||
|
<a href="https://github.com/Felix8568"><img src="https://avatars.githubusercontent.com/u/86166271?v=4&s=48" width="48" height="48" alt="Felix8568"></a>
|
||||||
|
<a href="https://github.com/GabrielWithTina"><img src="https://avatars.githubusercontent.com/u/2384004?v=4&s=48" width="48" height="48" alt="GabrielWithTina"></a>
|
||||||
|
<a href="https://github.com/georgeatparallel"><img src="https://avatars.githubusercontent.com/u/297992784?v=4&s=48" width="48" height="48" alt="georgeatparallel"></a>
|
||||||
|
<a href="https://github.com/SHLE1"><img src="https://avatars.githubusercontent.com/u/101321085?v=4&s=48" width="48" height="48" alt="SHLE1"></a>
|
||||||
|
<a href="https://github.com/lzmjlrt"><img src="https://avatars.githubusercontent.com/u/62170398?v=4&s=48" width="48" height="48" alt="lzmjlrt"></a>
|
||||||
|
<a href="https://github.com/saimonventura"><img src="https://avatars.githubusercontent.com/u/3719710?v=4&s=48" width="48" height="48" alt="saimonventura"></a>
|
||||||
|
<a href="https://github.com/SIDD-KIDD"><img src="https://avatars.githubusercontent.com/u/162047739?v=4&s=48" width="48" height="48" alt="SIDD-KIDD"></a>
|
||||||
|
<a href="https://github.com/sihyeonn"><img src="https://avatars.githubusercontent.com/u/24850223?v=4&s=48" width="48" height="48" alt="sihyeonn"></a>
|
||||||
|
<a href="https://github.com/Seym0n"><img src="https://avatars.githubusercontent.com/u/119116740?v=4&s=48" width="48" height="48" alt="Seym0n"></a>
|
||||||
|
<a href="https://github.com/fyhertz"><img src="https://avatars.githubusercontent.com/u/2746007?v=4&s=48" width="48" height="48" alt="fyhertz"></a>
|
||||||
|
<a href="https://github.com/sohamb117"><img src="https://avatars.githubusercontent.com/u/36938330?v=4&s=48" width="48" height="48" alt="sohamb117"></a>
|
||||||
|
<a href="https://github.com/Solaris-star"><img src="https://avatars.githubusercontent.com/u/67425364?v=4&s=48" width="48" height="48" alt="Solaris-star"></a>
|
||||||
|
<a href="https://github.com/Syoc"><img src="https://avatars.githubusercontent.com/u/9057210?v=4&s=48" width="48" height="48" alt="Syoc"></a>
|
||||||
|
<a href="https://github.com/tedyyan"><img src="https://avatars.githubusercontent.com/u/2662290?v=4&s=48" width="48" height="48" alt="tedyyan"></a>
|
||||||
|
<a href="https://github.com/xuayan-nokia"><img src="https://avatars.githubusercontent.com/u/87028154?v=4&s=48" width="48" height="48" alt="xuayan-nokia"></a>
|
||||||
|
<a href="https://github.com/TheAutomatic"><img src="https://avatars.githubusercontent.com/u/5350578?v=4&s=48" width="48" height="48" alt="TheAutomatic"></a>
|
||||||
|
<a href="https://github.com/TomLisankie"><img src="https://avatars.githubusercontent.com/u/92654?v=4&s=48" width="48" height="48" alt="TomLisankie"></a>
|
||||||
|
<a href="https://github.com/tamvicky"><img src="https://avatars.githubusercontent.com/u/9824871?v=4&s=48" width="48" height="48" alt="tamvicky"></a>
|
||||||
|
<a href="https://github.com/MVS-source"><img src="https://avatars.githubusercontent.com/u/72023257?v=4&s=48" width="48" height="48" alt="MVS-source"></a>
|
||||||
|
<a href="https://github.com/wingkwong"><img src="https://avatars.githubusercontent.com/u/35857179?v=4&s=48" width="48" height="48" alt="wingkwong"></a>
|
||||||
|
<a href="https://github.com/wenjielei1990"><img src="https://avatars.githubusercontent.com/u/182426847?v=4&s=48" width="48" height="48" alt="wenjielei1990"></a>
|
||||||
|
<a href="https://github.com/Wenzhang-Chen"><img src="https://avatars.githubusercontent.com/u/212304734?v=4&s=48" width="48" height="48" alt="Wenzhang-Chen"></a>
|
||||||
|
<a href="https://github.com/wesleyzhangwq"><img src="https://avatars.githubusercontent.com/u/275724973?v=4&s=48" width="48" height="48" alt="wesleyzhangwq"></a>
|
||||||
|
<a href="https://github.com/XiaoHuo888-hue"><img src="https://avatars.githubusercontent.com/u/315183888?v=4&s=48" width="48" height="48" alt="XiaoHuo888-hue"></a>
|
||||||
|
<a href="https://github.com/Lyt060814"><img src="https://avatars.githubusercontent.com/u/182195098?v=4&s=48" width="48" height="48" alt="Lyt060814"></a>
|
||||||
|
<a href="https://github.com/limdingwen"><img src="https://avatars.githubusercontent.com/u/1744967?v=4&s=48" width="48" height="48" alt="limdingwen"></a>
|
||||||
|
<a href="https://github.com/luc-nguyen-cake"><img src="https://avatars.githubusercontent.com/u/308145655?v=4&s=48" width="48" height="48" alt="luc-nguyen-cake"></a>
|
||||||
|
<a href="https://github.com/maciejwojcik86"><img src="https://avatars.githubusercontent.com/u/122781058?v=4&s=48" width="48" height="48" alt="maciejwojcik86"></a>
|
||||||
|
<a href="https://github.com/mvanhorn"><img src="https://avatars.githubusercontent.com/u/455140?v=4&s=48" width="48" height="48" alt="mvanhorn"></a>
|
||||||
|
<a href="https://github.com/twiddles"><img src="https://avatars.githubusercontent.com/u/242461?v=4&s=48" width="48" height="48" alt="twiddles"></a>
|
||||||
|
<a href="https://github.com/maxmilian"><img src="https://avatars.githubusercontent.com/u/3001335?v=4&s=48" width="48" height="48" alt="maxmilian"></a>
|
||||||
|
<a href="https://github.com/mrbob-git"><img src="https://avatars.githubusercontent.com/u/202024716?v=4&s=48" width="48" height="48" alt="mrbob-git"></a>
|
||||||
|
<a href="https://github.com/MuataSr"><img src="https://avatars.githubusercontent.com/u/177951810?v=4&s=48" width="48" height="48" alt="MuataSr"></a>
|
||||||
|
<a href="https://github.com/Neutralmilkzzz"><img src="https://avatars.githubusercontent.com/u/216463318?v=4&s=48" width="48" height="48" alt="Neutralmilkzzz"></a>
|
||||||
|
<a href="https://github.com/nblondiau"><img src="https://avatars.githubusercontent.com/u/6884594?v=4&s=48" width="48" height="48" alt="nblondiau"></a>
|
||||||
|
<a href="https://github.com/omdv"><img src="https://avatars.githubusercontent.com/u/4576131?v=4&s=48" width="48" height="48" alt="omdv"></a>
|
||||||
|
<a href="https://github.com/rbankole"><img src="https://avatars.githubusercontent.com/u/25436617?v=4&s=48" width="48" height="48" alt="rbankole"></a>
|
||||||
|
<a href="https://github.com/orrinwitt"><img src="https://avatars.githubusercontent.com/u/9917194?v=4&s=48" width="48" height="48" alt="orrinwitt"></a>
|
||||||
|
<a href="https://github.com/pjperez"><img src="https://avatars.githubusercontent.com/u/747936?v=4&s=48" width="48" height="48" alt="pjperez"></a>
|
||||||
|
<a href="https://github.com/pve"><img src="https://avatars.githubusercontent.com/u/37116?v=4&s=48" width="48" height="48" alt="pve"></a>
|
||||||
|
<a href="https://github.com/PeterDaveHello"><img src="https://avatars.githubusercontent.com/u/3691490?v=4&s=48" width="48" height="48" alt="PeterDaveHello"></a>
|
||||||
|
<a href="https://github.com/Molunerfinn"><img src="https://avatars.githubusercontent.com/u/12621342?v=4&s=48" width="48" height="48" alt="Molunerfinn"></a>
|
||||||
|
<a href="https://github.com/power88"><img src="https://avatars.githubusercontent.com/u/24859241?v=4&s=48" width="48" height="48" alt="power88"></a>
|
||||||
|
<a href="https://github.com/srajasimman"><img src="https://avatars.githubusercontent.com/u/15092596?v=4&s=48" width="48" height="48" alt="srajasimman"></a>
|
||||||
|
<a href="https://github.com/groudas"><img src="https://avatars.githubusercontent.com/u/18154989?v=4&s=48" width="48" height="48" alt="groudas"></a>
|
||||||
|
<a href="https://github.com/RohitDayanand"><img src="https://avatars.githubusercontent.com/u/66650100?v=4&s=48" width="48" height="48" alt="RohitDayanand"></a>
|
||||||
|
<a href="https://github.com/katafractari"><img src="https://avatars.githubusercontent.com/u/1299228?v=4&s=48" width="48" height="48" alt="katafractari"></a>
|
||||||
|
<a href="https://github.com/rudy-of-the-corner"><img src="https://avatars.githubusercontent.com/u/188991374?v=4&s=48" width="48" height="48" alt="rudy-of-the-corner"></a>
|
||||||
|
</p>
|
||||||
|
<!-- contributors:end -->
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em> Thanks for visiting ✨ nanobot!</em><br><br>
|
<em> Thanks for visiting ✨ nanobot!</em><br><br>
|
||||||
|
|||||||
+112
-143
@@ -79,6 +79,15 @@ from nanobot.session.model_selection import (
|
|||||||
SESSION_MODEL_PRESET_METADATA_KEY,
|
SESSION_MODEL_PRESET_METADATA_KEY,
|
||||||
model_preset_from_metadata,
|
model_preset_from_metadata,
|
||||||
)
|
)
|
||||||
|
from nanobot.session.recovery import (
|
||||||
|
PENDING_FOLLOWUP_ID_KEY,
|
||||||
|
RECOVERY_INBOUND_METADATA_KEY,
|
||||||
|
RecoveryAdmission,
|
||||||
|
acknowledge_pending_followups,
|
||||||
|
record_pending_followup,
|
||||||
|
restore_pending_interruption,
|
||||||
|
restore_runtime_checkpoint,
|
||||||
|
)
|
||||||
from nanobot.session.summary import SessionSummary
|
from nanobot.session.summary import SessionSummary
|
||||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
||||||
from nanobot.utils.cancellation import task_is_cancelling
|
from nanobot.utils.cancellation import task_is_cancelling
|
||||||
@@ -291,12 +300,14 @@ class AgentLoop:
|
|||||||
restart_mode: str = "auto",
|
restart_mode: str = "auto",
|
||||||
local_trigger_store: LocalTriggerStore | None = None,
|
local_trigger_store: LocalTriggerStore | None = None,
|
||||||
idle_compact_check_interval_seconds: int = 0,
|
idle_compact_check_interval_seconds: int = 0,
|
||||||
|
recovery_admission: RecoveryAdmission | None = None,
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
_tc = tools_config or ToolsConfig()
|
_tc = tools_config or ToolsConfig()
|
||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
|
self._recovery_admission = recovery_admission
|
||||||
if turn_delivery_factory is not None:
|
if turn_delivery_factory is not None:
|
||||||
if turn_delivery_factory.bus is not bus:
|
if turn_delivery_factory.bus is not bus:
|
||||||
raise ValueError("turn delivery factory must use the agent message bus")
|
raise ValueError("turn delivery factory must use the agent message bus")
|
||||||
@@ -409,6 +420,7 @@ class AgentLoop:
|
|||||||
# When a session has an active task, new messages for that session
|
# When a session has an active task, new messages for that session
|
||||||
# are routed here instead of creating a new task.
|
# are routed here instead of creating a new task.
|
||||||
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
|
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
|
||||||
|
self._preserve_inflight_turns_on_shutdown = False
|
||||||
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
||||||
self._cron_turns = CronTurnCoordinator(
|
self._cron_turns = CronTurnCoordinator(
|
||||||
publish_inbound=self.bus.publish_inbound,
|
publish_inbound=self.bus.publish_inbound,
|
||||||
@@ -726,6 +738,9 @@ class AgentLoop:
|
|||||||
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
self._mark_pending_user_turn(session)
|
self._mark_pending_user_turn(session)
|
||||||
|
followup_id = msg.metadata.get(PENDING_FOLLOWUP_ID_KEY)
|
||||||
|
if isinstance(followup_id, str) and followup_id:
|
||||||
|
acknowledge_pending_followups(session, [followup_id])
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -1061,6 +1076,9 @@ class AgentLoop:
|
|||||||
row["subagent_task_id"] = task_id
|
row["subagent_task_id"] = task_id
|
||||||
row[HIDDEN_HISTORY_META] = subagent_marker
|
row[HIDDEN_HISTORY_META] = subagent_marker
|
||||||
row["injected_event"] = "subagent_result"
|
row["injected_event"] = "subagent_result"
|
||||||
|
followup_id = metadata.get(PENDING_FOLLOWUP_ID_KEY)
|
||||||
|
if isinstance(followup_id, str) and followup_id:
|
||||||
|
row[PENDING_FOLLOWUP_ID_KEY] = followup_id
|
||||||
return row
|
return row
|
||||||
|
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
@@ -1285,6 +1303,23 @@ class AgentLoop:
|
|||||||
break
|
break
|
||||||
if deferred:
|
if deferred:
|
||||||
continue
|
continue
|
||||||
|
routed_msg = msg
|
||||||
|
if effective_key != msg.session_key:
|
||||||
|
routed_msg = dataclasses.replace(
|
||||||
|
msg,
|
||||||
|
session_key_override=effective_key,
|
||||||
|
)
|
||||||
|
# A newer WebUI message must supersede an explicit recovery
|
||||||
|
# before it is injected into that recovery's pending queue.
|
||||||
|
# Without this admission point, a recovered turn could finish
|
||||||
|
# first and only then observe the user's newer request.
|
||||||
|
if (
|
||||||
|
effective_key in self._pending_queues
|
||||||
|
and msg.channel == "websocket"
|
||||||
|
and self._recovery_admission is not None
|
||||||
|
and not await self._recovery_admission.admit(routed_msg)
|
||||||
|
):
|
||||||
|
continue
|
||||||
# If this session already has an active pending queue (i.e. a task
|
# If this session already has an active pending queue (i.e. a task
|
||||||
# is processing this session), route the message there for mid-turn
|
# is processing this session), route the message there for mid-turn
|
||||||
# injection instead of creating a competing task.
|
# injection instead of creating a competing task.
|
||||||
@@ -1297,12 +1332,18 @@ class AgentLoop:
|
|||||||
self.commands.dispatch,
|
self.commands.dispatch,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
pending_msg = msg
|
pending_msg = routed_msg
|
||||||
if effective_key != msg.session_key:
|
session = self.sessions.get_or_create(effective_key)
|
||||||
|
followup_id = record_pending_followup(session, pending_msg)
|
||||||
|
if followup_id is not None:
|
||||||
pending_msg = dataclasses.replace(
|
pending_msg = dataclasses.replace(
|
||||||
msg,
|
pending_msg,
|
||||||
session_key_override=effective_key,
|
metadata={
|
||||||
|
**pending_msg.metadata,
|
||||||
|
PENDING_FOLLOWUP_ID_KEY: followup_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
try:
|
try:
|
||||||
self._pending_queues[effective_key].put_nowait(pending_msg)
|
self._pending_queues[effective_key].put_nowait(pending_msg)
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
@@ -1310,6 +1351,7 @@ class AgentLoop:
|
|||||||
"Pending queue full for session {}, falling back to queued task",
|
"Pending queue full for session {}, falling back to queued task",
|
||||||
effective_key,
|
effective_key,
|
||||||
)
|
)
|
||||||
|
msg = pending_msg
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Routed follow-up message to pending queue for session {}",
|
"Routed follow-up message to pending queue for session {}",
|
||||||
@@ -1319,17 +1361,45 @@ class AgentLoop:
|
|||||||
# Compute the effective session key before dispatching
|
# Compute the effective session key before dispatching
|
||||||
# This ensures /stop command can find tasks correctly when unified session is enabled
|
# This ensures /stop command can find tasks correctly when unified session is enabled
|
||||||
task = asyncio.create_task(self._dispatch(msg))
|
task = asyncio.create_task(self._dispatch(msg))
|
||||||
active_tasks = self._active_tasks.setdefault(effective_key, set())
|
active_tasks: set[asyncio.Task[Any]] = self._active_tasks.setdefault(
|
||||||
|
effective_key,
|
||||||
|
set(),
|
||||||
|
)
|
||||||
active_tasks.add(task)
|
active_tasks.add(task)
|
||||||
task.add_done_callback(active_tasks.discard)
|
task.add_done_callback(active_tasks.discard)
|
||||||
finally:
|
finally:
|
||||||
await self.aclose()
|
await self.aclose()
|
||||||
|
|
||||||
|
def preserve_inflight_turns_on_shutdown(self) -> None:
|
||||||
|
"""Keep durable checkpoints when the owning gateway exits.
|
||||||
|
|
||||||
|
Normal cancellation intentionally materializes partial output so a
|
||||||
|
user-stopped turn leaves a readable conversation. Gateway lifecycle
|
||||||
|
shutdown is different: RecoveryCoordinator needs the checkpoint intact
|
||||||
|
to safely offer the unfinished turn for explicit continuation later.
|
||||||
|
"""
|
||||||
|
self._preserve_inflight_turns_on_shutdown = True
|
||||||
|
|
||||||
async def _dispatch(self, msg: InboundMessage) -> None:
|
async def _dispatch(self, msg: InboundMessage) -> None:
|
||||||
"""Process a message: per-session serial, cross-session concurrent."""
|
"""Process a message: per-session serial, cross-session concurrent."""
|
||||||
session_key = self._effective_session_key(msg)
|
session_key = self._effective_session_key(msg)
|
||||||
if session_key != msg.session_key:
|
if session_key != msg.session_key:
|
||||||
msg = dataclasses.replace(msg, session_key_override=session_key)
|
msg = dataclasses.replace(msg, session_key_override=session_key)
|
||||||
|
recovery_task_registered = False
|
||||||
|
recovery_admission = self._recovery_admission
|
||||||
|
current_task: asyncio.Task[Any] | None = None
|
||||||
|
if recovery_admission is not None:
|
||||||
|
recovery_id = msg.metadata.get(RECOVERY_INBOUND_METADATA_KEY)
|
||||||
|
if isinstance(recovery_id, str) and recovery_id:
|
||||||
|
current_task = asyncio.current_task()
|
||||||
|
if current_task is not None:
|
||||||
|
recovery_admission.register_recovery_task(session_key, current_task)
|
||||||
|
recovery_task_registered = True
|
||||||
|
if not await recovery_admission.admit(msg):
|
||||||
|
logger.info("Skipped stale recovery for session {}", session_key)
|
||||||
|
if recovery_task_registered and current_task is not None:
|
||||||
|
recovery_admission.unregister_recovery_task(session_key, current_task)
|
||||||
|
return
|
||||||
lock = self._get_session_lock(session_key)
|
lock = self._get_session_lock(session_key)
|
||||||
gate = self._concurrency_gate or nullcontext()
|
gate = self._concurrency_gate or nullcontext()
|
||||||
|
|
||||||
@@ -1373,14 +1443,14 @@ class AgentLoop:
|
|||||||
session_key,
|
session_key,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
# Preserve partial context from the interrupted turn so
|
# An explicit turn stop materializes partial context so
|
||||||
# the user does not lose tool results and assistant
|
# the next prompt can see completed tool results. Gateway
|
||||||
# messages accumulated before /stop. The checkpoint was
|
# shutdown keeps the durable checkpoint untouched instead,
|
||||||
# already persisted to session metadata by
|
# allowing RecoveryCoordinator to offer Continue safely.
|
||||||
# _emit_checkpoint during tool execution; materializing
|
if (
|
||||||
# it into session history now makes it visible in the
|
session_key in self._discarding_sessions
|
||||||
# next conversation turn.
|
or self._preserve_inflight_turns_on_shutdown
|
||||||
if session_key in self._discarding_sessions:
|
):
|
||||||
raise
|
raise
|
||||||
try:
|
try:
|
||||||
key = self._effective_session_key(msg)
|
key = self._effective_session_key(msg)
|
||||||
@@ -1437,6 +1507,12 @@ class AgentLoop:
|
|||||||
await delivery.idle()
|
await delivery.idle()
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
finally:
|
finally:
|
||||||
|
if (
|
||||||
|
recovery_task_registered
|
||||||
|
and current_task is not None
|
||||||
|
and recovery_admission is not None
|
||||||
|
):
|
||||||
|
recovery_admission.unregister_recovery_task(session_key, current_task)
|
||||||
if pending is None:
|
if pending is None:
|
||||||
await delivery.idle()
|
await delivery.idle()
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
@@ -1738,7 +1814,10 @@ class AgentLoop:
|
|||||||
|
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if self._restore_pending_user_turn(session):
|
if (
|
||||||
|
RECOVERY_INBOUND_METADATA_KEY not in msg.metadata
|
||||||
|
and restore_pending_interruption(session)
|
||||||
|
):
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||||
@@ -2093,8 +2172,21 @@ class AgentLoop:
|
|||||||
if m.get("role") == "tool" and m.get("tool_call_id")
|
if m.get("role") == "tool" and m.get("tool_call_id")
|
||||||
}
|
}
|
||||||
last_assistant_idx: int | None = None
|
last_assistant_idx: int | None = None
|
||||||
|
saved_followup_ids: set[str] = set()
|
||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
|
followup_id_value = cast(object, entry.pop(PENDING_FOLLOWUP_ID_KEY, None))
|
||||||
|
followup_ids = (
|
||||||
|
[followup_id_value]
|
||||||
|
if isinstance(followup_id_value, str)
|
||||||
|
else [
|
||||||
|
followup_id
|
||||||
|
for followup_id in cast(list[object], followup_id_value)
|
||||||
|
if isinstance(followup_id, str)
|
||||||
|
]
|
||||||
|
if isinstance(followup_id_value, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
internal_meta = cast(object, entry.pop("_meta", None))
|
internal_meta = cast(object, entry.pop("_meta", None))
|
||||||
runtime_context_meta = (
|
runtime_context_meta = (
|
||||||
cast(dict[str, Any], internal_meta).get(
|
cast(dict[str, Any], internal_meta).get(
|
||||||
@@ -2147,6 +2239,8 @@ class AgentLoop:
|
|||||||
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
entry.setdefault("timestamp", datetime.now().isoformat())
|
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||||
session.messages.append(entry)
|
session.messages.append(entry)
|
||||||
|
if role == "user":
|
||||||
|
saved_followup_ids.update(followup_id for followup_id in followup_ids if followup_id)
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
last_assistant_idx = len(session.messages) - 1
|
last_assistant_idx = len(session.messages) - 1
|
||||||
declared_tool_call_ids.update(
|
declared_tool_call_ids.update(
|
||||||
@@ -2161,6 +2255,8 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
if turn_latency_ms is not None and last_assistant_idx is not None:
|
if turn_latency_ms is not None and last_assistant_idx is not None:
|
||||||
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
|
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
|
||||||
|
if saved_followup_ids:
|
||||||
|
acknowledge_pending_followups(session, saved_followup_ids)
|
||||||
session.updated_at = datetime.now()
|
session.updated_at = datetime.now()
|
||||||
|
|
||||||
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
|
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
|
||||||
@@ -2195,7 +2291,7 @@ class AgentLoop:
|
|||||||
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
|
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
|
||||||
"""Persist the latest in-flight turn state into session metadata."""
|
"""Persist the latest in-flight turn state into session metadata."""
|
||||||
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
|
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
|
||||||
self.sessions.save(session)
|
self.sessions.save_runtime_checkpoint(session)
|
||||||
|
|
||||||
def _mark_pending_user_turn(self, session: Session) -> None:
|
def _mark_pending_user_turn(self, session: Session) -> None:
|
||||||
session.metadata[self._PENDING_USER_TURN_KEY] = True
|
session.metadata[self._PENDING_USER_TURN_KEY] = True
|
||||||
@@ -2207,136 +2303,9 @@ class AgentLoop:
|
|||||||
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
|
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
|
||||||
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
|
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _checkpoint_message_key(message: dict[str, Any]) -> tuple[Any, ...]:
|
|
||||||
return (
|
|
||||||
message.get("role"),
|
|
||||||
message.get("content"),
|
|
||||||
message.get("tool_call_id"),
|
|
||||||
message.get("name"),
|
|
||||||
message.get("tool_calls"),
|
|
||||||
message.get("reasoning_content"),
|
|
||||||
message.get("thinking_blocks"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _restore_runtime_checkpoint(self, session: Session) -> bool:
|
def _restore_runtime_checkpoint(self, session: Session) -> bool:
|
||||||
"""Materialize an unfinished turn into session history before a new request."""
|
"""Materialize an unfinished turn into session history before a new request."""
|
||||||
from datetime import datetime
|
return restore_runtime_checkpoint(session)
|
||||||
|
|
||||||
checkpoint = cast(
|
|
||||||
object,
|
|
||||||
session.metadata.get(self._RUNTIME_CHECKPOINT_KEY),
|
|
||||||
)
|
|
||||||
if not isinstance(checkpoint, dict):
|
|
||||||
return False
|
|
||||||
checkpoint_data = cast(dict[str, Any], checkpoint)
|
|
||||||
|
|
||||||
assistant_message = cast(object, checkpoint_data.get("assistant_message"))
|
|
||||||
completed_tool_results = cast(
|
|
||||||
Iterable[object],
|
|
||||||
checkpoint_data.get("completed_tool_results") or [],
|
|
||||||
)
|
|
||||||
pending_tool_calls = cast(
|
|
||||||
Iterable[object],
|
|
||||||
checkpoint_data.get("pending_tool_calls") or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
restored_messages: list[dict[str, Any]] = []
|
|
||||||
if isinstance(assistant_message, dict):
|
|
||||||
restored = dict(cast(dict[str, Any], assistant_message))
|
|
||||||
restored.setdefault("timestamp", datetime.now().isoformat())
|
|
||||||
restored_messages.append(restored)
|
|
||||||
for message in completed_tool_results:
|
|
||||||
if isinstance(message, dict):
|
|
||||||
restored = dict(cast(dict[str, Any], message))
|
|
||||||
restored.setdefault("timestamp", datetime.now().isoformat())
|
|
||||||
restored_messages.append(restored)
|
|
||||||
for tool_call in pending_tool_calls:
|
|
||||||
if not isinstance(tool_call, dict):
|
|
||||||
continue
|
|
||||||
tool_call_data = cast(dict[str, Any], tool_call)
|
|
||||||
tool_id = tool_call_data.get("id")
|
|
||||||
function_data = cast(
|
|
||||||
dict[str, Any],
|
|
||||||
tool_call_data.get("function") or {},
|
|
||||||
)
|
|
||||||
name = function_data.get("name") or "tool"
|
|
||||||
restored_messages.append(
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_id,
|
|
||||||
"name": name,
|
|
||||||
"content": "Error: Task interrupted before this tool finished.",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
overlap = 0
|
|
||||||
max_overlap = min(len(session.messages), len(restored_messages))
|
|
||||||
for size in range(max_overlap, 0, -1):
|
|
||||||
existing = session.messages[-size:]
|
|
||||||
restored = restored_messages[:size]
|
|
||||||
if all(
|
|
||||||
self._checkpoint_message_key(left) == self._checkpoint_message_key(right)
|
|
||||||
for left, right in zip(existing, restored)
|
|
||||||
):
|
|
||||||
overlap = size
|
|
||||||
break
|
|
||||||
appended_messages = restored_messages[overlap:]
|
|
||||||
session.messages.extend(appended_messages)
|
|
||||||
assistant_message_data = (
|
|
||||||
cast(dict[str, Any], assistant_message)
|
|
||||||
if isinstance(assistant_message, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
provider_state_is_synchronized = (
|
|
||||||
checkpoint_data.get(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
|
|
||||||
== self._PROVIDER_STATE_CHECKPOINT_VERSION
|
|
||||||
)
|
|
||||||
phase = checkpoint_data.get("phase")
|
|
||||||
exact_final_response = (
|
|
||||||
phase == "final_response"
|
|
||||||
and assistant_message_data is not None
|
|
||||||
and assistant_message_data.get("role") == "assistant"
|
|
||||||
and not bool(checkpoint_data.get("completed_tool_results"))
|
|
||||||
and not bool(checkpoint_data.get("pending_tool_calls"))
|
|
||||||
)
|
|
||||||
exact_completed_tools = (
|
|
||||||
phase == "tools_completed"
|
|
||||||
and assistant_message_data is not None
|
|
||||||
and assistant_message_data.get("role") == "assistant"
|
|
||||||
and not bool(checkpoint_data.get("pending_tool_calls"))
|
|
||||||
)
|
|
||||||
if not (
|
|
||||||
provider_state_is_synchronized
|
|
||||||
and (exact_final_response or exact_completed_tools)
|
|
||||||
):
|
|
||||||
session.provider_state = None
|
|
||||||
|
|
||||||
self._clear_pending_user_turn(session)
|
|
||||||
self._clear_runtime_checkpoint(session)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _restore_pending_user_turn(self, session: Session) -> bool:
|
|
||||||
"""Close a turn that only persisted the user message before crashing."""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
if not session.metadata.get(self._PENDING_USER_TURN_KEY):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if session.messages and session.messages[-1].get("role") == "user":
|
|
||||||
session.messages.append(
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Error: Task interrupted before a response was generated.",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.provider_state = None
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
|
|
||||||
self._clear_pending_user_turn(session)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def process_direct(
|
async def process_direct(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+31
-6
@@ -37,6 +37,7 @@ from nanobot.runtime_context import (
|
|||||||
reattach_runtime_context,
|
reattach_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
|
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
IncrementalThinkExtractor,
|
IncrementalThinkExtractor,
|
||||||
build_assistant_message,
|
build_assistant_message,
|
||||||
@@ -234,6 +235,23 @@ class AgentRunner:
|
|||||||
merged.get("content"),
|
merged.get("content"),
|
||||||
injection.get("content"),
|
injection.get("content"),
|
||||||
)
|
)
|
||||||
|
followup_id = injection.get(PENDING_FOLLOWUP_ID_KEY)
|
||||||
|
if isinstance(followup_id, str) and followup_id:
|
||||||
|
existing = cast(object, merged.get(PENDING_FOLLOWUP_ID_KEY))
|
||||||
|
followup_ids = (
|
||||||
|
[existing]
|
||||||
|
if isinstance(existing, str)
|
||||||
|
else [
|
||||||
|
item
|
||||||
|
for item in cast(list[object], existing)
|
||||||
|
if isinstance(item, str)
|
||||||
|
]
|
||||||
|
if isinstance(existing, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if followup_id not in followup_ids:
|
||||||
|
followup_ids.append(followup_id)
|
||||||
|
merged[PENDING_FOLLOWUP_ID_KEY] = followup_ids
|
||||||
messages[-1] = merged
|
messages[-1] = merged
|
||||||
continue
|
continue
|
||||||
messages.append(injection)
|
messages.append(injection)
|
||||||
@@ -425,7 +443,7 @@ class AgentRunner:
|
|||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
final_content: str | None = None
|
final_content: str | None = None
|
||||||
tools_used: list[str] = []
|
tools_used: list[str] = []
|
||||||
usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
|
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
stop_reason = "completed"
|
stop_reason = "completed"
|
||||||
tool_events: list[dict[str, str]] = []
|
tool_events: list[dict[str, str]] = []
|
||||||
@@ -1384,11 +1402,6 @@ class AgentRunner:
|
|||||||
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
||||||
))
|
))
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
|
|
||||||
for key, value in addition.items():
|
|
||||||
target[key] = target.get(key, 0) + value
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
|
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
|
||||||
merged = dict(left)
|
merged = dict(left)
|
||||||
@@ -1396,6 +1409,18 @@ class AgentRunner:
|
|||||||
merged[key] = merged.get(key, 0) + value
|
merged[key] = merged.get(key, 0) + value
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
|
||||||
|
"""Fold one model request into the current turn's usage."""
|
||||||
|
total["request_count"] = total.get("request_count", 0) + 1
|
||||||
|
prompt_tokens = request.get("prompt_tokens")
|
||||||
|
if prompt_tokens is not None and prompt_tokens >= 0:
|
||||||
|
total["context_tokens"] = prompt_tokens
|
||||||
|
for key, value in request.items():
|
||||||
|
if key in {"context_tokens", "request_count"} or value < 0:
|
||||||
|
continue
|
||||||
|
total[key] = total.get(key, 0) + value
|
||||||
|
|
||||||
async def _execute_tools(
|
async def _execute_tools(
|
||||||
self,
|
self,
|
||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ class TurnEndEvent(OutboundEvent):
|
|||||||
context_window_tokens: int | None = None
|
context_window_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecoveryStateEvent(OutboundEvent):
|
||||||
|
status: str
|
||||||
|
recovery_id: str
|
||||||
|
reason: str | None = None
|
||||||
|
attempts: int = 0
|
||||||
|
can_continue: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GoalStatusEvent(OutboundEvent):
|
class GoalStatusEvent(OutboundEvent):
|
||||||
status: str
|
status: str
|
||||||
@@ -100,6 +109,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
|
|||||||
model: str
|
model: str
|
||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
context_window_tokens: int | None = None
|
context_window_tokens: int | None = None
|
||||||
|
fallback: bool = False
|
||||||
|
|
||||||
|
|
||||||
def outbound_message_for_event(
|
def outbound_message_for_event(
|
||||||
|
|||||||
@@ -145,14 +145,9 @@ class _FakeChannel:
|
|||||||
class _FakeInteractionResponse:
|
class _FakeInteractionResponse:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.messages: list[dict] = []
|
self.messages: list[dict] = []
|
||||||
self._done = False
|
|
||||||
|
|
||||||
async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
|
async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
|
||||||
self.messages.append({"content": content, "ephemeral": ephemeral})
|
self.messages.append({"content": content, "ephemeral": ephemeral})
|
||||||
self._done = True
|
|
||||||
|
|
||||||
def is_done(self) -> bool:
|
|
||||||
return self._done
|
|
||||||
|
|
||||||
|
|
||||||
def _make_interaction(
|
def _make_interaction(
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ class ChannelManager:
|
|||||||
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
|
webui_recovery_action: (
|
||||||
|
Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None
|
||||||
|
) = None,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
):
|
):
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
@@ -126,6 +129,7 @@ class ChannelManager:
|
|||||||
self._webui_mcp_runtime_status = webui_mcp_runtime_status
|
self._webui_mcp_runtime_status = webui_mcp_runtime_status
|
||||||
self._webui_mcp_reload = webui_mcp_reload
|
self._webui_mcp_reload = webui_mcp_reload
|
||||||
self._webui_skill_state_action = webui_skill_state_action
|
self._webui_skill_state_action = webui_skill_state_action
|
||||||
|
self._webui_recovery_action = webui_recovery_action
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._channel_owners: dict[str, str] = {}
|
self._channel_owners: dict[str, str] = {}
|
||||||
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
||||||
@@ -197,6 +201,7 @@ class ChannelManager:
|
|||||||
mcp_runtime_status=self._webui_mcp_runtime_status,
|
mcp_runtime_status=self._webui_mcp_runtime_status,
|
||||||
mcp_reload=self._webui_mcp_reload,
|
mcp_reload=self._webui_mcp_reload,
|
||||||
skill_state_action=self._webui_skill_state_action,
|
skill_state_action=self._webui_skill_state_action,
|
||||||
|
recovery_action=self._webui_recovery_action,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
kwargs["gateway"] = gateway
|
kwargs["gateway"] = gateway
|
||||||
@@ -615,6 +620,12 @@ class ChannelManager:
|
|||||||
if target is None:
|
if target is None:
|
||||||
logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
|
logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
|
||||||
return
|
return
|
||||||
|
if notice.channel == "websocket":
|
||||||
|
# Reconnect and recovery are already represented by WebSocket
|
||||||
|
# protocol state. A generic restart-complete notice must not
|
||||||
|
# masquerade as a recovery transition and overwrite a real
|
||||||
|
# awaiting-user checkpoint in connected clients.
|
||||||
|
return
|
||||||
|
|
||||||
while not target.is_running:
|
while not target.is_running:
|
||||||
remaining = deadline - loop.time()
|
remaining = deadline - loop.time()
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class MattermostConfig(Base):
|
|||||||
include_thread_context: bool = True
|
include_thread_context: bool = True
|
||||||
thread_context_limit: int = 20
|
thread_context_limit: int = 20
|
||||||
streaming: bool = True
|
streaming: bool = True
|
||||||
streaming_max_chars: int = 16000
|
|
||||||
react_emoji: str = "eyes"
|
react_emoji: str = "eyes"
|
||||||
done_emoji: str = "white_check_mark"
|
done_emoji: str = "white_check_mark"
|
||||||
send_progress: bool = True
|
send_progress: bool = True
|
||||||
@@ -106,7 +105,6 @@ class MattermostChannel(BaseChannel):
|
|||||||
self._ws_task: asyncio.Task[None] | None = None
|
self._ws_task: asyncio.Task[None] | None = None
|
||||||
self._self_id: str | None = None
|
self._self_id: str | None = None
|
||||||
self._self_username: str | None = None
|
self._self_username: str | None = None
|
||||||
self._self_email: str | None = None
|
|
||||||
self._usernames: dict[str, str] = {}
|
self._usernames: dict[str, str] = {}
|
||||||
self._user_emails: dict[str, str] = {}
|
self._user_emails: dict[str, str] = {}
|
||||||
self._channel_types: dict[str, str] = {}
|
self._channel_types: dict[str, str] = {}
|
||||||
@@ -138,7 +136,6 @@ class MattermostChannel(BaseChannel):
|
|||||||
me = cast(dict[str, Any], resp.json())
|
me = cast(dict[str, Any], resp.json())
|
||||||
self._self_id = me.get("id")
|
self._self_id = me.get("id")
|
||||||
self._self_username = me.get("username")
|
self._self_username = me.get("username")
|
||||||
self._self_email = me.get("email", "")
|
|
||||||
self.logger.info("bot @{} connected", self._self_username)
|
self.logger.info("bot @{} connected", self._self_username)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("Failed to identify bot user: {}", e)
|
self.logger.error("Failed to identify bot user: {}", e)
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ class _FakeHTTPClient:
|
|||||||
self.delete_calls: list[dict[str, Any]] = []
|
self.delete_calls: list[dict[str, Any]] = []
|
||||||
self._get_responses: dict[str, Any] = {}
|
self._get_responses: dict[str, Any] = {}
|
||||||
self._post_responses: dict[str, Any] = {}
|
self._post_responses: dict[str, Any] = {}
|
||||||
self._put_responses: dict[str, Any] = {}
|
|
||||||
self._delete_status: int | None = None
|
|
||||||
|
|
||||||
def _req(self, method: str, path: str) -> httpx.Request:
|
def _req(self, method: str, path: str) -> httpx.Request:
|
||||||
return httpx.Request(method, f"https://chat.example.com{path}")
|
return httpx.Request(method, f"https://chat.example.com{path}")
|
||||||
@@ -46,12 +44,6 @@ class _FakeHTTPClient:
|
|||||||
def set_post_response(self, path: str, data: Any) -> None:
|
def set_post_response(self, path: str, data: Any) -> None:
|
||||||
self._post_responses[path] = data
|
self._post_responses[path] = data
|
||||||
|
|
||||||
def set_put_response(self, path: str, data: Any) -> None:
|
|
||||||
self._put_responses[path] = data
|
|
||||||
|
|
||||||
def set_delete_status(self, status: int) -> None:
|
|
||||||
self._delete_status = status
|
|
||||||
|
|
||||||
async def get(self, path: str, **kwargs) -> httpx.Response:
|
async def get(self, path: str, **kwargs) -> httpx.Response:
|
||||||
self.get_calls.append({"path": path, **kwargs})
|
self.get_calls.append({"path": path, **kwargs})
|
||||||
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
|
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
|
||||||
@@ -71,13 +63,11 @@ class _FakeHTTPClient:
|
|||||||
|
|
||||||
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
|
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
|
||||||
self.put_calls.append({"path": path, "json": json})
|
self.put_calls.append({"path": path, "json": json})
|
||||||
data = self._put_responses.get(path, {"id": path.split("/")[-1]})
|
return self._resp(200, {"id": path.split("/")[-1]}, "PUT", path)
|
||||||
return self._resp(200, data, "PUT", path)
|
|
||||||
|
|
||||||
async def delete(self, path: str, **kwargs) -> httpx.Response:
|
async def delete(self, path: str, **kwargs) -> httpx.Response:
|
||||||
self.delete_calls.append({"path": path})
|
self.delete_calls.append({"path": path})
|
||||||
status = self._delete_status if self._delete_status is not None else 200
|
return self._resp(200, {}, "DELETE", path)
|
||||||
return self._resp(status, {}, "DELETE", path)
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -119,7 +109,6 @@ def test_config_defaults():
|
|||||||
assert config.server_url == ""
|
assert config.server_url == ""
|
||||||
assert config.token == ""
|
assert config.token == ""
|
||||||
assert config.streaming is True
|
assert config.streaming is True
|
||||||
assert config.streaming_max_chars == 16000
|
|
||||||
assert config.send_tool_hints is True
|
assert config.send_tool_hints is True
|
||||||
assert config.dm.enabled is True
|
assert config.dm.enabled is True
|
||||||
assert config.dm.policy == "open"
|
assert config.dm.policy == "open"
|
||||||
@@ -150,7 +139,6 @@ def test_config_camelcase_aliases():
|
|||||||
"serverUrl": "https://mm.example.com",
|
"serverUrl": "https://mm.example.com",
|
||||||
"token": "abc123",
|
"token": "abc123",
|
||||||
"allowFromMatchMode": "username",
|
"allowFromMatchMode": "username",
|
||||||
"streamingMaxChars": 8000,
|
|
||||||
"replyInThread": False,
|
"replyInThread": False,
|
||||||
"sendToolHints": False,
|
"sendToolHints": False,
|
||||||
}
|
}
|
||||||
@@ -158,7 +146,6 @@ def test_config_camelcase_aliases():
|
|||||||
assert config.server_url == "https://mm.example.com"
|
assert config.server_url == "https://mm.example.com"
|
||||||
assert config.token == "abc123"
|
assert config.token == "abc123"
|
||||||
assert config.allow_from_match_mode == "username"
|
assert config.allow_from_match_mode == "username"
|
||||||
assert config.streaming_max_chars == 8000
|
|
||||||
assert config.reply_in_thread is False
|
assert config.reply_in_thread is False
|
||||||
assert config.send_tool_hints is False
|
assert config.send_tool_hints is False
|
||||||
|
|
||||||
@@ -194,7 +181,6 @@ async def test_start_identifies_bot():
|
|||||||
|
|
||||||
assert channel._self_id == "botuserid123"
|
assert channel._self_id == "botuserid123"
|
||||||
assert channel._self_username == "nanobot"
|
assert channel._self_username == "nanobot"
|
||||||
assert channel._self_email == "bot@example.com"
|
|
||||||
assert not start_task.done()
|
assert not start_task.done()
|
||||||
user_me_calls = [c for c in fake.get_calls[calls_before:] if "/api/v4/users/me" in c["path"]]
|
user_me_calls = [c for c in fake.get_calls[calls_before:] if "/api/v4/users/me" in c["path"]]
|
||||||
assert len(user_me_calls) == 1
|
assert len(user_me_calls) == 1
|
||||||
@@ -674,7 +660,7 @@ async def test_stream_end_adds_done_emoji():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stream_chunk_boundary_finalizes_and_creates_new():
|
async def test_stream_chunk_boundary_finalizes_and_creates_new():
|
||||||
channel, fake = _make_channel({"streamingMaxChars": 10})
|
channel, fake = _make_channel()
|
||||||
channel._self_id = "bot_id"
|
channel._self_id = "bot_id"
|
||||||
fake.set_post_response("/api/v4/posts", {"id": "post_1"})
|
fake.set_post_response("/api/v4/posts", {"id": "post_1"})
|
||||||
|
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ class MochatChannel(BaseChannel):
|
|||||||
self.config: MochatConfig = config
|
self.config: MochatConfig = config
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
self._socket: Any = None
|
self._socket: Any = None
|
||||||
self._ws_connected = self._ws_ready = False
|
self._ws_ready = False
|
||||||
|
|
||||||
self._state_dir = get_runtime_subdir("mochat")
|
self._state_dir = get_runtime_subdir("mochat")
|
||||||
self._cursor_path = self._state_dir / "session_cursors.json"
|
self._cursor_path = self._state_dir / "session_cursors.json"
|
||||||
@@ -346,7 +346,7 @@ class MochatChannel(BaseChannel):
|
|||||||
if self._http:
|
if self._http:
|
||||||
await self._http.aclose()
|
await self._http.aclose()
|
||||||
self._http = None
|
self._http = None
|
||||||
self._ws_connected = self._ws_ready = False
|
self._ws_ready = False
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send outbound message to session or panel."""
|
"""Send outbound message to session or panel."""
|
||||||
@@ -422,7 +422,7 @@ class MochatChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def connect() -> None:
|
async def connect() -> None:
|
||||||
self._ws_connected, self._ws_ready = True, False
|
self._ws_ready = False
|
||||||
self.logger.info("websocket connected")
|
self.logger.info("websocket connected")
|
||||||
subscribed = await self._subscribe_all()
|
subscribed = await self._subscribe_all()
|
||||||
self._ws_ready = subscribed
|
self._ws_ready = subscribed
|
||||||
@@ -431,7 +431,7 @@ class MochatChannel(BaseChannel):
|
|||||||
async def disconnect() -> None:
|
async def disconnect() -> None:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
self._ws_connected = self._ws_ready = False
|
self._ws_ready = False
|
||||||
self.logger.warning("websocket disconnected")
|
self.logger.warning("websocket disconnected")
|
||||||
await self._ensure_fallback_workers()
|
await self._ensure_fallback_workers()
|
||||||
|
|
||||||
|
|||||||
@@ -363,13 +363,6 @@ def test_reported_daily_brief_pattern():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
|
|
||||||
"""Helper: full markdown → signal pipeline, including chunking."""
|
|
||||||
plain, styles = _markdown_to_signal(text)
|
|
||||||
chunks = split_message(plain, max_len) if plain else [""]
|
|
||||||
return chunks, _partition_styles(plain, chunks, styles)
|
|
||||||
|
|
||||||
|
|
||||||
def test_partition_styles_single_chunk_passthrough():
|
def test_partition_styles_single_chunk_passthrough():
|
||||||
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
||||||
parts = _partition_styles(plain, [plain], styles)
|
parts = _partition_styles(plain, [plain], styles)
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ class SlackConfig(Base):
|
|||||||
webhook_path: str = "/slack/events"
|
webhook_path: str = "/slack/events"
|
||||||
bot_token: str = ""
|
bot_token: str = ""
|
||||||
app_token: str = ""
|
app_token: str = ""
|
||||||
user_token_read_only: bool = True
|
|
||||||
reply_in_thread: bool = True
|
reply_in_thread: bool = True
|
||||||
react_emoji: str = "eyes"
|
react_emoji: str = "eyes"
|
||||||
done_emoji: str = "white_check_mark"
|
done_emoji: str = "white_check_mark"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
ProgressEvent,
|
ProgressEvent,
|
||||||
|
RecoveryStateEvent,
|
||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
@@ -55,6 +56,7 @@ from nanobot.security.workspace_access import (
|
|||||||
)
|
)
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.model_selection import model_preset_from_metadata
|
from nanobot.session.model_selection import model_preset_from_metadata
|
||||||
|
from nanobot.session.recovery import recovery_state_from_metadata
|
||||||
from nanobot.session.webui_turns import (
|
from nanobot.session.webui_turns import (
|
||||||
clear_websocket_turn_if_current,
|
clear_websocket_turn_if_current,
|
||||||
clear_websocket_turns,
|
clear_websocket_turns,
|
||||||
@@ -426,6 +428,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||||
|
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||||
|
|
||||||
# -- Subscription bookkeeping -------------------------------------------
|
# -- Subscription bookkeeping -------------------------------------------
|
||||||
|
|
||||||
@@ -452,6 +455,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
||||||
fields["model_preset"] = None
|
fields["model_preset"] = None
|
||||||
if isinstance(metadata, dict):
|
if isinstance(metadata, dict):
|
||||||
|
recovery_state = recovery_state_from_metadata(metadata)
|
||||||
|
if recovery_state is not None:
|
||||||
|
fields["recovery_state"] = recovery_state
|
||||||
usage = metadata.get("_last_usage")
|
usage = metadata.get("_last_usage")
|
||||||
if isinstance(usage, dict):
|
if isinstance(usage, dict):
|
||||||
sanitized_usage: dict[str, int | float] = {}
|
sanitized_usage: dict[str, int | float] = {}
|
||||||
@@ -482,6 +488,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
for key in tuple(self._stream_text_buffers):
|
for key in tuple(self._stream_text_buffers):
|
||||||
if key[0] == chat_id:
|
if key[0] == chat_id:
|
||||||
self._stream_text_buffers.pop(key, None)
|
self._stream_text_buffers.pop(key, None)
|
||||||
|
for key in tuple(self._reasoning_text_buffers):
|
||||||
|
if key[0] == chat_id:
|
||||||
|
self._reasoning_text_buffers.pop(key, None)
|
||||||
|
|
||||||
async def _discard_connection_owned_chat(
|
async def _discard_connection_owned_chat(
|
||||||
self,
|
self,
|
||||||
@@ -1641,11 +1650,22 @@ class WebSocketChannel(BaseChannel):
|
|||||||
include_source=include_source,
|
include_source=include_source,
|
||||||
transcript_overrides=transcript_overrides,
|
transcript_overrides=transcript_overrides,
|
||||||
)
|
)
|
||||||
if (
|
return self._retain_turn_on_transcript_failure(
|
||||||
not persisted
|
chat_id,
|
||||||
and phase in {"answer", "complete"}
|
persisted=persisted,
|
||||||
and (metadata or {}).get("webui") is True
|
metadata=metadata,
|
||||||
):
|
phase=phase,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _retain_turn_on_transcript_failure(
|
||||||
|
chat_id: str,
|
||||||
|
*,
|
||||||
|
persisted: bool,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
phase: str,
|
||||||
|
) -> bool:
|
||||||
|
if not persisted and phase in {"answer", "complete"} and (metadata or {}).get("webui") is True:
|
||||||
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||||
mark_websocket_turn_transcript_persistence_failed(
|
mark_websocket_turn_transcript_persistence_failed(
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -1653,6 +1673,34 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return persisted
|
return persisted
|
||||||
|
|
||||||
|
def _persist_turn_stream_event(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
event: dict[str, Any],
|
||||||
|
*,
|
||||||
|
completed_text: str | None,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
phase: str,
|
||||||
|
include_source: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Persist the canonical end of a live stream, never its wire chunks."""
|
||||||
|
if not self._temporary_chats.should_persist_transcript(chat_id):
|
||||||
|
return True
|
||||||
|
persisted = self._transcripts.prepare_and_append_stream_event(
|
||||||
|
chat_id,
|
||||||
|
event,
|
||||||
|
completed_text=completed_text,
|
||||||
|
metadata=metadata,
|
||||||
|
phase=phase,
|
||||||
|
include_source=include_source,
|
||||||
|
)
|
||||||
|
return self._retain_turn_on_transcript_failure(
|
||||||
|
chat_id,
|
||||||
|
persisted=persisted,
|
||||||
|
metadata=metadata,
|
||||||
|
phase=phase,
|
||||||
|
)
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
event = outbound_event_from_message(msg)
|
event = outbound_event_from_message(msg)
|
||||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||||
@@ -1685,6 +1733,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
model_name=event.model,
|
model_name=event.model,
|
||||||
model_preset=event.model_preset,
|
model_preset=event.model_preset,
|
||||||
context_window_tokens=event.context_window_tokens,
|
context_window_tokens=event.context_window_tokens,
|
||||||
|
fallback=event.fallback,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, UserInputEvent):
|
if isinstance(event, UserInputEvent):
|
||||||
@@ -1696,6 +1745,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
provenance=event.provenance,
|
provenance=event.provenance,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if isinstance(event, RecoveryStateEvent):
|
||||||
|
if conns:
|
||||||
|
await self.send_recovery_state(msg.chat_id, event)
|
||||||
|
return
|
||||||
if isinstance(event, GoalStateSyncEvent):
|
if isinstance(event, GoalStateSyncEvent):
|
||||||
if conns:
|
if conns:
|
||||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||||
@@ -1834,9 +1887,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
self._persist_turn_transcript_event(
|
stream_key = (chat_id, str(stream_id or ""))
|
||||||
|
self._reasoning_text_buffers.setdefault(stream_key, []).append(delta)
|
||||||
|
self._persist_turn_stream_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
|
completed_text=None,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
phase="reasoning",
|
phase="reasoning",
|
||||||
)
|
)
|
||||||
@@ -1862,9 +1918,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
self._persist_turn_transcript_event(
|
stream_key = (chat_id, str(stream_id or ""))
|
||||||
|
reasoning_text = "".join(self._reasoning_text_buffers.pop(stream_key, []))
|
||||||
|
self._persist_turn_stream_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
|
completed_text=reasoning_text or None,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
phase="reasoning",
|
phase="reasoning",
|
||||||
)
|
)
|
||||||
@@ -1912,6 +1971,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
stream_key = (chat_id, str(stream_id or ""))
|
stream_key = (chat_id, str(stream_id or ""))
|
||||||
|
completed_text: str | None = None
|
||||||
if stream_end:
|
if stream_end:
|
||||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||||
buffered = (
|
buffered = (
|
||||||
@@ -1923,6 +1983,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
buffered.append(delta)
|
buffered.append(delta)
|
||||||
full_text = "".join(buffered)
|
full_text = "".join(buffered)
|
||||||
rewritten = self._media.rewrite_local_markdown_images(full_text)
|
rewritten = self._media.rewrite_local_markdown_images(full_text)
|
||||||
|
completed_text = rewritten
|
||||||
if delta or rewritten != full_text:
|
if delta or rewritten != full_text:
|
||||||
body["text"] = rewritten
|
body["text"] = rewritten
|
||||||
else:
|
else:
|
||||||
@@ -1938,9 +1999,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
body["resuming"] = True
|
body["resuming"] = True
|
||||||
if stream_end and merge_next:
|
if stream_end and merge_next:
|
||||||
body["merge_next"] = True
|
body["merge_next"] = True
|
||||||
self._persist_turn_transcript_event(
|
self._persist_turn_stream_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
|
completed_text=completed_text,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
phase="answer",
|
phase="answer",
|
||||||
include_source=True,
|
include_source=True,
|
||||||
@@ -1997,12 +2059,34 @@ class WebSocketChannel(BaseChannel):
|
|||||||
# carries a durable incomplete marker. The HTTP replay path can
|
# carries a durable incomplete marker. The HTTP replay path can
|
||||||
# recover the latter from session history after a gateway restart.
|
# recover the latter from session history after a gateway restart.
|
||||||
clear_websocket_turn_if_current(chat_id, turn_owner)
|
clear_websocket_turn_if_current(chat_id, turn_owner)
|
||||||
|
self._clear_stream_buffers(chat_id)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
if not conns:
|
if not conns:
|
||||||
return
|
return
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" turn_end ")
|
await self._safe_send_to(connection, raw, label=" turn_end ")
|
||||||
|
|
||||||
|
async def send_recovery_state(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
event: RecoveryStateEvent,
|
||||||
|
) -> None:
|
||||||
|
"""Publish one structured recovery transition without chat pollution."""
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"event": "recovery_state",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"status": event.status,
|
||||||
|
"recovery_id": event.recovery_id,
|
||||||
|
"attempts": event.attempts,
|
||||||
|
}
|
||||||
|
if event.reason:
|
||||||
|
body["reason"] = event.reason
|
||||||
|
if event.can_continue is not None:
|
||||||
|
body["can_continue"] = event.can_continue
|
||||||
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
|
for connection in list(self._subs.get(chat_id, ())):
|
||||||
|
await self._safe_send_to(connection, raw, label=" recovery_state ")
|
||||||
|
|
||||||
async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None:
|
async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None:
|
||||||
"""Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
|
"""Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
@@ -2102,6 +2186,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
model_name: Any,
|
model_name: Any,
|
||||||
model_preset: Any = None,
|
model_preset: Any = None,
|
||||||
context_window_tokens: Any = None,
|
context_window_tokens: Any = None,
|
||||||
|
fallback: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Notify one chat's subscribers which model is handling its current request."""
|
"""Notify one chat's subscribers which model is handling its current request."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
@@ -2120,6 +2205,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
body["model_preset"] = model_preset.strip()
|
body["model_preset"] = model_preset.strip()
|
||||||
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
||||||
body["context_window_tokens"] = context_window_tokens
|
body["context_window_tokens"] = context_window_tokens
|
||||||
|
if fallback:
|
||||||
|
body["fallback"] = True
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
ProgressEvent,
|
ProgressEvent,
|
||||||
|
RecoveryStateEvent,
|
||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
@@ -2073,6 +2074,21 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
|||||||
"model_preset": "Deep Research",
|
"model_preset": "Deep Research",
|
||||||
"context_window_tokens": 128_000,
|
"context_window_tokens": 128_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="",
|
||||||
|
event=TurnModelUpdatedEvent(
|
||||||
|
model="deepseek/deepseek-chat",
|
||||||
|
model_preset="Deep Research",
|
||||||
|
fallback=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fallback_payload = json.loads(chat_one.send.call_args.args[0])
|
||||||
|
assert fallback_payload["fallback"] is True
|
||||||
chat_two.send.assert_not_awaited()
|
chat_two.send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@@ -2348,8 +2364,9 @@ async def test_send_delta_preserves_webui_source_metadata() -> None:
|
|||||||
assert second["event"] == "stream_end"
|
assert second["event"] == "stream_end"
|
||||||
assert second["source"] == source
|
assert second["source"] == source
|
||||||
lines = read_transcript_lines("websocket:chat-source-stream")
|
lines = read_transcript_lines("websocket:chat-source-stream")
|
||||||
assert lines[-2]["source"] == source
|
|
||||||
assert lines[-1]["source"] == source
|
assert lines[-1]["source"] == source
|
||||||
|
assert lines[-1]["event"] == "stream_end"
|
||||||
|
assert lines[-1]["text"] == "done"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -2374,6 +2391,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
||||||
|
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
|
||||||
|
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
channel = WebSocketChannel(
|
channel = WebSocketChannel(
|
||||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||||
@@ -2403,6 +2422,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
|||||||
"second",
|
"second",
|
||||||
]
|
]
|
||||||
assert ("chat-1", "sid") not in channel._stream_text_buffers
|
assert ("chat-1", "sid") not in channel._stream_text_buffers
|
||||||
|
lines = read_transcript_lines("websocket:chat-1")
|
||||||
|
assert [line["event"] for line in lines] == ["stream_end", "stream_end"]
|
||||||
|
assert [line["text"] for line in lines] == ["first ", "first second"]
|
||||||
|
body = build_webui_thread_response("websocket:chat-1")
|
||||||
|
assert body is not None
|
||||||
|
assert body["messages"][-1]["content"] == "first second"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -2596,7 +2621,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
|||||||
|
|
||||||
assert channel._subs == {}
|
assert channel._subs == {}
|
||||||
lines = read_transcript_lines("websocket:chat-1")
|
lines = read_transcript_lines("websocket:chat-1")
|
||||||
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
|
assert [line["event"] for line in lines] == ["stream_end", "turn_end"]
|
||||||
|
assert lines[0]["text"] == "hello world"
|
||||||
body = build_webui_thread_response("websocket:chat-1")
|
body = build_webui_thread_response("websocket:chat-1")
|
||||||
assert body is not None
|
assert body is not None
|
||||||
assert body["messages"][-1]["role"] == "assistant"
|
assert body["messages"][-1]["role"] == "assistant"
|
||||||
@@ -2604,6 +2630,77 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
|||||||
assert body["messages"][-1]["latencyMs"] == 42
|
assert body["messages"][-1]["latencyMs"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_transcript_writes_once_per_completed_segment(monkeypatch) -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus),
|
||||||
|
)
|
||||||
|
append = MagicMock()
|
||||||
|
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||||
|
|
||||||
|
await channel.send_delta("chat-write-rate", "one", stream_id="s1")
|
||||||
|
await channel.send_delta("chat-write-rate", " two", stream_id="s1")
|
||||||
|
await channel.send_delta("chat-write-rate", " three", stream_id="s1")
|
||||||
|
|
||||||
|
append.assert_not_called()
|
||||||
|
|
||||||
|
await channel.send_delta("chat-write-rate", "", stream_id="s1", stream_end=True)
|
||||||
|
|
||||||
|
append.assert_called_once()
|
||||||
|
persisted = append.call_args.args[1]
|
||||||
|
assert persisted["event"] == "stream_end"
|
||||||
|
assert persisted["text"] == "one two three"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reasoning_transcript_persists_one_canonical_record(monkeypatch) -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus),
|
||||||
|
)
|
||||||
|
append = MagicMock()
|
||||||
|
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||||
|
|
||||||
|
await channel.send_reasoning_delta("chat-reasoning-write-rate", "plan ", stream_id="r1")
|
||||||
|
await channel.send_reasoning_delta("chat-reasoning-write-rate", "then act", stream_id="r1")
|
||||||
|
|
||||||
|
append.assert_not_called()
|
||||||
|
|
||||||
|
await channel.send_reasoning_end("chat-reasoning-write-rate", stream_id="r1")
|
||||||
|
|
||||||
|
append.assert_called_once()
|
||||||
|
persisted = append.call_args.args[1]
|
||||||
|
assert persisted["event"] == "reasoning_end"
|
||||||
|
assert persisted["text"] == "plan then act"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_turn_end_discards_unclosed_stream_buffers() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus),
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send_delta("chat-unclosed", "partial", stream_id="s1")
|
||||||
|
await channel.send_reasoning_delta("chat-unclosed", "thinking", stream_id="r1")
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-unclosed",
|
||||||
|
content="",
|
||||||
|
event=TurnEndEvent(),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert channel._stream_text_buffers == {}
|
||||||
|
assert channel._reasoning_text_buffers == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -2624,6 +2721,39 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recovery_state_is_a_structured_event_not_assistant_text() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus),
|
||||||
|
)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="",
|
||||||
|
event=RecoveryStateEvent(
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id="recovery-1",
|
||||||
|
reason="tool_state_unknown",
|
||||||
|
attempts=1,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert _sent_ws_payloads(mock_ws) == [{
|
||||||
|
"event": "recovery_state",
|
||||||
|
"chat_id": "chat-1",
|
||||||
|
"status": "awaiting_user",
|
||||||
|
"recovery_id": "recovery-1",
|
||||||
|
"reason": "tool_state_unknown",
|
||||||
|
"attempts": 1,
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
|
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ def _make_handler(
|
|||||||
channel_feature_action: Any | None = None,
|
channel_feature_action: Any | None = None,
|
||||||
channel_runtime_status: Any | None = None,
|
channel_runtime_status: Any | None = None,
|
||||||
mcp_reload: Any | None = None,
|
mcp_reload: Any | None = None,
|
||||||
|
recovery_action: Any | None = None,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||||
workspace = workspace_path or Path.cwd()
|
workspace = workspace_path or Path.cwd()
|
||||||
@@ -103,6 +104,7 @@ def _make_handler(
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
mcp_reload=mcp_reload,
|
||||||
|
recovery_action=recovery_action,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ def _ch(
|
|||||||
channel_feature_action: Any | None = None,
|
channel_feature_action: Any | None = None,
|
||||||
channel_runtime_status: Any | None = None,
|
channel_runtime_status: Any | None = None,
|
||||||
mcp_reload: Any | None = None,
|
mcp_reload: Any | None = None,
|
||||||
|
recovery_action: Any | None = None,
|
||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> WebSocketChannel:
|
) -> WebSocketChannel:
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
@@ -145,6 +148,7 @@ def _ch(
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
mcp_reload=mcp_reload,
|
||||||
|
recovery_action=recovery_action,
|
||||||
)
|
)
|
||||||
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
||||||
|
|
||||||
@@ -1244,39 +1248,6 @@ async def test_pairing_routes_require_token_and_approve_or_deny(
|
|||||||
assert "Missing pairing code" in missing_code.text
|
assert "Missing pairing code" in missing_code.text
|
||||||
|
|
||||||
|
|
||||||
def test_api_service_settings_read_api_key_from_webui_payload(bus: MagicMock) -> None:
|
|
||||||
channel = _ch(bus)
|
|
||||||
request = _FakeReq(path="/api/settings/api-service/start")
|
|
||||||
setattr(
|
|
||||||
request,
|
|
||||||
"_nanobot_webui_mutation_payload",
|
|
||||||
{"host": "0.0.0.0", "port": 8900, "timeout": 120, "api_key": "secret-token"},
|
|
||||||
)
|
|
||||||
|
|
||||||
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
|
||||||
|
|
||||||
assert query == {
|
|
||||||
"host": ["0.0.0.0"],
|
|
||||||
"port": ["8900"],
|
|
||||||
"timeout": ["120"],
|
|
||||||
"api_key": ["secret-token"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_service_settings_reject_non_string_api_key(bus: MagicMock) -> None:
|
|
||||||
from nanobot.webui.settings_api import WebUISettingsError
|
|
||||||
|
|
||||||
channel = _ch(bus)
|
|
||||||
request = _FakeReq(path="/api/settings/api-service/start")
|
|
||||||
setattr(
|
|
||||||
request,
|
|
||||||
"_nanobot_webui_mutation_payload",
|
|
||||||
{"host": "127.0.0.1", "api_key": 123},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="API key must be a string"):
|
|
||||||
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_nanobot_feature_remote_install_requires_opt_in(
|
async def test_nanobot_feature_remote_install_requires_opt_in(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
@@ -3275,6 +3246,28 @@ async def _webui_mutate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recovery_mutation_uses_authenticated_websocket_action(bus: MagicMock) -> None:
|
||||||
|
recovery_action = AsyncMock(return_value={
|
||||||
|
"status": "resuming",
|
||||||
|
"recovery_id": "recovery-1",
|
||||||
|
})
|
||||||
|
channel = _ch(bus, recovery_action=recovery_action)
|
||||||
|
|
||||||
|
response = await _webui_mutate(
|
||||||
|
channel,
|
||||||
|
"recovery.continue",
|
||||||
|
{"chat_id": "chat-1", "recovery_id": "recovery-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "resuming"
|
||||||
|
recovery_action.assert_awaited_once_with(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "chat-1", "recovery_id": "recovery-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_workspace_folder_picker_is_local_authenticated_mutation(
|
async def test_workspace_folder_picker_is_local_authenticated_mutation(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
|
|||||||
@@ -202,12 +202,6 @@ class WsTestClient:
|
|||||||
assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'"
|
assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'"
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
async def recv_stream_end(self, timeout: float = 10.0) -> WsMessage:
|
|
||||||
"""Receive and validate a 'stream_end' event."""
|
|
||||||
msg = await self.recv(timeout)
|
|
||||||
assert msg.event == "stream_end", f"Expected 'stream_end' event, got '{msg.event}'"
|
|
||||||
return msg
|
|
||||||
|
|
||||||
async def collect_stream(self, timeout: float = 10.0) -> list[WsMessage]:
|
async def collect_stream(self, timeout: float = 10.0) -> list[WsMessage]:
|
||||||
"""Collect all deltas and the final stream_end into a list."""
|
"""Collect all deltas and the final stream_end into a list."""
|
||||||
messages: list[WsMessage] = []
|
messages: list[WsMessage] = []
|
||||||
@@ -232,10 +226,6 @@ class WsTestClient:
|
|||||||
"""Send a JSON frame."""
|
"""Send a JSON frame."""
|
||||||
await self.ws.send(json.dumps(data, ensure_ascii=False))
|
await self.ws.send(json.dumps(data, ensure_ascii=False))
|
||||||
|
|
||||||
async def send_content(self, content: str) -> None:
|
|
||||||
"""Send content in the preferred JSON format ``{"content": ...}``."""
|
|
||||||
await self.send_json({"content": content})
|
|
||||||
|
|
||||||
# -- Connection introspection -----------------------------------------
|
# -- Connection introspection -----------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from nanobot.cli.agent import agent # noqa: E402
|
|||||||
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||||
from nanobot.cli.gateway_runtime import _run_gateway # noqa: E402
|
from nanobot.cli.gateway_runtime import _run_gateway # noqa: E402
|
||||||
from nanobot.cli.log_control import _set_nanobot_logs # noqa: E402
|
from nanobot.cli.log_control import _set_nanobot_logs # noqa: E402
|
||||||
|
from nanobot.cli.process_identity import set_cli_process_identity # noqa: E402
|
||||||
from nanobot.cli.provider import provider_app # noqa: E402
|
from nanobot.cli.provider import provider_app # noqa: E402
|
||||||
from nanobot.cli.runtime_config import ( # noqa: E402
|
from nanobot.cli.runtime_config import ( # noqa: E402
|
||||||
_load_inspection_config,
|
_load_inspection_config,
|
||||||
@@ -99,12 +100,17 @@ def version_callback(value: bool):
|
|||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
def main(
|
def main(
|
||||||
|
ctx: typer.Context,
|
||||||
version: bool = typer.Option(
|
version: bool = typer.Option(
|
||||||
None, "--version", "-v", callback=version_callback, is_eager=True
|
None, "--version", "-v", callback=version_callback, is_eager=True
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
"""nanobot - Personal AI Assistant."""
|
"""nanobot - Personal AI Assistant."""
|
||||||
pass
|
# Editable/source installs can retain an older generated console script that
|
||||||
|
# imports this Typer app directly instead of ``nanobot.cli.entry``. Keep the
|
||||||
|
# role identity correct until that launcher is regenerated.
|
||||||
|
command = ctx.invoked_subcommand
|
||||||
|
set_cli_process_identity([command] if command else sys.argv[1:])
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
|
from nanobot.cli.process_identity import set_cli_process_identity
|
||||||
|
|
||||||
|
|
||||||
def _native_tui_candidate(args: list[str]) -> bool:
|
def _native_tui_candidate(args: list[str]) -> bool:
|
||||||
"""Return whether ``agent`` can start without the classic agent stack."""
|
"""Return whether ``agent`` can start without the classic agent stack."""
|
||||||
@@ -34,6 +36,7 @@ def _configure_windows_console() -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Dispatch native TUI startup without importing the complete CLI graph."""
|
"""Dispatch native TUI startup without importing the complete CLI graph."""
|
||||||
|
set_cli_process_identity(sys.argv[1:])
|
||||||
_configure_windows_console()
|
_configure_windows_console()
|
||||||
if _native_tui_candidate(sys.argv[1:]):
|
if _native_tui_candidate(sys.argv[1:]):
|
||||||
import typer
|
import typer
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ def _run_gateway(
|
|||||||
from nanobot.providers.fallback_provider import FallbackProvider
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.session.recovery import RecoveryCoordinator
|
||||||
from nanobot.session.webui_turns import (
|
from nanobot.session.webui_turns import (
|
||||||
WebuiTurnCoordinator,
|
WebuiTurnCoordinator,
|
||||||
WebuiTurnRoutePolicy,
|
WebuiTurnRoutePolicy,
|
||||||
@@ -422,6 +423,12 @@ def _run_gateway(
|
|||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
mcp_provider = MCPProvider.from_config(config, tools)
|
mcp_provider = MCPProvider.from_config(config, tools)
|
||||||
|
|
||||||
|
recovery = RecoveryCoordinator(
|
||||||
|
sessions=session_manager,
|
||||||
|
bus=bus,
|
||||||
|
unified_session=config.agents.defaults.unified_session,
|
||||||
|
)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop.from_config(
|
agent = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
@@ -440,6 +447,7 @@ def _run_gateway(
|
|||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
tool_registry=tools,
|
tool_registry=tools,
|
||||||
|
recovery_admission=recovery,
|
||||||
)
|
)
|
||||||
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
||||||
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
||||||
@@ -448,6 +456,7 @@ def _run_gateway(
|
|||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=session_manager,
|
sessions=session_manager,
|
||||||
schedule_background=_schedule_webui_background,
|
schedule_background=_schedule_webui_background,
|
||||||
|
recovery=recovery,
|
||||||
)
|
)
|
||||||
webui_turn_coordinator.subscribe(runtime_events)
|
webui_turn_coordinator.subscribe(runtime_events)
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -683,6 +692,7 @@ def _run_gateway(
|
|||||||
webui_mcp_runtime_status=mcp_provider.runtime_status,
|
webui_mcp_runtime_status=mcp_provider.runtime_status,
|
||||||
webui_mcp_reload=mcp_provider.reload,
|
webui_mcp_reload=mcp_provider.reload,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
|
webui_recovery_action=recovery.handle_action,
|
||||||
config_path=Path(config_path),
|
config_path=Path(config_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -849,6 +859,7 @@ def _run_gateway(
|
|||||||
tasks: list[asyncio.Task[Any]] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
shutdown_task: asyncio.Task[Any] | None = None
|
shutdown_task: asyncio.Task[Any] | None = None
|
||||||
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
||||||
|
startup_complete = False
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
cli_terminal._ensure_interactive_tty_mode()
|
cli_terminal._ensure_interactive_tty_mode()
|
||||||
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
|
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
|
||||||
@@ -861,6 +872,10 @@ def _run_gateway(
|
|||||||
await cron.start()
|
await cron.start()
|
||||||
# Re-read once on first admission to close the watcher subscription window.
|
# Re-read once on first admission to close the watcher subscription window.
|
||||||
agent.runtime_resolver.invalidate()
|
agent.runtime_resolver.invalidate()
|
||||||
|
# Recovery must finish before WebSocket and other channels begin
|
||||||
|
# accepting new input. That makes a new user message reliably
|
||||||
|
# supersede an old recoverable turn instead of racing its queue.
|
||||||
|
await recovery.scan()
|
||||||
async def _run_agent() -> None:
|
async def _run_agent() -> None:
|
||||||
try:
|
try:
|
||||||
await mcp_provider.connect()
|
await mcp_provider.connect()
|
||||||
@@ -915,6 +930,7 @@ def _run_gateway(
|
|||||||
name="nanobot-webui-dev-server",
|
name="nanobot-webui-dev-server",
|
||||||
))
|
))
|
||||||
runtime_tasks = asyncio.gather(*tasks)
|
runtime_tasks = asyncio.gather(*tasks)
|
||||||
|
startup_complete = True
|
||||||
shutdown_task = asyncio.create_task(
|
shutdown_task = asyncio.create_task(
|
||||||
shutdown_event.wait(),
|
shutdown_event.wait(),
|
||||||
name="nanobot-gateway-shutdown",
|
name="nanobot-gateway-shutdown",
|
||||||
@@ -936,6 +952,10 @@ def _run_gateway(
|
|||||||
|
|
||||||
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
|
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
|
||||||
console.print(traceback.format_exc())
|
console.print(traceback.format_exc())
|
||||||
|
if not startup_complete:
|
||||||
|
# Do not report a successful gateway command when startup
|
||||||
|
# failed before any runtime task or listener was created.
|
||||||
|
raise typer.Exit(1)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
if shutdown_task and not shutdown_task.done():
|
if shutdown_task and not shutdown_task.done():
|
||||||
@@ -943,6 +963,10 @@ def _run_gateway(
|
|||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await shutdown_task
|
await shutdown_task
|
||||||
cron.stop()
|
cron.stop()
|
||||||
|
# A gateway exit interrupts ownership of active turns; it is
|
||||||
|
# not the same as the user stopping a turn. Keep checkpoints
|
||||||
|
# so the next gateway can offer an explicit Continue action.
|
||||||
|
agent.preserve_inflight_turns_on_shutdown()
|
||||||
agent.stop()
|
agent.stop()
|
||||||
# Cancel runtime tasks first, then deterministically close
|
# Cancel runtime tasks first, then deterministically close
|
||||||
# exec/MCP resources while the event loop is still alive.
|
# exec/MCP resources while the event loop is still alive.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Give nanobot processes recognizable operating-system names."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
_ROLES: Final = {"agent", "gateway", "webui"}
|
||||||
|
|
||||||
|
|
||||||
|
def _set_process_title(title: str) -> None:
|
||||||
|
# Process titles are short; do not trade Linux /proc environment visibility for
|
||||||
|
# extra title storage. setproctitle reads this switch when it is imported.
|
||||||
|
os.environ.setdefault("SPT_NOENV", "1")
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(title)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cli_process_identity(args: list[str]) -> None:
|
||||||
|
"""Name this CLI process after the nanobot role it is running."""
|
||||||
|
if os.name == "nt":
|
||||||
|
# Windows process managers use the console launcher's executable name,
|
||||||
|
# which packaging already generates as ``nanobot.exe``.
|
||||||
|
return
|
||||||
|
role = args[0] if args and args[0] in _ROLES else None
|
||||||
|
_set_process_title(f"nanobot-{role}" if role else "nanobot")
|
||||||
|
|
||||||
|
|
||||||
|
def named_executable(executable: str, *, name: str, directory: Path) -> str:
|
||||||
|
"""Return a stable POSIX symlink whose basename identifies a child process."""
|
||||||
|
if os.name == "nt":
|
||||||
|
return executable
|
||||||
|
try:
|
||||||
|
target = Path(executable).resolve(strict=True)
|
||||||
|
digest = hashlib.sha256(os.fsencode(target)).hexdigest()[:12]
|
||||||
|
link_dir = directory / digest
|
||||||
|
link = link_dir / name
|
||||||
|
link_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
if link.is_symlink() and link.resolve(strict=False) == target:
|
||||||
|
return str(link)
|
||||||
|
if link.exists():
|
||||||
|
return executable
|
||||||
|
pending = link.with_name(f".{name}.{os.getpid()}")
|
||||||
|
pending.unlink(missing_ok=True)
|
||||||
|
pending.symlink_to(target)
|
||||||
|
os.replace(pending, link)
|
||||||
|
except OSError:
|
||||||
|
return executable
|
||||||
|
return str(link)
|
||||||
@@ -17,6 +17,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
|
from nanobot.cli.process_identity import named_executable
|
||||||
from nanobot.cli.runtime_config import _model_display
|
from nanobot.cli.runtime_config import _model_display
|
||||||
from nanobot.cli.webui_support import (
|
from nanobot.cli.webui_support import (
|
||||||
_gateway_health_ready,
|
_gateway_health_ready,
|
||||||
@@ -229,7 +230,12 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
|
|||||||
detail = (install.stderr or install.stdout).strip().splitlines()
|
detail = (install.stderr or install.stdout).strip().splitlines()
|
||||||
suffix = f": {detail[-1]}" if detail else ""
|
suffix = f": {detail[-1]}" if detail else ""
|
||||||
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
|
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
|
||||||
return [bun, str(source_dir / "src" / "index.ts")]
|
executable = named_executable(
|
||||||
|
bun,
|
||||||
|
name="nanobot-tui",
|
||||||
|
directory=get_data_dir() / "run" / "executables",
|
||||||
|
)
|
||||||
|
return [executable, str(source_dir / "src" / "index.ts")]
|
||||||
|
|
||||||
|
|
||||||
def _download_release_tui(asset: str) -> Path | None:
|
def _download_release_tui(asset: str) -> Path | None:
|
||||||
|
|||||||
@@ -96,22 +96,6 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
# it; poll() both reaps it and reports the real lifecycle state.
|
# it; poll() both reaps it and reports the real lifecycle state.
|
||||||
self._owned_process: Any | None = None
|
self._owned_process: Any | None = None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
|
|
||||||
"""Update a managed state file after the recorded process restarts."""
|
|
||||||
if not paths.state_path.exists():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
return
|
|
||||||
state["pid"] = os.getpid()
|
|
||||||
runtime = cls(paths=paths)
|
|
||||||
state.pop("stable_identity", None)
|
|
||||||
state.update(runtime.process_identity_record(os.getpid()))
|
|
||||||
state["started_at"] = _utc_now()
|
|
||||||
runtime._write_state(state)
|
|
||||||
|
|
||||||
def start_background(self, options: _StartOptionsT) -> ProcessResult:
|
def start_background(self, options: _StartOptionsT) -> ProcessResult:
|
||||||
"""Start the configured command as a detached process."""
|
"""Start the configured command as a detached process."""
|
||||||
with self._lifecycle_lock():
|
with self._lifecycle_lock():
|
||||||
|
|||||||
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await self._notify_fallback_model(fallback_model)
|
|
||||||
|
|
||||||
fallback_kwargs = {
|
fallback_kwargs = {
|
||||||
**kwargs,
|
**kwargs,
|
||||||
"model": fallback_model,
|
"model": fallback_model,
|
||||||
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
|
|||||||
fallback_response = await call(fallback_provider, fallback_kwargs)
|
fallback_response = await call(fallback_provider, fallback_kwargs)
|
||||||
|
|
||||||
if fallback_response.finish_reason != "error":
|
if fallback_response.finish_reason != "error":
|
||||||
|
# Do not publish a model switch merely because a fallback was
|
||||||
|
# attempted. A fallback can fail just like the primary, and
|
||||||
|
# the WebUI would otherwise show a misleading success signal.
|
||||||
|
# Publish only after this response is known to be usable.
|
||||||
|
await self._notify_fallback_model(fallback_model)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Fallback '{}' succeeded after primary '{}' failed",
|
"Fallback '{}' succeeded after primary '{}' failed",
|
||||||
fallback_model, primary_model,
|
fallback_model, primary_model,
|
||||||
|
|||||||
@@ -251,6 +251,26 @@ def _refusal_event_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_summary_event_key(
|
||||||
|
item_id: object,
|
||||||
|
summary_index: object,
|
||||||
|
) -> tuple[str | None, int] | None:
|
||||||
|
"""Identify one reasoning summary part across its text deltas."""
|
||||||
|
if not isinstance(summary_index, int) or isinstance(summary_index, bool):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
item_id if isinstance(item_id, str) else None,
|
||||||
|
summary_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _separate_reasoning_part(content: str | None, part: str) -> str:
|
||||||
|
"""Separate summary parts only when the provider supplied no whitespace."""
|
||||||
|
if content and not content[-1].isspace() and not part[0].isspace():
|
||||||
|
return "\n" + part
|
||||||
|
return part
|
||||||
|
|
||||||
|
|
||||||
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
|
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
|
||||||
"""Return only text not already surfaced by refusal deltas."""
|
"""Return only text not already surfaced by refusal deltas."""
|
||||||
if not streamed_text:
|
if not streamed_text:
|
||||||
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
|
|||||||
usage: dict[str, int] = {}
|
usage: dict[str, int] = {}
|
||||||
reasoning_content: str | None = None
|
reasoning_content: str | None = None
|
||||||
streamed_reasoning = False
|
streamed_reasoning = False
|
||||||
|
reasoning_summary_key: tuple[str | None, int] | None = None
|
||||||
refusal_seen = False
|
refusal_seen = False
|
||||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||||
emitted_refusal_text = ""
|
emitted_refusal_text = ""
|
||||||
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
|
|||||||
elif event_type == "response.reasoning_summary_text.delta":
|
elif event_type == "response.reasoning_summary_text.delta":
|
||||||
delta_text = event.get("delta") or ""
|
delta_text = event.get("delta") or ""
|
||||||
if delta_text:
|
if delta_text:
|
||||||
|
summary_key = _reasoning_summary_event_key(
|
||||||
|
event.get("item_id"),
|
||||||
|
event.get("summary_index"),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
summary_key is not None
|
||||||
|
and reasoning_summary_key is not None
|
||||||
|
and summary_key != reasoning_summary_key
|
||||||
|
):
|
||||||
|
delta_text = _separate_reasoning_part(reasoning_content, delta_text)
|
||||||
|
if summary_key is not None:
|
||||||
|
reasoning_summary_key = summary_key
|
||||||
reasoning_content = (reasoning_content or "") + delta_text
|
reasoning_content = (reasoning_content or "") + delta_text
|
||||||
streamed_reasoning = True
|
streamed_reasoning = True
|
||||||
if on_reasoning_delta:
|
if on_reasoning_delta:
|
||||||
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
|||||||
text = summary.get("text")
|
text = summary.get("text")
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
parts.append(text)
|
parts.append(text)
|
||||||
return "".join(parts) or None
|
content = ""
|
||||||
|
for part in parts:
|
||||||
|
content += _separate_reasoning_part(content, part)
|
||||||
|
return content or None
|
||||||
|
|
||||||
|
|
||||||
def parse_response_output(
|
def parse_response_output(
|
||||||
|
|||||||
+135
-2
@@ -48,15 +48,21 @@ _SESSION_PREVIEW_MAX_CHARS = 120
|
|||||||
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
||||||
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
||||||
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
|
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
|
||||||
|
_RUNTIME_CHECKPOINT_DATA_ERRORS = (OSError, *_SESSION_DATA_ERRORS)
|
||||||
_PROVIDER_STATE_RECORD_TYPE = "provider_state"
|
_PROVIDER_STATE_RECORD_TYPE = "provider_state"
|
||||||
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
|
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
|
||||||
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})'
|
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})'
|
||||||
)
|
)
|
||||||
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
|
_RUNTIME_CHECKPOINT_VERSION = 1
|
||||||
|
_RUNTIME_CHECKPOINT_SUFFIX = ".checkpoint.json"
|
||||||
_FORK_VOLATILE_METADATA_KEYS = {
|
_FORK_VOLATILE_METADATA_KEYS = {
|
||||||
"goal_state",
|
"goal_state",
|
||||||
"pending_user_turn",
|
"pending_user_turn",
|
||||||
|
"pending_user_followups",
|
||||||
"runtime_checkpoint",
|
"runtime_checkpoint",
|
||||||
"session_handle",
|
"session_handle",
|
||||||
|
"webui_recovery",
|
||||||
"thread_goal",
|
"thread_goal",
|
||||||
"title",
|
"title",
|
||||||
"title_user_edited",
|
"title_user_edited",
|
||||||
@@ -1001,6 +1007,9 @@ class JsonlSessionStore:
|
|||||||
def get_session_path(self, key: str) -> Path:
|
def get_session_path(self, key: str) -> Path:
|
||||||
return self.sessions_dir / f"{self.storage_key(key)}.jsonl"
|
return self.sessions_dir / f"{self.storage_key(key)}.jsonl"
|
||||||
|
|
||||||
|
def get_runtime_checkpoint_path(self, key: str) -> Path:
|
||||||
|
return self.sessions_dir / f"{self.storage_key(key)}{_RUNTIME_CHECKPOINT_SUFFIX}"
|
||||||
|
|
||||||
def get_legacy_lossy_path(self, key: str) -> Path:
|
def get_legacy_lossy_path(self, key: str) -> Path:
|
||||||
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
||||||
|
|
||||||
@@ -1066,7 +1075,7 @@ class JsonlSessionStore:
|
|||||||
else:
|
else:
|
||||||
messages.append(data)
|
messages.append(data)
|
||||||
|
|
||||||
return Session(
|
session = Session(
|
||||||
key=key,
|
key=key,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
created_at=created_at or datetime.now(),
|
created_at=created_at or datetime.now(),
|
||||||
@@ -1075,6 +1084,8 @@ class JsonlSessionStore:
|
|||||||
last_consolidated=last_consolidated,
|
last_consolidated=last_consolidated,
|
||||||
provider_state=provider_state,
|
provider_state=provider_state,
|
||||||
)
|
)
|
||||||
|
self._overlay_runtime_checkpoint_unlocked(session, path)
|
||||||
|
return session
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to load session {}: {}", key, e)
|
logger.warning("Failed to load session {}: {}", key, e)
|
||||||
repaired = self._repair_unlocked(key)
|
repaired = self._repair_unlocked(key)
|
||||||
@@ -1159,7 +1170,7 @@ class JsonlSessionStore:
|
|||||||
if not messages and not metadata and provider_state is None:
|
if not messages and not metadata and provider_state is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return Session(
|
session = Session(
|
||||||
key=key,
|
key=key,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
created_at=created_at or datetime.now(),
|
created_at=created_at or datetime.now(),
|
||||||
@@ -1168,6 +1179,8 @@ class JsonlSessionStore:
|
|||||||
last_consolidated=last_consolidated,
|
last_consolidated=last_consolidated,
|
||||||
provider_state=provider_state,
|
provider_state=provider_state,
|
||||||
)
|
)
|
||||||
|
self._overlay_runtime_checkpoint_unlocked(session, path)
|
||||||
|
return session
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Repair failed for session {}: {}", key, e)
|
logger.warning("Repair failed for session {}: {}", key, e)
|
||||||
return None
|
return None
|
||||||
@@ -1186,6 +1199,105 @@ class JsonlSessionStore:
|
|||||||
with self._session_files_lock:
|
with self._session_files_lock:
|
||||||
self._save_unlocked(session, fsync=fsync)
|
self._save_unlocked(session, fsync=fsync)
|
||||||
|
|
||||||
|
def save_runtime_checkpoint(self, session: Session) -> None:
|
||||||
|
"""Atomically persist only the volatile in-flight turn state.
|
||||||
|
|
||||||
|
A checkpoint is written several times during a tool-heavy turn. Keeping it
|
||||||
|
beside the append history avoids copying the full transcript at each safe
|
||||||
|
recovery boundary.
|
||||||
|
"""
|
||||||
|
with self._session_files_lock:
|
||||||
|
path = self.get_session_path(session.key)
|
||||||
|
if not path.exists():
|
||||||
|
# A user turn normally creates the session first. Internal callers
|
||||||
|
# may checkpoint a fresh session, so establish the durable base once.
|
||||||
|
self._save_unlocked(session)
|
||||||
|
return
|
||||||
|
|
||||||
|
checkpoint = session.metadata.get(_RUNTIME_CHECKPOINT_KEY)
|
||||||
|
if not isinstance(checkpoint, dict):
|
||||||
|
self.get_runtime_checkpoint_path(session.key).unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"version": _RUNTIME_CHECKPOINT_VERSION,
|
||||||
|
"session_key": session.key,
|
||||||
|
"base_updated_at": session.updated_at.isoformat(),
|
||||||
|
"base_message_count": len(session.messages),
|
||||||
|
"checkpoint": checkpoint,
|
||||||
|
"provider_state": (
|
||||||
|
session.provider_state.to_private_record()
|
||||||
|
if session.provider_state is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
target = self.get_runtime_checkpoint_path(session.key)
|
||||||
|
tmp = target.with_name(f".{target.name}.{secrets.token_hex(8)}.tmp")
|
||||||
|
try:
|
||||||
|
with open(tmp, "x", encoding="utf-8") as handle:
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
json.dump(
|
||||||
|
payload,
|
||||||
|
handle,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
os.replace(tmp, target)
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _overlay_runtime_checkpoint_unlocked(self, session: Session, main_path: Path) -> None:
|
||||||
|
checkpoint_path = self.get_runtime_checkpoint_path(session.key)
|
||||||
|
try:
|
||||||
|
checkpoint_stat = checkpoint_path.lstat()
|
||||||
|
if not stat.S_ISREG(checkpoint_stat.st_mode):
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring non-regular runtime checkpoint for session {}",
|
||||||
|
session.key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# A complete session save supersedes an older sidecar. This comparison
|
||||||
|
# closes the small crash window between replacing the JSONL and unlinking
|
||||||
|
# its previous checkpoint.
|
||||||
|
if main_path.stat().st_mtime_ns > checkpoint_stat.st_mtime_ns:
|
||||||
|
checkpoint_path.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
raw = _json_object(json.loads(checkpoint_path.read_text(encoding="utf-8")))
|
||||||
|
if (
|
||||||
|
raw.get("version") != _RUNTIME_CHECKPOINT_VERSION
|
||||||
|
or raw.get("session_key") != session.key
|
||||||
|
or raw.get("base_updated_at") != session.updated_at.isoformat()
|
||||||
|
or raw.get("base_message_count") != len(session.messages)
|
||||||
|
or not isinstance(raw.get("checkpoint"), dict)
|
||||||
|
):
|
||||||
|
checkpoint_path.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
provider_record = raw.get("provider_state")
|
||||||
|
provider_state = (
|
||||||
|
None
|
||||||
|
if provider_record is None
|
||||||
|
else ProviderConversationState.from_private_record(provider_record)
|
||||||
|
)
|
||||||
|
if provider_record is not None and provider_state is None:
|
||||||
|
raise ValueError("invalid checkpoint provider state")
|
||||||
|
session.metadata[_RUNTIME_CHECKPOINT_KEY] = cast(
|
||||||
|
dict[str, Any], raw["checkpoint"]
|
||||||
|
)
|
||||||
|
session.provider_state = provider_state
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except _RUNTIME_CHECKPOINT_DATA_ERRORS as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring invalid runtime checkpoint for session {}: {}",
|
||||||
|
session.key,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
# Atomic writes mean a malformed target cannot become valid later.
|
||||||
|
# Remove it once so future loads do not repeatedly parse and log it.
|
||||||
|
with suppress(OSError):
|
||||||
|
if checkpoint_path.is_file() and not checkpoint_path.is_symlink():
|
||||||
|
checkpoint_path.unlink()
|
||||||
|
|
||||||
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
|
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
path = self.get_session_path(session.key)
|
path = self.get_session_path(session.key)
|
||||||
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||||
@@ -1215,6 +1327,10 @@ class JsonlSessionStore:
|
|||||||
|
|
||||||
os.replace(tmp_path, path)
|
os.replace(tmp_path, path)
|
||||||
|
|
||||||
|
# The full record now contains the authoritative checkpoint state (or
|
||||||
|
# its removal), so an older volatile overlay is no longer needed.
|
||||||
|
self.get_runtime_checkpoint_path(session.key).unlink(missing_ok=True)
|
||||||
|
|
||||||
if fsync:
|
if fsync:
|
||||||
with suppress(PermissionError):
|
with suppress(PermissionError):
|
||||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||||
@@ -1278,6 +1394,7 @@ class JsonlSessionStore:
|
|||||||
def _delete_unlocked(self, key: str) -> bool:
|
def _delete_unlocked(self, key: str) -> bool:
|
||||||
paths = [
|
paths = [
|
||||||
self.get_session_path(key),
|
self.get_session_path(key),
|
||||||
|
self.get_runtime_checkpoint_path(key),
|
||||||
self.get_legacy_lossy_path(key),
|
self.get_legacy_lossy_path(key),
|
||||||
self.get_legacy_session_path(key),
|
self.get_legacy_session_path(key),
|
||||||
]
|
]
|
||||||
@@ -1585,6 +1702,10 @@ class SessionManager:
|
|||||||
"""Get the collision-resistant workspace path for a session."""
|
"""Get the collision-resistant workspace path for a session."""
|
||||||
return self._jsonl_store.get_session_path(key)
|
return self._jsonl_store.get_session_path(key)
|
||||||
|
|
||||||
|
def _get_runtime_checkpoint_path(self, key: str) -> Path:
|
||||||
|
"""Get the private in-flight checkpoint path for a session."""
|
||||||
|
return self._jsonl_store.get_runtime_checkpoint_path(key)
|
||||||
|
|
||||||
def _get_legacy_lossy_path(self, key: str) -> Path:
|
def _get_legacy_lossy_path(self, key: str) -> Path:
|
||||||
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
||||||
return self._jsonl_store.get_legacy_lossy_path(key)
|
return self._jsonl_store.get_legacy_lossy_path(key)
|
||||||
@@ -1653,6 +1774,18 @@ class SessionManager:
|
|||||||
self._store.save(session, fsync=fsync)
|
self._store.save(session, fsync=fsync)
|
||||||
self._remember(session)
|
self._remember(session)
|
||||||
|
|
||||||
|
def save_runtime_checkpoint(self, session: Session) -> None:
|
||||||
|
"""Persist volatile recovery state without rewriting long history."""
|
||||||
|
if not session.policy.persist:
|
||||||
|
return
|
||||||
|
if self._store is self._jsonl_store:
|
||||||
|
self._jsonl_store.save_runtime_checkpoint(session)
|
||||||
|
self._remember(session)
|
||||||
|
return
|
||||||
|
# Third-party stores keep their existing all-or-nothing semantics until
|
||||||
|
# they opt into a dedicated checkpoint primitive.
|
||||||
|
self.save(session)
|
||||||
|
|
||||||
def rename_model_preset(self, old_name: str, new_name: str) -> int:
|
def rename_model_preset(self, old_name: str, new_name: str) -> int:
|
||||||
"""Rename a session-scoped model preset across durable and live sessions."""
|
"""Rename a session-scoped model preset across durable and live sessions."""
|
||||||
if old_name == new_name:
|
if old_name == new_name:
|
||||||
|
|||||||
@@ -0,0 +1,939 @@
|
|||||||
|
"""Durable, side-effect-safe recovery for interrupted WebUI turns.
|
||||||
|
|
||||||
|
The coordinator owns restart policy. AgentLoop only exposes checkpoint
|
||||||
|
materialization and an admission hook, so transport code never has to guess
|
||||||
|
whether an interrupted tool call is safe to replay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Protocol, cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.outbound_events import (
|
||||||
|
RecoveryStateEvent,
|
||||||
|
SessionUpdatedEvent,
|
||||||
|
outbound_message_for_event,
|
||||||
|
)
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session import turn_continuation
|
||||||
|
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
|
||||||
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
||||||
|
|
||||||
|
RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
|
PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
RECOVERY_METADATA_KEY = "webui_recovery"
|
||||||
|
RECOVERY_INBOUND_METADATA_KEY = "_webui_recovery_id"
|
||||||
|
PENDING_FOLLOWUPS_KEY = "pending_user_followups"
|
||||||
|
PENDING_FOLLOWUP_ID_KEY = "_recovery_followup_id"
|
||||||
|
PROVIDER_STATE_CHECKPOINT_VERSION_KEY = "provider_state_checkpoint_version"
|
||||||
|
PROVIDER_STATE_CHECKPOINT_VERSION = "v1"
|
||||||
|
|
||||||
|
_RECOVERY_STATUSES = frozenset({"resuming", "awaiting_user", "recovered", "failed"})
|
||||||
|
_UNCERTAIN_TOOL_PHASES = frozenset({"awaiting_tools"})
|
||||||
|
_KNOWN_CHECKPOINT_PHASES = frozenset(
|
||||||
|
{"final_response", "tools_completed", "awaiting_tools", "error"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryActionError(ValueError):
|
||||||
|
"""A stale or malformed recovery action from an authenticated WebUI."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryAdmission(Protocol):
|
||||||
|
"""Narrow AgentLoop boundary for explicit recovery validation."""
|
||||||
|
|
||||||
|
async def admit(self, message: InboundMessage) -> bool: ...
|
||||||
|
|
||||||
|
def register_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None: ...
|
||||||
|
|
||||||
|
def unregister_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def record_pending_followup(session: Session, message: InboundMessage) -> str | None:
|
||||||
|
"""Durably journal a WebUI follow-up before injecting it into a live turn."""
|
||||||
|
if message.channel != "websocket":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
metadata_value: object = json.loads(json.dumps(message.metadata))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("Skipping non-serializable WebUI follow-up for recovery")
|
||||||
|
return None
|
||||||
|
if not isinstance(metadata_value, dict):
|
||||||
|
return None
|
||||||
|
metadata = cast(dict[str, Any], metadata_value)
|
||||||
|
existing_id = metadata.pop(PENDING_FOLLOWUP_ID_KEY, None)
|
||||||
|
followup_id = (
|
||||||
|
existing_id
|
||||||
|
if isinstance(existing_id, str) and existing_id
|
||||||
|
else uuid4().hex
|
||||||
|
)
|
||||||
|
records = _pending_followup_records(session)
|
||||||
|
if any(record.get("id") == followup_id for record in records):
|
||||||
|
return followup_id
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"id": followup_id,
|
||||||
|
"sender_id": message.sender_id,
|
||||||
|
"chat_id": message.chat_id,
|
||||||
|
"content": message.content,
|
||||||
|
"media": list(message.media or []),
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# This journal is the recovery source of truth, not a mirror of the
|
||||||
|
# bounded in-memory injection queue. A queued turn can receive more
|
||||||
|
# follow-ups than the live queue accepts; dropping older journal entries
|
||||||
|
# would make those acknowledged user messages unrecoverable after a
|
||||||
|
# gateway restart. Entries are removed only once their user rows are
|
||||||
|
# committed by ``acknowledge_pending_followups``.
|
||||||
|
session.metadata[PENDING_FOLLOWUPS_KEY] = records
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
return followup_id
|
||||||
|
|
||||||
|
|
||||||
|
def pending_followups(session: Session) -> list[InboundMessage]:
|
||||||
|
"""Decode still-unacknowledged follow-ups from durable session metadata."""
|
||||||
|
messages: list[InboundMessage] = []
|
||||||
|
for record in _pending_followup_records(session):
|
||||||
|
followup_id = cast(object, record.get("id"))
|
||||||
|
sender_id = cast(object, record.get("sender_id"))
|
||||||
|
chat_id = cast(object, record.get("chat_id"))
|
||||||
|
content = cast(object, record.get("content"))
|
||||||
|
metadata = cast(object, record.get("metadata"))
|
||||||
|
if (
|
||||||
|
not isinstance(followup_id, str)
|
||||||
|
or not followup_id
|
||||||
|
or not isinstance(sender_id, str)
|
||||||
|
or not sender_id
|
||||||
|
or not isinstance(chat_id, str)
|
||||||
|
or not chat_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if not isinstance(content, str) or not isinstance(metadata, dict):
|
||||||
|
continue
|
||||||
|
media_value = cast(object, record.get("media"))
|
||||||
|
media = (
|
||||||
|
[item for item in cast(list[object], media_value) if isinstance(item, str)]
|
||||||
|
if isinstance(media_value, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id=sender_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
content=content,
|
||||||
|
media=media,
|
||||||
|
metadata={**cast(dict[str, Any], metadata), PENDING_FOLLOWUP_ID_KEY: followup_id},
|
||||||
|
session_key_override=session.key,
|
||||||
|
require_existing_session=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_pending_followups(session: Session, followup_ids: Iterable[str]) -> None:
|
||||||
|
"""Remove journal entries whose user rows were committed to history."""
|
||||||
|
acknowledged = set(followup_ids)
|
||||||
|
if not acknowledged:
|
||||||
|
return
|
||||||
|
records = [record for record in _pending_followup_records(session) if record.get("id") not in acknowledged]
|
||||||
|
if records:
|
||||||
|
session.metadata[PENDING_FOLLOWUPS_KEY] = records
|
||||||
|
else:
|
||||||
|
session.metadata.pop(PENDING_FOLLOWUPS_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_followup_records(session: Session) -> list[dict[str, Any]]:
|
||||||
|
raw = cast(object, session.metadata.get(PENDING_FOLLOWUPS_KEY))
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
values = cast(list[object], raw)
|
||||||
|
return [cast(dict[str, Any], value) for value in values if isinstance(value, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _checkpoint_message_key(message: Mapping[str, Any]) -> tuple[Any, ...]:
|
||||||
|
return (
|
||||||
|
message.get("role"),
|
||||||
|
message.get("content"),
|
||||||
|
message.get("tool_call_id"),
|
||||||
|
message.get("name"),
|
||||||
|
message.get("tool_calls"),
|
||||||
|
message.get("reasoning_content"),
|
||||||
|
message.get("thinking_blocks"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _checkpoint_tool_call_ids(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
result_rows: bool = False,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Validate checkpoint tool rows and return their stable IDs."""
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return None
|
||||||
|
ids: list[str] = []
|
||||||
|
for raw in cast(list[object], value):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
row = cast(dict[str, Any], raw)
|
||||||
|
id_key = "tool_call_id" if result_rows else "id"
|
||||||
|
call_id = cast(object, row.get(id_key))
|
||||||
|
if not isinstance(call_id, str) or not call_id:
|
||||||
|
return None
|
||||||
|
if result_rows:
|
||||||
|
if row.get("role") != "tool":
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
function_value = cast(object, row.get("function"))
|
||||||
|
if not isinstance(function_value, dict):
|
||||||
|
return None
|
||||||
|
function = cast(dict[str, Any], function_value)
|
||||||
|
name = cast(object, function.get("name"))
|
||||||
|
if not isinstance(name, str) or not name:
|
||||||
|
return None
|
||||||
|
ids.append(call_id)
|
||||||
|
return ids if len(ids) == len(set(ids)) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_checkpoint_is_well_formed(checkpoint: Mapping[str, Any]) -> bool:
|
||||||
|
"""Return whether a checkpoint is safe to offer for continuation.
|
||||||
|
|
||||||
|
Restoration stays tolerant so Dismiss can always clear corrupt state.
|
||||||
|
Continue is stricter: silently dropping a malformed tool result could make
|
||||||
|
the model repeat an external side effect.
|
||||||
|
"""
|
||||||
|
assistant_value = cast(object, checkpoint.get("assistant_message"))
|
||||||
|
if not isinstance(assistant_value, dict):
|
||||||
|
return False
|
||||||
|
assistant = cast(dict[str, Any], assistant_value)
|
||||||
|
if assistant.get("role") != "assistant":
|
||||||
|
return False
|
||||||
|
|
||||||
|
completed_ids = _checkpoint_tool_call_ids(
|
||||||
|
cast(object, checkpoint.get("completed_tool_results")),
|
||||||
|
result_rows=True,
|
||||||
|
)
|
||||||
|
pending_ids = _checkpoint_tool_call_ids(
|
||||||
|
cast(object, checkpoint.get("pending_tool_calls")),
|
||||||
|
)
|
||||||
|
if completed_ids is None or pending_ids is None:
|
||||||
|
return False
|
||||||
|
assistant_calls_value = cast(object, assistant.get("tool_calls"))
|
||||||
|
assistant_call_ids = (
|
||||||
|
[]
|
||||||
|
if assistant_calls_value is None
|
||||||
|
else _checkpoint_tool_call_ids(assistant_calls_value)
|
||||||
|
)
|
||||||
|
if assistant_call_ids is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
phase = checkpoint.get("phase")
|
||||||
|
if phase == "final_response":
|
||||||
|
content = cast(object, assistant.get("content"))
|
||||||
|
return (
|
||||||
|
isinstance(content, str)
|
||||||
|
and bool(content.strip())
|
||||||
|
and not assistant_call_ids
|
||||||
|
and not completed_ids
|
||||||
|
and not pending_ids
|
||||||
|
)
|
||||||
|
if phase == "awaiting_tools":
|
||||||
|
return (
|
||||||
|
bool(assistant_call_ids)
|
||||||
|
and not completed_ids
|
||||||
|
and len(assistant_call_ids) == len(pending_ids)
|
||||||
|
and set(assistant_call_ids) == set(pending_ids)
|
||||||
|
)
|
||||||
|
if phase == "tools_completed":
|
||||||
|
return (
|
||||||
|
bool(assistant_call_ids)
|
||||||
|
and not pending_ids
|
||||||
|
and len(assistant_call_ids) == len(completed_ids)
|
||||||
|
and set(assistant_call_ids) == set(completed_ids)
|
||||||
|
)
|
||||||
|
# Error checkpoints have no current producer contract. Treat legacy or
|
||||||
|
# future instances as review-only until their exact persisted shape is
|
||||||
|
# specified; guessing here could make a partial side effect repeat.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def restore_runtime_checkpoint(session: Session) -> bool:
|
||||||
|
"""Materialize the durable checkpoint exactly once and clear it.
|
||||||
|
|
||||||
|
Pending tool calls become explicit interrupted tool results. They are
|
||||||
|
never executed here. Provider-native state is retained only for the two
|
||||||
|
checkpoint shapes known to be synchronized with persisted history.
|
||||||
|
"""
|
||||||
|
checkpoint = cast(object, session.metadata.get(RUNTIME_CHECKPOINT_KEY))
|
||||||
|
if not isinstance(checkpoint, dict):
|
||||||
|
return False
|
||||||
|
data = cast(dict[str, Any], checkpoint)
|
||||||
|
assistant = cast(object, data.get("assistant_message"))
|
||||||
|
completed_value = cast(object, data.get("completed_tool_results"))
|
||||||
|
pending_value = cast(object, data.get("pending_tool_calls"))
|
||||||
|
completed = cast(list[object], completed_value) if isinstance(completed_value, list) else []
|
||||||
|
pending = cast(list[object], pending_value) if isinstance(pending_value, list) else []
|
||||||
|
|
||||||
|
restored: list[dict[str, Any]] = []
|
||||||
|
if isinstance(assistant, dict):
|
||||||
|
assistant_row = cast(dict[str, Any], assistant)
|
||||||
|
else:
|
||||||
|
assistant_row = {}
|
||||||
|
if assistant_row.get("role") == "assistant":
|
||||||
|
row = dict(assistant_row)
|
||||||
|
row.setdefault("timestamp", datetime.now().isoformat())
|
||||||
|
restored.append(row)
|
||||||
|
for value in completed:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
tool_result = cast(dict[str, Any], value)
|
||||||
|
if tool_result.get("role") != "tool":
|
||||||
|
continue
|
||||||
|
row = dict(tool_result)
|
||||||
|
row.setdefault("timestamp", datetime.now().isoformat())
|
||||||
|
restored.append(row)
|
||||||
|
for value in pending:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
tool_call = cast(dict[str, Any], value)
|
||||||
|
tool_call_id = tool_call.get("id")
|
||||||
|
function_value = cast(object, tool_call.get("function"))
|
||||||
|
if not isinstance(tool_call_id, str) or not tool_call_id:
|
||||||
|
continue
|
||||||
|
function = (
|
||||||
|
cast(dict[str, Any], function_value)
|
||||||
|
if isinstance(function_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
name = function.get("name")
|
||||||
|
restored.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"name": name if isinstance(name, str) and name else "tool",
|
||||||
|
"content": "Error: Task interrupted before this tool finished.",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"_recovery_interrupted": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
overlap = 0
|
||||||
|
for size in range(min(len(session.messages), len(restored)), 0, -1):
|
||||||
|
if all(
|
||||||
|
_checkpoint_message_key(left) == _checkpoint_message_key(right)
|
||||||
|
for left, right in zip(session.messages[-size:], restored[:size])
|
||||||
|
):
|
||||||
|
overlap = size
|
||||||
|
break
|
||||||
|
session.messages.extend(restored[overlap:])
|
||||||
|
|
||||||
|
assistant_data = cast(dict[str, Any], assistant) if isinstance(assistant, dict) else None
|
||||||
|
synchronized = (
|
||||||
|
data.get(PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
|
||||||
|
== PROVIDER_STATE_CHECKPOINT_VERSION
|
||||||
|
)
|
||||||
|
phase = data.get("phase")
|
||||||
|
exact_final = (
|
||||||
|
phase == "final_response"
|
||||||
|
and assistant_data is not None
|
||||||
|
and assistant_data.get("role") == "assistant"
|
||||||
|
and not data.get("completed_tool_results")
|
||||||
|
and not data.get("pending_tool_calls")
|
||||||
|
)
|
||||||
|
exact_tools = (
|
||||||
|
phase == "tools_completed"
|
||||||
|
and assistant_data is not None
|
||||||
|
and assistant_data.get("role") == "assistant"
|
||||||
|
and not data.get("pending_tool_calls")
|
||||||
|
)
|
||||||
|
if not (synchronized and (exact_final or exact_tools)):
|
||||||
|
session.provider_state = None
|
||||||
|
|
||||||
|
session.metadata.pop(PENDING_USER_TURN_KEY, None)
|
||||||
|
session.metadata.pop(RUNTIME_CHECKPOINT_KEY, None)
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _discard_runtime_checkpoint(session: Session) -> bool:
|
||||||
|
"""Drop checkpoint state that cannot be projected into valid history."""
|
||||||
|
if RUNTIME_CHECKPOINT_KEY not in session.metadata:
|
||||||
|
return False
|
||||||
|
session.metadata.pop(RUNTIME_CHECKPOINT_KEY, None)
|
||||||
|
session.provider_state = None
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def restore_pending_interruption(session: Session, *, superseded: bool = False) -> bool:
|
||||||
|
"""Close a persisted user-only turn without pretending it was answered."""
|
||||||
|
if not session.metadata.get(PENDING_USER_TURN_KEY):
|
||||||
|
return False
|
||||||
|
if session.messages and session.messages[-1].get("role") == "user":
|
||||||
|
content = (
|
||||||
|
"Task recovery was superseded by a newer message."
|
||||||
|
if superseded
|
||||||
|
else "Error: Task interrupted before a response was generated."
|
||||||
|
)
|
||||||
|
session.messages.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"_recovery_interrupted": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.provider_state = None
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
session.metadata.pop(PENDING_USER_TURN_KEY, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def append_recovery_interruption(session: Session, *, superseded: bool = False) -> None:
|
||||||
|
"""Close a restored partial turn whose last durable row is not the user message."""
|
||||||
|
if session.messages and session.messages[-1].get("_recovery_interrupted") is True:
|
||||||
|
return
|
||||||
|
session.messages.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": (
|
||||||
|
"Task recovery was superseded by a newer message."
|
||||||
|
if superseded
|
||||||
|
else "Error: Task recovery was interrupted before completion."
|
||||||
|
),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"_recovery_interrupted": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.provider_state = None
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
def recovery_state_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
"""Return a sanitized recovery state suitable for the WebSocket wire."""
|
||||||
|
value = metadata.get(RECOVERY_METADATA_KEY) if metadata else None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
state = cast(dict[str, Any], value)
|
||||||
|
status = state.get("status")
|
||||||
|
recovery_id = state.get("recovery_id")
|
||||||
|
if status not in _RECOVERY_STATUSES or not isinstance(recovery_id, str):
|
||||||
|
return None
|
||||||
|
payload: dict[str, Any] = {"status": status, "recovery_id": recovery_id}
|
||||||
|
reason = state.get("reason")
|
||||||
|
if isinstance(reason, str) and reason:
|
||||||
|
payload["reason"] = reason
|
||||||
|
attempts = state.get("attempts")
|
||||||
|
if isinstance(attempts, int) and attempts >= 0:
|
||||||
|
payload["attempts"] = attempts
|
||||||
|
can_continue = state.get("can_continue")
|
||||||
|
if isinstance(can_continue, bool):
|
||||||
|
payload["can_continue"] = can_continue
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(slots=True)
|
||||||
|
class RecoveryCoordinator:
|
||||||
|
"""Classify, announce, and gate durable WebUI turn recovery."""
|
||||||
|
|
||||||
|
sessions: SessionManager
|
||||||
|
bus: MessageBus
|
||||||
|
unified_session: bool = False
|
||||||
|
_active_recovery_tasks: dict[str, asyncio.Task[Any]] = dataclasses.field(
|
||||||
|
default_factory=dict,
|
||||||
|
init=False,
|
||||||
|
repr=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None:
|
||||||
|
"""Track the task that owns an explicit recovery continuation."""
|
||||||
|
self._active_recovery_tasks[session_key] = task
|
||||||
|
|
||||||
|
def unregister_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None:
|
||||||
|
"""Drop a recovery task without removing a newer task for the same session."""
|
||||||
|
if self._active_recovery_tasks.get(session_key) is task:
|
||||||
|
self._active_recovery_tasks.pop(session_key, None)
|
||||||
|
|
||||||
|
async def _cancel_active_recovery(self, session_key: str) -> None:
|
||||||
|
"""Stop an explicit continuation before accepting newer user input."""
|
||||||
|
task = self._active_recovery_tasks.get(session_key)
|
||||||
|
if task is None or task is asyncio.current_task() or task.done():
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
# AgentLoop's cancellation path materializes any partial checkpoint and
|
||||||
|
# releases its pending queue. Wait for that ownership to be released
|
||||||
|
# before the newer message is routed.
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
|
||||||
|
async def scan(self) -> None:
|
||||||
|
"""Recover every interrupted WebUI session once at gateway startup."""
|
||||||
|
for key in self._recovery_candidates():
|
||||||
|
metadata_payload = self.sessions.read_session_metadata(key)
|
||||||
|
raw_metadata = metadata_payload.get("metadata") if metadata_payload else None
|
||||||
|
metadata = cast(dict[str, Any], raw_metadata) if isinstance(raw_metadata, dict) else {}
|
||||||
|
route = self._websocket_route_for(key, metadata)
|
||||||
|
if route is None:
|
||||||
|
continue
|
||||||
|
unfinished = self._has_unfinished_webui_transcript(key)
|
||||||
|
if not self._needs_recovery(metadata) and not unfinished:
|
||||||
|
continue
|
||||||
|
session = self.sessions.get_or_create(key)
|
||||||
|
try:
|
||||||
|
await self._recover_session(session, route[1])
|
||||||
|
await self._requeue_pending_followups(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to recover interrupted WebUI session {}", session.key)
|
||||||
|
state = recovery_state_from_metadata(session.metadata)
|
||||||
|
failed = self._set_state(
|
||||||
|
session,
|
||||||
|
status="failed",
|
||||||
|
recovery_id=cast(str, state["recovery_id"]) if state else uuid4().hex,
|
||||||
|
attempts=cast(int, state.get("attempts", 0)) if state else 0,
|
||||||
|
reason="recovery_failed",
|
||||||
|
can_continue=False,
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(route[1], failed)
|
||||||
|
|
||||||
|
def _recovery_candidates(self) -> list[str]:
|
||||||
|
"""Discover canonical and transcript-only WebUI sessions cheaply."""
|
||||||
|
candidates = dict.fromkeys(
|
||||||
|
key
|
||||||
|
for item in self.sessions.list_sessions()
|
||||||
|
if isinstance((key := item.get("key")), str)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Imported lazily because the sidebar index also projects recovery
|
||||||
|
# metadata. The index is the owner of transcript-only discovery;
|
||||||
|
# duplicating its filename and migration rules here would drift.
|
||||||
|
from nanobot.webui.session_list_index import list_webui_sessions
|
||||||
|
|
||||||
|
for item in list_webui_sessions(self.sessions):
|
||||||
|
key = item.get("key")
|
||||||
|
if isinstance(key, str):
|
||||||
|
candidates.setdefault(key, None)
|
||||||
|
except Exception:
|
||||||
|
# Canonical checkpoint recovery remains available even if the
|
||||||
|
# optional display-history index is corrupt or unavailable.
|
||||||
|
logger.exception("failed to discover transcript-only WebUI sessions")
|
||||||
|
return list(candidates)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _needs_recovery(metadata: Mapping[str, Any]) -> bool:
|
||||||
|
if metadata.get(PENDING_USER_TURN_KEY) is True:
|
||||||
|
return True
|
||||||
|
if isinstance(metadata.get(RUNTIME_CHECKPOINT_KEY), dict):
|
||||||
|
return True
|
||||||
|
followups = metadata.get(PENDING_FOLLOWUPS_KEY)
|
||||||
|
if isinstance(followups, list) and len(cast(list[object], followups)) > 0:
|
||||||
|
return True
|
||||||
|
state = recovery_state_from_metadata(metadata)
|
||||||
|
return bool(state and state["status"] in {"resuming", "awaiting_user", "failed"})
|
||||||
|
|
||||||
|
async def admit(self, message: InboundMessage) -> bool:
|
||||||
|
"""Reject stale queued recoveries and let new user input supersede them."""
|
||||||
|
recovery_id = message.metadata.get(RECOVERY_INBOUND_METADATA_KEY)
|
||||||
|
if isinstance(recovery_id, str):
|
||||||
|
session = self.sessions.get_or_create(message.session_key)
|
||||||
|
state = recovery_state_from_metadata(session.metadata)
|
||||||
|
return bool(
|
||||||
|
state
|
||||||
|
and state["status"] == "resuming"
|
||||||
|
and state["recovery_id"] == recovery_id
|
||||||
|
)
|
||||||
|
if message.channel != "websocket":
|
||||||
|
return True
|
||||||
|
session = self.sessions.get_or_create(message.session_key)
|
||||||
|
state = recovery_state_from_metadata(session.metadata)
|
||||||
|
if state and state["status"] in {"resuming", "awaiting_user", "failed"}:
|
||||||
|
await self._cancel_active_recovery(message.session_key)
|
||||||
|
restore_runtime_checkpoint(session)
|
||||||
|
if not restore_pending_interruption(session, superseded=True):
|
||||||
|
append_recovery_interruption(session, superseded=True)
|
||||||
|
recovered = self._set_state(
|
||||||
|
session,
|
||||||
|
status="recovered",
|
||||||
|
recovery_id=cast(str, state["recovery_id"]),
|
||||||
|
attempts=cast(int, state.get("attempts", 0)),
|
||||||
|
reason="superseded",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(message.chat_id, recovered)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def turn_completed(self, session_key: str) -> None:
|
||||||
|
"""Resolve a resuming state after the recovered turn commits."""
|
||||||
|
session = self.sessions.get_or_create(session_key)
|
||||||
|
state = recovery_state_from_metadata(session.metadata)
|
||||||
|
if not state or state["status"] != "resuming":
|
||||||
|
return
|
||||||
|
route = self._websocket_route(session)
|
||||||
|
if route is None:
|
||||||
|
return
|
||||||
|
recovered = self._set_state(
|
||||||
|
session,
|
||||||
|
status="recovered",
|
||||||
|
recovery_id=cast(str, state["recovery_id"]),
|
||||||
|
attempts=cast(int, state.get("attempts", 0)),
|
||||||
|
reason="continued",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(route[1], recovered)
|
||||||
|
|
||||||
|
async def handle_action(self, action: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Apply an authenticated continue/dismiss operation."""
|
||||||
|
chat_id = payload.get("chat_id")
|
||||||
|
recovery_id = payload.get("recovery_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id:
|
||||||
|
raise RecoveryActionError("missing chat_id")
|
||||||
|
if not isinstance(recovery_id, str) or not recovery_id:
|
||||||
|
raise RecoveryActionError("missing recovery_id")
|
||||||
|
session = self.sessions.get_or_create(self._session_key(chat_id))
|
||||||
|
state = recovery_state_from_metadata(session.metadata)
|
||||||
|
if not state or state["recovery_id"] != recovery_id:
|
||||||
|
raise RecoveryActionError("recovery state is stale", status=409)
|
||||||
|
|
||||||
|
if action == "dismiss":
|
||||||
|
restore_runtime_checkpoint(session)
|
||||||
|
restore_pending_interruption(session)
|
||||||
|
next_state = self._set_state(
|
||||||
|
session,
|
||||||
|
status="recovered",
|
||||||
|
recovery_id=recovery_id,
|
||||||
|
attempts=cast(int, state.get("attempts", 0)),
|
||||||
|
reason="dismissed",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, next_state)
|
||||||
|
return next_state
|
||||||
|
if action != "continue":
|
||||||
|
raise RecoveryActionError("unknown recovery action")
|
||||||
|
if state["status"] not in {"awaiting_user", "failed"}:
|
||||||
|
raise RecoveryActionError("recovery is not waiting for confirmation", status=409)
|
||||||
|
if state.get("can_continue") is False:
|
||||||
|
raise RecoveryActionError("recovery context is unavailable", status=409)
|
||||||
|
next_state = self._set_state(
|
||||||
|
session,
|
||||||
|
status="resuming",
|
||||||
|
recovery_id=recovery_id,
|
||||||
|
attempts=cast(int, state.get("attempts", 0)) + 1,
|
||||||
|
reason="user_confirmed",
|
||||||
|
resume_message_count=len(session.messages),
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, next_state)
|
||||||
|
await self._queue_continuation(session, chat_id, next_state)
|
||||||
|
return next_state
|
||||||
|
|
||||||
|
async def _recover_session(self, session: Session, chat_id: str) -> None:
|
||||||
|
checkpoint_value = cast(object, session.metadata.get(RUNTIME_CHECKPOINT_KEY))
|
||||||
|
checkpoint = (
|
||||||
|
cast(dict[str, Any], checkpoint_value)
|
||||||
|
if isinstance(checkpoint_value, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
pending = session.metadata.get(PENDING_USER_TURN_KEY) is True
|
||||||
|
state = recovery_state_from_metadata(session.metadata)
|
||||||
|
if not pending and checkpoint is None:
|
||||||
|
if state and state["status"] == "resuming":
|
||||||
|
resume_count = self._resume_message_count(session)
|
||||||
|
if resume_count is not None and len(session.messages) > resume_count:
|
||||||
|
next_state = self._set_state(
|
||||||
|
session,
|
||||||
|
status="recovered",
|
||||||
|
recovery_id=cast(str, state["recovery_id"]),
|
||||||
|
attempts=cast(int, state.get("attempts", 0)),
|
||||||
|
reason="committed",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
next_state = self._set_state(
|
||||||
|
session,
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id=cast(str, state["recovery_id"]),
|
||||||
|
attempts=cast(int, state.get("attempts", 1)),
|
||||||
|
reason="loop_guard",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, next_state)
|
||||||
|
elif self._has_unfinished_webui_transcript(session.key):
|
||||||
|
# A normal last-client shutdown can materialize the checkpoint
|
||||||
|
# before the process exits. In that path there is no pending
|
||||||
|
# marker left to classify, but the append-only transcript still
|
||||||
|
# contains an activity row without a turn_end. Treat it as an
|
||||||
|
# interrupted turn instead of letting the UI resurrect it as a
|
||||||
|
# forever-running spinner.
|
||||||
|
can_continue = self._has_saved_continuation_context(session)
|
||||||
|
waiting = self._set_state(
|
||||||
|
session,
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id=uuid4().hex,
|
||||||
|
attempts=0,
|
||||||
|
reason=(
|
||||||
|
"interrupted_with_saved_context"
|
||||||
|
if can_continue
|
||||||
|
else "interrupted_without_checkpoint"
|
||||||
|
),
|
||||||
|
can_continue=can_continue,
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, waiting)
|
||||||
|
return
|
||||||
|
if state and state["status"] in {"awaiting_user", "failed"}:
|
||||||
|
await self._publish(chat_id, state)
|
||||||
|
return
|
||||||
|
if state and state["status"] == "resuming":
|
||||||
|
restore_runtime_checkpoint(session)
|
||||||
|
restore_pending_interruption(session)
|
||||||
|
waiting = self._set_state(
|
||||||
|
session,
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id=cast(str, state["recovery_id"]),
|
||||||
|
attempts=cast(int, state.get("attempts", 1)),
|
||||||
|
reason="loop_guard",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, waiting)
|
||||||
|
return
|
||||||
|
|
||||||
|
recovery_id = uuid4().hex
|
||||||
|
phase = checkpoint.get("phase") if checkpoint is not None else None
|
||||||
|
pending_calls = checkpoint.get("pending_tool_calls") if checkpoint is not None else None
|
||||||
|
if checkpoint is not None and phase not in _KNOWN_CHECKPOINT_PHASES:
|
||||||
|
_discard_runtime_checkpoint(session)
|
||||||
|
restore_pending_interruption(session)
|
||||||
|
waiting = self._set_state(
|
||||||
|
session,
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id=recovery_id,
|
||||||
|
attempts=0,
|
||||||
|
reason="checkpoint_unknown",
|
||||||
|
can_continue=False,
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, waiting)
|
||||||
|
return
|
||||||
|
if checkpoint is not None and not _runtime_checkpoint_is_well_formed(checkpoint):
|
||||||
|
_discard_runtime_checkpoint(session)
|
||||||
|
restore_pending_interruption(session)
|
||||||
|
waiting = self._set_state(
|
||||||
|
session,
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id=recovery_id,
|
||||||
|
attempts=0,
|
||||||
|
reason="checkpoint_invalid",
|
||||||
|
can_continue=False,
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, waiting)
|
||||||
|
return
|
||||||
|
if phase == "final_response":
|
||||||
|
restore_runtime_checkpoint(session)
|
||||||
|
recovered = self._set_state(
|
||||||
|
session,
|
||||||
|
status="recovered",
|
||||||
|
recovery_id=recovery_id,
|
||||||
|
attempts=0,
|
||||||
|
reason="answer_restored",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, recovered)
|
||||||
|
return
|
||||||
|
if phase in _UNCERTAIN_TOOL_PHASES or pending_calls:
|
||||||
|
restore_runtime_checkpoint(session)
|
||||||
|
waiting = self._set_state(
|
||||||
|
session,
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id=recovery_id,
|
||||||
|
attempts=0,
|
||||||
|
reason="tool_state_unknown",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, waiting)
|
||||||
|
return
|
||||||
|
# A gateway restart is a lifecycle boundary. Never enqueue model work
|
||||||
|
# implicitly: even a synchronized checkpoint may sit next to an
|
||||||
|
# external side effect that the user should review first. The final
|
||||||
|
# answer path above only restores persisted output; it never executes.
|
||||||
|
restore_runtime_checkpoint(session)
|
||||||
|
waiting = self._set_state(
|
||||||
|
session,
|
||||||
|
status="awaiting_user",
|
||||||
|
recovery_id=recovery_id,
|
||||||
|
attempts=0,
|
||||||
|
reason="restart_requires_confirmation",
|
||||||
|
)
|
||||||
|
self.sessions.save(session)
|
||||||
|
await self._publish(chat_id, waiting)
|
||||||
|
|
||||||
|
async def _queue_continuation(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
chat_id: str,
|
||||||
|
state: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
recovery_id = cast(str, state["recovery_id"])
|
||||||
|
await self.bus.publish_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="system:recovery",
|
||||||
|
chat_id=chat_id,
|
||||||
|
content=(
|
||||||
|
"Continue the interrupted request from the saved conversation context. "
|
||||||
|
"Do not repeat completed work or mention the restart unless it affects the answer."
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"webui": True,
|
||||||
|
"_wants_stream": True,
|
||||||
|
WEBUI_TURN_METADATA_KEY: f"recovery:{recovery_id}",
|
||||||
|
RECOVERY_INBOUND_METADATA_KEY: recovery_id,
|
||||||
|
turn_continuation.INTERNAL_CONTINUATION_META: True,
|
||||||
|
turn_continuation.SKIP_USER_PERSIST_META: True,
|
||||||
|
},
|
||||||
|
session_key_override=session.key,
|
||||||
|
require_existing_session=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _requeue_pending_followups(self, session: Session) -> None:
|
||||||
|
"""Return durable live-turn follow-ups to the bus after a restart."""
|
||||||
|
for message in pending_followups(session):
|
||||||
|
await self.bus.publish_inbound(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resume_message_count(session: Session) -> int | None:
|
||||||
|
raw_value = cast(object, session.metadata.get(RECOVERY_METADATA_KEY))
|
||||||
|
value = cast(dict[str, Any], raw_value) if isinstance(raw_value, dict) else None
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
count = value.get("resume_message_count")
|
||||||
|
return count if isinstance(count, int) and count >= 0 else None
|
||||||
|
|
||||||
|
async def _publish(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
state: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Publish the recovery state and invalidate its sidebar projection."""
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id=chat_id,
|
||||||
|
event=RecoveryStateEvent(
|
||||||
|
status=cast(str, state["status"]),
|
||||||
|
recovery_id=cast(str, state["recovery_id"]),
|
||||||
|
reason=cast(str | None, state.get("reason")),
|
||||||
|
attempts=cast(int, state.get("attempts", 0)),
|
||||||
|
can_continue=cast(bool | None, state.get("can_continue")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id=chat_id,
|
||||||
|
event=SessionUpdatedEvent(scope="thread"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_state(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
recovery_id: str,
|
||||||
|
attempts: int,
|
||||||
|
reason: str,
|
||||||
|
resume_message_count: int | None = None,
|
||||||
|
can_continue: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
state = {
|
||||||
|
"status": status,
|
||||||
|
"recovery_id": recovery_id,
|
||||||
|
"attempts": max(0, attempts),
|
||||||
|
"reason": reason,
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
if not can_continue:
|
||||||
|
state["can_continue"] = False
|
||||||
|
if resume_message_count is not None:
|
||||||
|
state["resume_message_count"] = max(0, resume_message_count)
|
||||||
|
session.metadata[RECOVERY_METADATA_KEY] = state
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _session_key(self, chat_id: str) -> str:
|
||||||
|
return UNIFIED_SESSION_KEY if self.unified_session else f"websocket:{chat_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_unfinished_webui_transcript(session_key: str) -> bool:
|
||||||
|
"""Detect a stale WebUI activity tail after an unclean gateway stop.
|
||||||
|
|
||||||
|
The transcript is intentionally consulted only as a last-resort signal:
|
||||||
|
a durable pending turn or runtime checkpoint always takes precedence.
|
||||||
|
This keeps browser disconnects harmless while preventing a materialized
|
||||||
|
partial turn from being presented as active forever after a restart.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from nanobot.webui.transcript import has_unfinished_transcript_tail
|
||||||
|
|
||||||
|
return has_unfinished_transcript_tail(session_key)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
# Recovery must fail closed if the optional display transcript is
|
||||||
|
# corrupt or unavailable; the normal checkpoint path still applies.
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_saved_continuation_context(session: Session) -> bool:
|
||||||
|
"""Whether an interrupted turn left model-visible context to continue from."""
|
||||||
|
last_user = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index in range(len(session.messages) - 1, -1, -1)
|
||||||
|
if session.messages[index].get("role") == "user"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if last_user is None:
|
||||||
|
return False
|
||||||
|
tail = session.messages[last_user + 1 :]
|
||||||
|
return bool(tail) and (
|
||||||
|
tail[-1].get("role") == "tool"
|
||||||
|
or any(message.get("_recovery_interrupted") is True for message in tail)
|
||||||
|
or any(
|
||||||
|
message.get("role") == "assistant" and bool(message.get("tool_calls"))
|
||||||
|
for message in tail
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _websocket_route(session: Session) -> tuple[str, str] | None:
|
||||||
|
return RecoveryCoordinator._websocket_route_for(session.key, session.metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _websocket_route_for(
|
||||||
|
session_key: str,
|
||||||
|
metadata: Mapping[str, Any],
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
if session_key.startswith("websocket:"):
|
||||||
|
chat_id = session_key.split(":", 1)[1]
|
||||||
|
return ("websocket", chat_id) if chat_id else None
|
||||||
|
if session_key == UNIFIED_SESSION_KEY:
|
||||||
|
route = last_channel_from_metadata(metadata)
|
||||||
|
if route and route[0] == "websocket":
|
||||||
|
return route
|
||||||
|
return None
|
||||||
@@ -43,6 +43,7 @@ from nanobot.runtime_context import public_history_message
|
|||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
from nanobot.session.recovery import RecoveryCoordinator
|
||||||
from nanobot.session.session_handles import session_handle_for_name
|
from nanobot.session.session_handles import session_handle_for_name
|
||||||
from nanobot.session.session_messages import (
|
from nanobot.session.session_messages import (
|
||||||
SessionMessageEnvelope,
|
SessionMessageEnvelope,
|
||||||
@@ -495,6 +496,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
|
|||||||
if context.runtime is not None
|
if context.runtime is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
fallback=True,
|
||||||
),
|
),
|
||||||
metadata=context.metadata,
|
metadata=context.metadata,
|
||||||
)
|
)
|
||||||
@@ -510,6 +512,7 @@ class WebuiTurnCoordinator:
|
|||||||
bus: MessageBus
|
bus: MessageBus
|
||||||
sessions: SessionManager
|
sessions: SessionManager
|
||||||
schedule_background: Callable[[Awaitable[None]], None]
|
schedule_background: Callable[[Awaitable[None]], None]
|
||||||
|
recovery: RecoveryCoordinator | None = None
|
||||||
|
|
||||||
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
|
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
|
||||||
"""Subscribe this coordinator to runtime events."""
|
"""Subscribe this coordinator to runtime events."""
|
||||||
@@ -653,6 +656,8 @@ class WebuiTurnCoordinator:
|
|||||||
event.runtime.context_window_tokens if event.runtime is not None else None
|
event.runtime.context_window_tokens if event.runtime is not None else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if self.recovery is not None:
|
||||||
|
await self.recovery.turn_completed(event.context.session_key)
|
||||||
self._schedule_title_update_from_event(event)
|
self._schedule_title_update_from_event(event)
|
||||||
|
|
||||||
async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None:
|
async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None:
|
||||||
@@ -684,15 +689,6 @@ class WebuiTurnCoordinator:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def publish_run_status(
|
|
||||||
self,
|
|
||||||
msg: InboundMessage,
|
|
||||||
status: str,
|
|
||||||
*,
|
|
||||||
started_at: float | None = None,
|
|
||||||
) -> None:
|
|
||||||
await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
|
|
||||||
|
|
||||||
async def handle_turn_end(
|
async def handle_turn_end(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ def build_gateway_services(
|
|||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
|
recovery_action: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
settings = WebUISettingsServices.create(
|
settings = WebUISettingsServices.create(
|
||||||
@@ -131,6 +132,7 @@ def build_gateway_services(
|
|||||||
mcp_runtime_status=mcp_runtime_status,
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
mcp_reload=mcp_reload,
|
||||||
skill_state_action=skill_state_action,
|
skill_state_action=skill_state_action,
|
||||||
|
recovery_action=recovery_action,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ class GatewayTokenStore:
|
|||||||
self.api_tokens[token_value] = expiry
|
self.api_tokens[token_value] = expiry
|
||||||
return token_value
|
return token_value
|
||||||
|
|
||||||
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
|
|
||||||
return self.take_issued_token_audience(token_value) is not None
|
|
||||||
|
|
||||||
def take_issued_token_audience(
|
def take_issued_token_audience(
|
||||||
self,
|
self,
|
||||||
token_value: str | None,
|
token_value: str | None,
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ from nanobot.session.manager import (
|
|||||||
_metadata_title, # pyright: ignore[reportPrivateUsage]
|
_metadata_title, # pyright: ignore[reportPrivateUsage]
|
||||||
)
|
)
|
||||||
from nanobot.session.model_selection import model_preset_from_metadata
|
from nanobot.session.model_selection import model_preset_from_metadata
|
||||||
|
from nanobot.session.recovery import recovery_state_from_metadata
|
||||||
|
|
||||||
_INDEX_VERSION = 7
|
_INDEX_VERSION = 8
|
||||||
_INDEX_FILENAME = ".webui_session_index.json"
|
_INDEX_FILENAME = ".webui_session_index.json"
|
||||||
_MODEL_PRESET_FIELD = "model_preset"
|
_MODEL_PRESET_FIELD = "model_preset"
|
||||||
_ROW_SOURCE_FIELD = "_source"
|
_ROW_SOURCE_FIELD = "_source"
|
||||||
@@ -245,6 +246,7 @@ def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dic
|
|||||||
"title": row.get("title", ""),
|
"title": row.get("title", ""),
|
||||||
"preview": row.get("preview", ""),
|
"preview": row.get("preview", ""),
|
||||||
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
||||||
|
"recovery_state": row.get("recovery_state"),
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
||||||
"path": str(path),
|
"path": str(path),
|
||||||
@@ -485,6 +487,7 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
|
|||||||
"title": _metadata_title(session.metadata),
|
"title": _metadata_title(session.metadata),
|
||||||
"preview": _preview_from_messages(session.messages),
|
"preview": _preview_from_messages(session.messages),
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
||||||
|
"recovery_state": recovery_state_from_metadata(session.metadata),
|
||||||
**_indexed_workspace_scope_fields(session.metadata),
|
**_indexed_workspace_scope_fields(session.metadata),
|
||||||
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
@@ -601,6 +604,7 @@ def _scan_transcript_row(
|
|||||||
"title": "",
|
"title": "",
|
||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
_MODEL_PRESET_FIELD: None,
|
_MODEL_PRESET_FIELD: None,
|
||||||
|
"recovery_state": None,
|
||||||
**_indexed_workspace_scope_fields({}),
|
**_indexed_workspace_scope_fields({}),
|
||||||
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
|
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
|
||||||
"file": stem,
|
"file": stem,
|
||||||
@@ -687,6 +691,7 @@ def _scan_session_row(
|
|||||||
"title": _metadata_title(metadata),
|
"title": _metadata_title(metadata),
|
||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
||||||
|
"recovery_state": recovery_state_from_metadata(metadata),
|
||||||
**_indexed_workspace_scope_fields(metadata),
|
**_indexed_workspace_scope_fields(metadata),
|
||||||
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ from nanobot.webui.nanobot_features_api import (
|
|||||||
nanobot_features_payload,
|
nanobot_features_payload,
|
||||||
)
|
)
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
create_model_configuration,
|
create_model_configuration,
|
||||||
create_provider_settings,
|
create_provider_settings,
|
||||||
@@ -490,17 +489,6 @@ class WebUISettingsRouter:
|
|||||||
lambda: request_image_generation_reload(self.bus),
|
lambda: request_image_generation_reload(self.bus),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _apply_image_generation_runtime_change(
|
|
||||||
self,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
updated, restart_cleared = (
|
|
||||||
await self._apply_image_generation_runtime_change_result(payload)
|
|
||||||
)
|
|
||||||
if restart_cleared:
|
|
||||||
self._restart_sections.discard("image")
|
|
||||||
return updated
|
|
||||||
|
|
||||||
async def _reload_mcp_runtime(self) -> dict[str, Any]:
|
async def _reload_mcp_runtime(self) -> dict[str, Any]:
|
||||||
if self._mcp_reload is None:
|
if self._mcp_reload is None:
|
||||||
return {
|
return {
|
||||||
@@ -531,47 +519,9 @@ class WebUISettingsRouter:
|
|||||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
return self._query(request)
|
return self._query(request)
|
||||||
|
|
||||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
|
||||||
return self._query(request)
|
|
||||||
|
|
||||||
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
|
||||||
payload = _mutation_payload(request)
|
|
||||||
if payload is not None:
|
|
||||||
api_key = payload.get("api_key")
|
|
||||||
if api_key is not None and not isinstance(api_key, str):
|
|
||||||
raise WebUISettingsError("API service API key must be a string")
|
|
||||||
return self._query(request)
|
|
||||||
|
|
||||||
def _api_runtime(self) -> ApiRuntime:
|
def _api_runtime(self) -> ApiRuntime:
|
||||||
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
||||||
|
|
||||||
def _api_service_payload(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
last_action: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return capability_domain.api_service_payload(
|
|
||||||
self.settings,
|
|
||||||
self._api_runtime(),
|
|
||||||
last_action=last_action,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _masked_secret(value: str) -> str | None:
|
|
||||||
return capability_domain.masked_api_secret(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _api_runtime_message(message: str) -> str:
|
|
||||||
return capability_domain.api_runtime_message(message)
|
|
||||||
|
|
||||||
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
|
||||||
return self._system.parse_channel_values(
|
|
||||||
SettingsRequest(
|
|
||||||
query=self._query(request),
|
|
||||||
payload=_mutation_payload(request),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _save_channel_config_values(
|
def _save_channel_config_values(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -610,17 +560,6 @@ class WebUISettingsRouter:
|
|||||||
allow_install=allow_install,
|
allow_install=allow_install,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _feature_runtime_fallback(
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
message: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return system_domain.SystemSettingsHandler.feature_runtime_fallback(
|
|
||||||
payload,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _allow_feature_package_install(
|
def _allow_feature_package_install(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
|
|||||||
+103
-29
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
|
|||||||
record.update(transcript_overrides)
|
record.update(transcript_overrides)
|
||||||
return self.append(chat_id, record)
|
return self.append(chat_id, record)
|
||||||
|
|
||||||
|
def prepare_and_append_stream_event(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
event: dict[str, Any],
|
||||||
|
*,
|
||||||
|
completed_text: str | None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
phase: str | None = None,
|
||||||
|
include_source: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Annotate every live stream event, but persist only completed segments.
|
||||||
|
|
||||||
|
Delta frames are a transport concern: retaining each token-sized chunk
|
||||||
|
would turn rendering cadence into disk-write cadence. The matching end
|
||||||
|
event carries the canonical segment text used by history replay.
|
||||||
|
"""
|
||||||
|
self.prepare_event(
|
||||||
|
chat_id,
|
||||||
|
event,
|
||||||
|
metadata=metadata,
|
||||||
|
phase=phase,
|
||||||
|
include_source=include_source,
|
||||||
|
)
|
||||||
|
if event.get("event") in {"delta", "reasoning_delta"}:
|
||||||
|
return True
|
||||||
|
record = dict(event)
|
||||||
|
if completed_text is not None:
|
||||||
|
record["text"] = completed_text
|
||||||
|
return self.append(chat_id, record)
|
||||||
|
|
||||||
def append_user_message(
|
def append_user_message(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -1840,7 +1870,14 @@ def replay_transcript_to_ui_messages(
|
|||||||
return None
|
return None
|
||||||
return str(last.get("id"))
|
return str(last.get("id"))
|
||||||
|
|
||||||
def demote_interrupted_assistant(segment: str) -> None:
|
def close_interrupted_assistant() -> None:
|
||||||
|
"""Close an answer segment before tool activity without changing its semantics.
|
||||||
|
|
||||||
|
The wire protocol already marks answer, reasoning, and activity phases.
|
||||||
|
A later tool event does not turn previously emitted answer text into
|
||||||
|
reasoning; preserving ``content`` also keeps live and replay projections
|
||||||
|
equivalent.
|
||||||
|
"""
|
||||||
nonlocal buffer_message_id, buffer_parts
|
nonlocal buffer_message_id, buffer_parts
|
||||||
for i in range(len(messages) - 1, -1, -1):
|
for i in range(len(messages) - 1, -1, -1):
|
||||||
candidate = messages[i]
|
candidate = messages[i]
|
||||||
@@ -1856,19 +1893,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
or candidate.get("media")
|
or candidate.get("media")
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
reasoning_parts = [
|
messages[i] = {**candidate, "isStreaming": False}
|
||||||
part
|
|
||||||
for part in (candidate.get("reasoning"), content)
|
|
||||||
if isinstance(part, str) and part.strip()
|
|
||||||
]
|
|
||||||
messages[i] = {
|
|
||||||
**candidate,
|
|
||||||
"content": "",
|
|
||||||
"reasoning": "\n\n".join(reasoning_parts),
|
|
||||||
"reasoningStreaming": False,
|
|
||||||
"isStreaming": False,
|
|
||||||
"activitySegmentId": candidate.get("activitySegmentId") or segment,
|
|
||||||
}
|
|
||||||
if buffer_message_id == candidate.get("id"):
|
if buffer_message_id == candidate.get("id"):
|
||||||
buffer_message_id = None
|
buffer_message_id = None
|
||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
@@ -1903,13 +1928,24 @@ def replay_transcript_to_ui_messages(
|
|||||||
kept.append(m)
|
kept.append(m)
|
||||||
messages = kept
|
messages = kept
|
||||||
|
|
||||||
def stamp_latency(latency_ms: int) -> None:
|
def stamp_completion(
|
||||||
|
*,
|
||||||
|
latency_ms: int | None = None,
|
||||||
|
usage: dict[str, int] | None = None,
|
||||||
|
context_window_tokens: int | None = None,
|
||||||
|
) -> None:
|
||||||
for i in range(len(messages) - 1, -1, -1):
|
for i in range(len(messages) - 1, -1, -1):
|
||||||
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
|
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
|
||||||
|
completion: dict[str, Any] = {"isStreaming": False}
|
||||||
|
if latency_ms is not None:
|
||||||
|
completion["latencyMs"] = latency_ms
|
||||||
|
if usage:
|
||||||
|
completion["usage"] = usage
|
||||||
|
if context_window_tokens is not None:
|
||||||
|
completion["contextWindowTokens"] = context_window_tokens
|
||||||
messages[i] = {
|
messages[i] = {
|
||||||
**messages[i],
|
**messages[i],
|
||||||
"latencyMs": latency_ms,
|
**completion,
|
||||||
"isStreaming": False,
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2028,7 +2064,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
if not segment:
|
if not segment:
|
||||||
segment = _new_activity_segment(activate=False)
|
segment = _new_activity_segment(activate=False)
|
||||||
active_file_edit_segment_id = segment
|
active_file_edit_segment_id = segment
|
||||||
demote_interrupted_assistant(segment)
|
close_interrupted_assistant()
|
||||||
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
|
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
|
||||||
target_index = find_file_edit_trace_index(segment, edits)
|
target_index = find_file_edit_trace_index(segment, edits)
|
||||||
if target_index is not None:
|
if target_index is not None:
|
||||||
@@ -2215,20 +2251,17 @@ def replay_transcript_to_ui_messages(
|
|||||||
turn_fields = _turn_fields(rec, "answer")
|
turn_fields = _turn_fields(rec, "answer")
|
||||||
source_fields = _source_fields(rec)
|
source_fields = _source_fields(rec)
|
||||||
if isinstance(final_text, str):
|
if isinstance(final_text, str):
|
||||||
|
if buffer_message_id is None:
|
||||||
|
buffer_message_id = find_active_placeholder(messages, turn_fields)
|
||||||
if buffer_message_id is None:
|
if buffer_message_id is None:
|
||||||
buffer_message_id = _new_id("buf", idx)
|
buffer_message_id = _new_id("buf", idx)
|
||||||
messages.append(
|
messages.append({
|
||||||
{
|
|
||||||
"id": buffer_message_id,
|
"id": buffer_message_id,
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": final_text,
|
"content": "",
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**turn_fields,
|
|
||||||
**source_fields,
|
|
||||||
"createdAt": _created_at_ms(rec, idx),
|
"createdAt": _created_at_ms(rec, idx),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
else:
|
|
||||||
for i, m in enumerate(messages):
|
for i, m in enumerate(messages):
|
||||||
if m.get("id") == buffer_message_id:
|
if m.get("id") == buffer_message_id:
|
||||||
messages[i] = {
|
messages[i] = {
|
||||||
@@ -2274,6 +2307,16 @@ def replay_transcript_to_ui_messages(
|
|||||||
if ev == "reasoning_end":
|
if ev == "reasoning_end":
|
||||||
if suppress_until_turn_end:
|
if suppress_until_turn_end:
|
||||||
continue
|
continue
|
||||||
|
text = rec.get("text")
|
||||||
|
if isinstance(text, str) and text:
|
||||||
|
close_file_edit_phase_before_activity()
|
||||||
|
attach_reasoning_chunk(
|
||||||
|
messages,
|
||||||
|
text,
|
||||||
|
idx,
|
||||||
|
_turn_fields(rec, "reasoning"),
|
||||||
|
_created_at_ms(rec, idx),
|
||||||
|
)
|
||||||
close_reasoning(messages)
|
close_reasoning(messages)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -2315,7 +2358,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
if not trace_lines:
|
if not trace_lines:
|
||||||
continue
|
continue
|
||||||
segment = _ensure_activity_segment()
|
segment = _ensure_activity_segment()
|
||||||
demote_interrupted_assistant(segment)
|
close_interrupted_assistant()
|
||||||
last = messages[-1] if messages else None
|
last = messages[-1] if messages else None
|
||||||
if (
|
if (
|
||||||
last
|
last
|
||||||
@@ -2401,8 +2444,26 @@ def replay_transcript_to_ui_messages(
|
|||||||
messages[i] = {**m, "isStreaming": False}
|
messages[i] = {**m, "isStreaming": False}
|
||||||
prune_reasoning_only()
|
prune_reasoning_only()
|
||||||
lat = rec.get("latency_ms")
|
lat = rec.get("latency_ms")
|
||||||
if isinstance(lat, (int, float)) and lat >= 0:
|
usage = rec.get("usage")
|
||||||
stamp_latency(int(lat))
|
sanitized_usage = (
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in cast(dict[object, object], usage).items()
|
||||||
|
if isinstance(key, str) and type(value) is int and value >= 0
|
||||||
|
}
|
||||||
|
if isinstance(usage, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
context_window = rec.get("context_window_tokens")
|
||||||
|
stamp_completion(
|
||||||
|
latency_ms=int(lat) if isinstance(lat, (int, float)) and lat >= 0 else None,
|
||||||
|
usage=sanitized_usage,
|
||||||
|
context_window_tokens=(
|
||||||
|
int(context_window)
|
||||||
|
if isinstance(context_window, (int, float)) and context_window >= 0
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
buffer_message_id = None
|
buffer_message_id = None
|
||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
continue
|
continue
|
||||||
@@ -2480,6 +2541,19 @@ def has_pending_tool_calls(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_unfinished_transcript_tail(session_key: str) -> bool:
|
||||||
|
"""Return whether the active transcript ends in an unfinished turn.
|
||||||
|
|
||||||
|
Recovery runs at gateway startup and only needs the newest, still-active
|
||||||
|
turn. Completed turns are rotated into immutable segment files, so reading
|
||||||
|
every historical segment here would make restart cost grow with the full
|
||||||
|
conversation history.
|
||||||
|
"""
|
||||||
|
return has_pending_tool_calls(
|
||||||
|
_read_transcript_file(webui_transcript_path(session_key))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
|
def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
|
||||||
"""Return stable identities for turns with an explicitly persisted completion."""
|
"""Return stable identities for turns with an explicitly persisted completion."""
|
||||||
completed: list[str] = []
|
completed: list[str] = []
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from nanobot.cron.session_turns import is_bound_cron_job
|
|||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
from nanobot.security.workspace_access import WorkspaceScope
|
from nanobot.security.workspace_access import WorkspaceScope
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.session.recovery import RecoveryActionError
|
||||||
from nanobot.session.session_handles import (
|
from nanobot.session.session_handles import (
|
||||||
SessionHandleResolver,
|
SessionHandleResolver,
|
||||||
)
|
)
|
||||||
@@ -145,6 +146,8 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"skill.delete": "/api/webui/skills/delete",
|
"skill.delete": "/api/webui/skills/delete",
|
||||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||||
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
||||||
|
"recovery.continue": "/api/webui/recovery/continue",
|
||||||
|
"recovery.dismiss": "/api/webui/recovery/dismiss",
|
||||||
"settings.agent.update": "/api/settings/update",
|
"settings.agent.update": "/api/settings/update",
|
||||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||||
@@ -323,6 +326,9 @@ class GatewayHTTPHandler:
|
|||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
|
recovery_action: (
|
||||||
|
Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None
|
||||||
|
) = None,
|
||||||
log: Any = logger,
|
log: Any = logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -340,6 +346,7 @@ class GatewayHTTPHandler:
|
|||||||
disabled_skills if disabled_skills is not None else set()
|
disabled_skills if disabled_skills is not None else set()
|
||||||
)
|
)
|
||||||
self.skill_state_action = skill_state_action
|
self.skill_state_action = skill_state_action
|
||||||
|
self.recovery_action = recovery_action
|
||||||
self._skill_install_lock = asyncio.Lock()
|
self._skill_install_lock = asyncio.Lock()
|
||||||
self._folder_picker_lock = asyncio.Lock()
|
self._folder_picker_lock = asyncio.Lock()
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
@@ -454,6 +461,8 @@ class GatewayHTTPHandler:
|
|||||||
return True
|
return True
|
||||||
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
||||||
return True
|
return True
|
||||||
|
if path in {"/api/webui/recovery/continue", "/api/webui/recovery/dismiss"}:
|
||||||
|
return True
|
||||||
return path in {
|
return path in {
|
||||||
"/api/webui/skills/install",
|
"/api/webui/skills/install",
|
||||||
"/api/webui/skills/update",
|
"/api/webui/skills/update",
|
||||||
@@ -507,6 +516,11 @@ class GatewayHTTPHandler:
|
|||||||
if response is not None:
|
if response is not None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# Recovery routes
|
||||||
|
response = await self._dispatch_recovery_route(request, got)
|
||||||
|
if response is not None:
|
||||||
|
return response
|
||||||
|
|
||||||
# Session routes
|
# Session routes
|
||||||
response = await self._dispatch_session_routes(request, got)
|
response = await self._dispatch_session_routes(request, got)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
@@ -700,6 +714,27 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _dispatch_recovery_route(
|
||||||
|
self,
|
||||||
|
request: WsRequest,
|
||||||
|
path: str,
|
||||||
|
) -> Response | None:
|
||||||
|
match = re.fullmatch(r"/api/webui/recovery/(continue|dismiss)", path)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
if not getattr(request, _WEBUI_MUTATION_REQUEST_ATTR, False):
|
||||||
|
return _http_error(405, "WebUI recovery actions require an authenticated WebSocket")
|
||||||
|
if self.recovery_action is None:
|
||||||
|
return _http_error(503, "WebUI recovery is unavailable")
|
||||||
|
payload = _mutation_payload(request)
|
||||||
|
if payload is None:
|
||||||
|
return _http_error(400, "invalid recovery payload")
|
||||||
|
try:
|
||||||
|
result = await self.recovery_action(match.group(1), payload)
|
||||||
|
except RecoveryActionError as exc:
|
||||||
|
return _http_error(exc.status, str(exc))
|
||||||
|
return _http_json_response(result)
|
||||||
|
|
||||||
async def _handle_session_context_get(self, request: WsRequest, key: str) -> Response:
|
async def _handle_session_context_get(self, request: WsRequest, key: str) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
@@ -746,6 +781,10 @@ class GatewayHTTPHandler:
|
|||||||
for k, v in s.items()
|
for k, v in s.items()
|
||||||
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
|
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
|
||||||
}
|
}
|
||||||
|
# Keep the additive recovery field absent for ordinary sessions so
|
||||||
|
# older clients and compact list responses stay unchanged.
|
||||||
|
if row.get("recovery_state") is None:
|
||||||
|
row.pop("recovery_state", None)
|
||||||
chat_id = key.split(":", 1)[1]
|
chat_id = key.split(":", 1)[1]
|
||||||
started_at = websocket_turn_wall_started_at(chat_id)
|
started_at = websocket_turn_wall_started_at(chat_id)
|
||||||
if started_at is not None:
|
if started_at is not None:
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,6 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.12.0,<3.0.0",
|
"pydantic-settings>=2.12.0,<3.0.0",
|
||||||
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
|
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
|
||||||
"websockets>=15.0,<17.0",
|
"websockets>=15.0,<17.0",
|
||||||
"websocket-client>=1.9.0,<2.0.0",
|
|
||||||
"httpx[socks]>=0.28.0,<1.0.0",
|
"httpx[socks]>=0.28.0,<1.0.0",
|
||||||
"ddgs>=9.5.5,<10.0.0",
|
"ddgs>=9.5.5,<10.0.0",
|
||||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
"oauth-cli-kit>=0.1.6,<1.0.0",
|
||||||
@@ -40,6 +39,7 @@ dependencies = [
|
|||||||
"qrcode[pil]>=8.0",
|
"qrcode[pil]>=8.0",
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
|
"setproctitle>=1.3.7,<2.0.0; sys_platform != 'win32'",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
"mcp>=1.26.0,<2.0.0",
|
"mcp>=1.26.0,<2.0.0",
|
||||||
"json-repair>=0.57.0,<1.0.0",
|
"json-repair>=0.57.0,<1.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Refresh the native contributor avatar wall in README.md."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict, cast
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
REPOSITORY = "HKUDS/nanobot"
|
||||||
|
README = Path(__file__).resolve().parents[1] / "README.md"
|
||||||
|
START = "<!-- contributors:start -->"
|
||||||
|
END = "<!-- contributors:end -->"
|
||||||
|
PER_PAGE = 100
|
||||||
|
MAINTAINERS = {"re-bin", "chengyongru"}
|
||||||
|
|
||||||
|
|
||||||
|
class Contributor(TypedDict):
|
||||||
|
login: str
|
||||||
|
type: str
|
||||||
|
html_url: str
|
||||||
|
avatar_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_contributors() -> list[Contributor]:
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": "nanobot-readme",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
}
|
||||||
|
if token := os.environ.get("GITHUB_TOKEN"):
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
contributors: list[Contributor] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
url = f"https://api.github.com/repos/{REPOSITORY}/contributors?per_page={PER_PAGE}&page={page}"
|
||||||
|
with urlopen(Request(url, headers=headers), timeout=30) as response: # noqa: S310
|
||||||
|
batch = cast(list[Contributor], json.load(response))
|
||||||
|
contributors.extend(batch)
|
||||||
|
if len(batch) < PER_PAGE:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return [
|
||||||
|
contributor
|
||||||
|
for contributor in contributors
|
||||||
|
if contributor.get("login")
|
||||||
|
and contributor.get("type") != "Bot"
|
||||||
|
and not contributor["login"].lower().endswith("[bot]")
|
||||||
|
and contributor["login"].lower() not in MAINTAINERS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_wall(contributors: list[Contributor]) -> str:
|
||||||
|
avatars = [
|
||||||
|
(
|
||||||
|
f'<a href="{contributor["html_url"]}">'
|
||||||
|
f'<img src="{contributor["avatar_url"]}&s=48" '
|
||||||
|
f'width="48" height="48" alt="{contributor["login"]}"></a>'
|
||||||
|
)
|
||||||
|
for contributor in contributors
|
||||||
|
]
|
||||||
|
wall = "\n".join(avatars)
|
||||||
|
return f"{START}\n<p>\n{wall}\n</p>\n{END}"
|
||||||
|
|
||||||
|
|
||||||
|
def update_readme(*, check: bool) -> bool:
|
||||||
|
current = README.read_text()
|
||||||
|
before, separator, tail = current.partition(START)
|
||||||
|
if not separator or END not in tail:
|
||||||
|
raise SystemExit("README contributor markers are missing")
|
||||||
|
|
||||||
|
_, _, after = tail.partition(END)
|
||||||
|
updated = f"{before}{render_wall(fetch_contributors())}{after}"
|
||||||
|
if updated == current:
|
||||||
|
return False
|
||||||
|
if check:
|
||||||
|
raise SystemExit("README contributor wall is out of date")
|
||||||
|
README.write_text(updated)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--check", action="store_true", help="fail when README.md is out of date")
|
||||||
|
args = parser.parse_args()
|
||||||
|
print("Updated README.md" if update_readme(check=args.check) else "README.md is current")
|
||||||
@@ -36,6 +36,7 @@ from nanobot.session.keys import (
|
|||||||
UNIFIED_SESSION_KEY,
|
UNIFIED_SESSION_KEY,
|
||||||
)
|
)
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
|
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY, PENDING_FOLLOWUPS_KEY
|
||||||
from nanobot.session.turn_continuation import (
|
from nanobot.session.turn_continuation import (
|
||||||
INTERNAL_CONTINUATION_META,
|
INTERNAL_CONTINUATION_META,
|
||||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||||
@@ -161,6 +162,35 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
|
|||||||
assert message["cron_prompt_ref"] == prompt_ref
|
assert message["cron_prompt_ref"] == prompt_ref
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_user_message_acknowledges_durable_followup(tmp_path: Path) -> None:
|
||||||
|
loop = _make_full_loop(tmp_path)
|
||||||
|
session = loop.sessions.get_or_create("websocket:chat")
|
||||||
|
session.metadata[PENDING_FOLLOWUPS_KEY] = [
|
||||||
|
{
|
||||||
|
"id": "followup-1",
|
||||||
|
"sender_id": "user",
|
||||||
|
"chat_id": "chat",
|
||||||
|
"content": "queued while busy",
|
||||||
|
"media": [],
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
persisted = loop._persist_user_message_early(
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="chat",
|
||||||
|
content="queued while busy",
|
||||||
|
metadata={PENDING_FOLLOWUP_ID_KEY: "followup-1"},
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert persisted is True
|
||||||
|
assert PENDING_FOLLOWUPS_KEY not in session.metadata
|
||||||
|
|
||||||
|
|
||||||
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
|
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
session = loop.sessions.get_or_create("websocket:auto")
|
session = loop.sessions.get_or_create("websocket:auto")
|
||||||
@@ -381,6 +411,34 @@ def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
|
|||||||
assert public_history_message(session.messages[0])["content"] == []
|
assert public_history_message(session.messages[0])["content"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_turn_acknowledges_every_merged_recovery_followup() -> None:
|
||||||
|
"""Persisting a merged injected row retires every durable follow-up ID."""
|
||||||
|
loop = _mk_loop()
|
||||||
|
session = Session(
|
||||||
|
key="test:recovery-followups",
|
||||||
|
metadata={
|
||||||
|
PENDING_FOLLOWUPS_KEY: [
|
||||||
|
{"id": "first"},
|
||||||
|
{"id": "second"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
loop._save_turn(
|
||||||
|
session,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "first\n\nsecond",
|
||||||
|
PENDING_FOLLOWUP_ID_KEY: ["first", "second"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
skip=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert PENDING_FOLLOWUPS_KEY not in session.metadata
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
|
def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:image")
|
session = Session(key="test:image")
|
||||||
|
|||||||
@@ -1070,6 +1070,8 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens():
|
|||||||
assert result.usage["prompt_tokens"] == 300 # 100 + 200
|
assert result.usage["prompt_tokens"] == 300 # 100 + 200
|
||||||
assert result.usage["completion_tokens"] == 30 # 10 + 20
|
assert result.usage["completion_tokens"] == 30 # 10 + 20
|
||||||
assert result.usage["cached_tokens"] == 230 # 80 + 150
|
assert result.usage["cached_tokens"] == 230 # 80 + 150
|
||||||
|
assert result.usage["context_tokens"] == 200
|
||||||
|
assert result.usage["request_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -584,9 +584,10 @@ class TestFallbackOnPrimaryError:
|
|||||||
assert restored.payload == state.payload
|
assert restored.payload == state.payload
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
async def test_reports_only_the_successful_fallback_model(self) -> None:
|
||||||
primary = _FakeProvider("primary", _error_response())
|
primary = _FakeProvider("primary", _error_response())
|
||||||
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
failed_fallback = _FakeProvider("failed", _error_response("backup overloaded"))
|
||||||
|
successful_fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||||
fallback_models: list[str] = []
|
fallback_models: list[str] = []
|
||||||
|
|
||||||
async def _observe(model: str) -> None:
|
async def _observe(model: str) -> None:
|
||||||
@@ -594,8 +595,11 @@ class TestFallbackOnPrimaryError:
|
|||||||
|
|
||||||
fb = FallbackProvider(
|
fb = FallbackProvider(
|
||||||
primary=primary,
|
primary=primary,
|
||||||
fallback_presets=[_fallback("fallback-a", provider="backup")],
|
fallback_presets=[
|
||||||
provider_factory=MagicMock(return_value=fallback),
|
_fallback("fallback-a", provider="backup"),
|
||||||
|
_fallback("fallback-b", provider="backup"),
|
||||||
|
],
|
||||||
|
provider_factory=MagicMock(side_effect=[failed_fallback, successful_fallback]),
|
||||||
fallback_model_observer=_observe,
|
fallback_model_observer=_observe,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -605,7 +609,7 @@ class TestFallbackOnPrimaryError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.content == "fallback ok"
|
assert result.content == "fallback ok"
|
||||||
assert fallback_models == ["fallback-a"]
|
assert fallback_models == ["fallback-b"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_logs_primary_error_before_fallback(self) -> None:
|
async def test_logs_primary_error_before_fallback(self) -> None:
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ async def test_runner_calls_run_level_hooks_on_success():
|
|||||||
"completion_tokens": 2,
|
"completion_tokens": 2,
|
||||||
"total_tokens": 5,
|
"total_tokens": 5,
|
||||||
"provider_tokens": 5,
|
"provider_tokens": 5,
|
||||||
|
"request_count": 1,
|
||||||
|
"context_tokens": 3,
|
||||||
},
|
},
|
||||||
["user", "assistant"],
|
["user", "assistant"],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def _make_injection_callback(queue: asyncio.Queue):
|
|||||||
return inject_cb
|
return inject_cb
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(tmp_path):
|
def _make_loop(tmp_path, *, recovery_admission=None):
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
@@ -39,7 +39,12 @@ def _make_loop(tmp_path):
|
|||||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
||||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||||
mock_sub_mgr.return_value.close = AsyncMock()
|
mock_sub_mgr.return_value.close = AsyncMock()
|
||||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
loop = AgentLoop(
|
||||||
|
bus=bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
recovery_admission=recovery_admission,
|
||||||
|
)
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -759,6 +764,20 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_merge_keeps_all_recovery_followup_ids() -> None:
|
||||||
|
"""Merged follow-ups stay acknowledged together after a later save."""
|
||||||
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
|
||||||
|
|
||||||
|
messages = [{"role": "user", "content": "first", PENDING_FOLLOWUP_ID_KEY: "one"}]
|
||||||
|
AgentRunner._append_injected_messages(
|
||||||
|
messages,
|
||||||
|
[{"role": "user", "content": "second", PENDING_FOLLOWUP_ID_KEY: "two"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert messages[-1][PENDING_FOLLOWUP_ID_KEY] == ["one", "two"]
|
||||||
|
|
||||||
|
|
||||||
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
|
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
@@ -967,6 +986,71 @@ async def test_followup_routed_to_pending_queue(tmp_path):
|
|||||||
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_websocket_followup_is_admitted_before_recovery_queue(tmp_path):
|
||||||
|
"""Recovery admission runs before a newer WebUI message is injected."""
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
admission = MagicMock()
|
||||||
|
admission.admit = AsyncMock(return_value=True)
|
||||||
|
loop = _make_loop(tmp_path, recovery_admission=admission)
|
||||||
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
session_key = "websocket:chat"
|
||||||
|
pending = asyncio.Queue(maxsize=20)
|
||||||
|
loop._pending_queues[session_key] = pending
|
||||||
|
|
||||||
|
run_task = asyncio.create_task(loop.run())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="u",
|
||||||
|
chat_id="chat",
|
||||||
|
content="new request",
|
||||||
|
)
|
||||||
|
await loop.bus.publish_inbound(msg)
|
||||||
|
|
||||||
|
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
||||||
|
admission.admit.assert_awaited_once_with(msg)
|
||||||
|
assert queued_msg.content == msg.content
|
||||||
|
assert queued_msg.metadata["_recovery_followup_id"]
|
||||||
|
|
||||||
|
loop.stop()
|
||||||
|
await asyncio.wait_for(run_task, timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unified_websocket_followup_admits_effective_session(tmp_path):
|
||||||
|
"""Recovery admission and the pending queue must use the same session key."""
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||||
|
|
||||||
|
admission = MagicMock()
|
||||||
|
admission.admit = AsyncMock(return_value=True)
|
||||||
|
loop = _make_loop(tmp_path, recovery_admission=admission)
|
||||||
|
loop._unified_session = True
|
||||||
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
pending = asyncio.Queue(maxsize=20)
|
||||||
|
loop._pending_queues[UNIFIED_SESSION_KEY] = pending
|
||||||
|
|
||||||
|
run_task = asyncio.create_task(loop.run())
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="u",
|
||||||
|
chat_id="chat",
|
||||||
|
content="new request",
|
||||||
|
)
|
||||||
|
await loop.bus.publish_inbound(msg)
|
||||||
|
|
||||||
|
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
||||||
|
admitted_msg = admission.admit.await_args.args[0]
|
||||||
|
assert admitted_msg.session_key == UNIFIED_SESSION_KEY
|
||||||
|
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
||||||
|
|
||||||
|
loop.stop()
|
||||||
|
await asyncio.wait_for(run_task, timeout=2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path):
|
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path):
|
||||||
"""Injected results stay inside the active turn instead of opening a side turn."""
|
"""Injected results stay inside the active turn instead of opening a side turn."""
|
||||||
@@ -1314,6 +1398,51 @@ async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
|
|||||||
assert pending.qsize() == 1
|
assert pending.qsize() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_queue_overflow_keeps_websocket_followup_durable(tmp_path):
|
||||||
|
"""Fallback dispatch must not acknowledge a WebUI message before it commits."""
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.session.manager import Session
|
||||||
|
from nanobot.session.recovery import pending_followups
|
||||||
|
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
dispatched = asyncio.Event()
|
||||||
|
release_dispatch = asyncio.Event()
|
||||||
|
|
||||||
|
async def _dispatch(_msg):
|
||||||
|
dispatched.set()
|
||||||
|
await release_dispatch.wait()
|
||||||
|
|
||||||
|
loop._dispatch = AsyncMock(side_effect=_dispatch) # type: ignore[method-assign]
|
||||||
|
session = Session(key="websocket:c")
|
||||||
|
loop.sessions.get_or_create.return_value = session
|
||||||
|
pending = asyncio.Queue(maxsize=1)
|
||||||
|
pending.put_nowait(
|
||||||
|
InboundMessage(channel="websocket", sender_id="u", chat_id="c", content="already queued")
|
||||||
|
)
|
||||||
|
loop._pending_queues["websocket:c"] = pending
|
||||||
|
|
||||||
|
run_task = asyncio.create_task(loop.run())
|
||||||
|
await loop.bus.publish_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="u",
|
||||||
|
chat_id="c",
|
||||||
|
content="durable follow-up",
|
||||||
|
metadata={"webui": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(dispatched.wait(), timeout=2)
|
||||||
|
|
||||||
|
assert [message.content for message in pending_followups(session)] == ["durable follow-up"]
|
||||||
|
dispatched_msg = loop._dispatch.await_args.args[0]
|
||||||
|
assert dispatched_msg.metadata["_recovery_followup_id"]
|
||||||
|
|
||||||
|
release_dispatch.set()
|
||||||
|
loop.stop()
|
||||||
|
await asyncio.wait_for(run_task, timeout=2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
|
async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
|
||||||
"""Messages left in the pending queue after _dispatch are re-published to the bus.
|
"""Messages left in the pending queue after _dispatch are re-published to the bus.
|
||||||
|
|||||||
@@ -161,3 +161,25 @@ async def test_dispatch_cancellation_restores_checkpoint():
|
|||||||
"Checkpoint metadata should be cleared after restore"
|
"Checkpoint metadata should be cleared after restore"
|
||||||
assert loop.sessions.save.called, \
|
assert loop.sessions.save.called, \
|
||||||
"Session should be persisted so the restored state survives process restart"
|
"Session should be persisted so the restored state survives process restart"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_cancellation_keeps_checkpoint_for_gateway_shutdown(tmp_path: Path) -> None:
|
||||||
|
"""Gateway shutdown preserves the checkpoint; an explicit stop restores it."""
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
loop.preserve_inflight_turns_on_shutdown()
|
||||||
|
loop._restore_runtime_checkpoint = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
async def _cancel(*_args: object, **_kwargs: object) -> None:
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
|
loop._process_message = _cancel # type: ignore[method-assign]
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await loop._dispatch(
|
||||||
|
InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="work")
|
||||||
|
)
|
||||||
|
|
||||||
|
loop._restore_runtime_checkpoint.assert_not_called()
|
||||||
|
|||||||
@@ -3721,6 +3721,27 @@ async def test_notify_restart_done_waits_until_channel_starts():
|
|||||||
assert sent_msg.content.startswith("Restart completed")
|
assert sent_msg.content.startswith("Restart completed")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_websocket_restart_notice_does_not_overwrite_recovery_state():
|
||||||
|
"""WebSocket attach/recovery events already own reconnect state."""
|
||||||
|
fake_config = SimpleNamespace(
|
||||||
|
channels=ChannelsConfig(),
|
||||||
|
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||||
|
)
|
||||||
|
mgr = ChannelManager.__new__(ChannelManager)
|
||||||
|
mgr.config = fake_config
|
||||||
|
mgr.bus = MessageBus()
|
||||||
|
channel = _StartableChannel(fake_config, mgr.bus)
|
||||||
|
channel._running = True
|
||||||
|
mgr.channels = {"websocket": channel}
|
||||||
|
mgr._send_with_retry = AsyncMock()
|
||||||
|
|
||||||
|
notice = RestartNotice(channel="websocket", chat_id="chat", started_at_raw="100.0")
|
||||||
|
await mgr._send_restart_notice_when_started(notice)
|
||||||
|
|
||||||
|
mgr._send_with_retry.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_restart_notice_retries_until_running_channel_accepts_delivery():
|
async def test_restart_notice_retries_until_running_channel_accepts_delivery():
|
||||||
"""A running flag must not make an early transport failure final."""
|
"""A running flag must not make an early transport failure final."""
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ class _GatewayAgentContractStub:
|
|||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def preserve_inflight_turns_on_shutdown(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _EmptyGatewaySessionManager:
|
||||||
|
"""Minimal session-manager contract for gateway assembly tests."""
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[dict[str, object]]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def flush_all(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
|
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
|
||||||
class _FakeLoop:
|
class _FakeLoop:
|
||||||
@@ -2756,7 +2769,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||||
@@ -2823,7 +2836,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
||||||
cron_service=_StopCron,
|
cron_service=_StopCron,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3329,7 +3342,7 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
||||||
cron_service=_StopCron,
|
cron_service=_StopCron,
|
||||||
get_cron_dir=lambda: legacy_dir,
|
get_cron_dir=lambda: legacy_dir,
|
||||||
)
|
)
|
||||||
@@ -3368,7 +3381,7 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
||||||
cron_service=_StopCron,
|
cron_service=_StopCron,
|
||||||
get_cron_dir=lambda: legacy_dir,
|
get_cron_dir=lambda: legacy_dir,
|
||||||
)
|
)
|
||||||
@@ -3569,7 +3582,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
@@ -3702,6 +3715,9 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
seen["agent_closed"] = True
|
seen["agent_closed"] = True
|
||||||
|
|
||||||
|
def preserve_inflight_turns_on_shutdown(self) -> None:
|
||||||
|
seen["inflight_turns_preserved"] = True
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
seen["agent_stopped"] = True
|
seen["agent_stopped"] = True
|
||||||
|
|
||||||
@@ -3771,7 +3787,7 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
|
||||||
@@ -3783,6 +3799,7 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert seen["agent_stopped"] is True
|
assert seen["agent_stopped"] is True
|
||||||
|
assert seen["inflight_turns_preserved"] is True
|
||||||
assert seen["agent_closed"] is True
|
assert seen["agent_closed"] is True
|
||||||
assert seen["agent_task_cleaned_up"] is True
|
assert seen["agent_task_cleaned_up"] is True
|
||||||
assert seen["channels_stopped"] is True
|
assert seen["channels_stopped"] is True
|
||||||
@@ -3894,7 +3911,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from nanobot.cli.commands import app
|
||||||
|
from nanobot.cli.process_identity import named_executable, set_cli_process_identity
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("args", "expected"),
|
||||||
|
[
|
||||||
|
(["agent"], "nanobot-agent"),
|
||||||
|
(["gateway", "--background"], "nanobot-gateway"),
|
||||||
|
(["webui"], "nanobot-webui"),
|
||||||
|
(["status"], "nanobot"),
|
||||||
|
([], "nanobot"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cli_process_identity_uses_product_and_role(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
args: list[str],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
titles: list[str] = []
|
||||||
|
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "posix")
|
||||||
|
monkeypatch.setattr("nanobot.cli.process_identity._set_process_title", titles.append)
|
||||||
|
|
||||||
|
set_cli_process_identity(args)
|
||||||
|
|
||||||
|
assert titles == [expected]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_process_identity_keeps_windows_launcher_name(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
titles: list[str] = []
|
||||||
|
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt")
|
||||||
|
monkeypatch.setattr("nanobot.cli.process_identity._set_process_title", titles.append)
|
||||||
|
|
||||||
|
set_cli_process_identity(["agent"])
|
||||||
|
|
||||||
|
assert titles == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_console_entrypoint_still_sets_subcommand_identity(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
commands: list[list[str]] = []
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands.set_cli_process_identity", commands.append)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(app, ["webui", "--help"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert commands == [["webui"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_executable_creates_stable_role_symlink(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("POSIX symlink naming is not used on Windows")
|
||||||
|
executable = tmp_path / "bun"
|
||||||
|
executable.write_text("runtime", encoding="utf-8")
|
||||||
|
|
||||||
|
first = Path(
|
||||||
|
named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run")
|
||||||
|
)
|
||||||
|
second = Path(
|
||||||
|
named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert first.name == "nanobot-tui"
|
||||||
|
assert first.is_symlink()
|
||||||
|
assert first.resolve() == executable
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_executable_uses_original_on_windows(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
named_executable("bun.exe", name="nanobot-tui", directory=tmp_path / "run")
|
||||||
|
== "bun.exe"
|
||||||
|
)
|
||||||
@@ -467,9 +467,13 @@ def test_source_checkout_refreshes_locked_tui_dependencies(
|
|||||||
return subprocess.CompletedProcess(command, 0, "", "")
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
|
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.tui_launcher.named_executable",
|
||||||
|
lambda executable, **_kwargs: f"{executable}-named",
|
||||||
|
)
|
||||||
|
|
||||||
assert _resolve_source_tui_command(source_dir, bun) == [
|
assert _resolve_source_tui_command(source_dir, bun) == [
|
||||||
bun,
|
f"{bun}-named",
|
||||||
str(source_dir / "src" / "index.ts"),
|
str(source_dir / "src" / "index.ts"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from nanobot.gateway import (
|
|||||||
GatewayRuntimePaths,
|
GatewayRuntimePaths,
|
||||||
GatewayStartOptions,
|
GatewayStartOptions,
|
||||||
GatewayStatus,
|
GatewayStatus,
|
||||||
|
RuntimeResult,
|
||||||
)
|
)
|
||||||
from nanobot.gateway.runtime import monitor_gateway_clients
|
from nanobot.gateway.runtime import monitor_gateway_clients
|
||||||
from nanobot.process_runtime import process_is_running
|
from nanobot.process_runtime import process_is_running
|
||||||
@@ -454,6 +455,33 @@ def test_restart_does_not_detach_a_foreground_gateway(tmp_path, monkeypatch):
|
|||||||
assert result.message == "gateway_foreground_restart_required"
|
assert result.message == "gateway_foreground_restart_required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_stops_then_starts_the_background_gateway(tmp_path, monkeypatch):
|
||||||
|
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||||
|
status = GatewayStatus(
|
||||||
|
running=True,
|
||||||
|
pid=12345,
|
||||||
|
state_path=runtime.paths.state_path,
|
||||||
|
log_path=runtime.paths.log_path,
|
||||||
|
launch_mode="background",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(runtime, "status", lambda **_kwargs: status)
|
||||||
|
|
||||||
|
def stop(*, timeout_s: int):
|
||||||
|
assert timeout_s == 20
|
||||||
|
return SimpleNamespace(ok=True, message="gateway_stopped", status=status)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime, "_stop", stop)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
runtime,
|
||||||
|
"_start_background",
|
||||||
|
lambda _options: RuntimeResult(True, "gateway_started_background", status),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runtime.restart(GatewayStartOptions(port=18790))
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
|
||||||
|
|
||||||
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
|
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
|
||||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||||
|
|||||||
@@ -1056,8 +1056,24 @@ class TestConsumeSse:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_summary_delta_extracted(self):
|
async def test_reasoning_summary_delta_extracted(self):
|
||||||
response = _SseResponse([
|
response = _SseResponse([
|
||||||
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
|
{
|
||||||
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
|
"type": "response.reasoning_summary_text.delta",
|
||||||
|
"item_id": "rs_1",
|
||||||
|
"summary_index": 0,
|
||||||
|
"delta": "thinking ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.reasoning_summary_text.delta",
|
||||||
|
"item_id": "rs_1",
|
||||||
|
"summary_index": 0,
|
||||||
|
"delta": "briefly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.reasoning_summary_text.delta",
|
||||||
|
"item_id": "rs_1",
|
||||||
|
"summary_index": 1,
|
||||||
|
"delta": "Checking result",
|
||||||
|
},
|
||||||
{"type": "response.output_text.delta", "delta": "answer"},
|
{"type": "response.output_text.delta", "delta": "answer"},
|
||||||
{"type": "response.completed", "response": {"status": "completed"}},
|
{"type": "response.completed", "response": {"status": "completed"}},
|
||||||
])
|
])
|
||||||
@@ -1075,8 +1091,8 @@ class TestConsumeSse:
|
|||||||
assert tool_calls == []
|
assert tool_calls == []
|
||||||
assert finish_reason == "stop"
|
assert finish_reason == "stop"
|
||||||
assert usage == {}
|
assert usage == {}
|
||||||
assert reasoning == "thinking briefly"
|
assert reasoning == "thinking briefly\nChecking result"
|
||||||
assert deltas == ["thinking ", "briefly"]
|
assert deltas == ["thinking ", "briefly", "\nChecking result"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_summary_from_completed_response(self):
|
async def test_reasoning_summary_from_completed_response(self):
|
||||||
@@ -1097,7 +1113,7 @@ class TestConsumeSse:
|
|||||||
|
|
||||||
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
|
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
|
||||||
|
|
||||||
assert reasoning == "cached summary"
|
assert reasoning == "cached\nsummary"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_capture_commits_exact_items_only_after_completed_event(self):
|
async def test_capture_commits_exact_items_only_after_completed_event(self):
|
||||||
|
|||||||
@@ -0,0 +1,773 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.outbound_events import RecoveryStateEvent, SessionUpdatedEvent
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
from nanobot.session.recovery import (
|
||||||
|
PENDING_FOLLOWUPS_KEY,
|
||||||
|
PENDING_USER_TURN_KEY,
|
||||||
|
RECOVERY_METADATA_KEY,
|
||||||
|
RUNTIME_CHECKPOINT_KEY,
|
||||||
|
RecoveryActionError,
|
||||||
|
RecoveryCoordinator,
|
||||||
|
acknowledge_pending_followups,
|
||||||
|
pending_followups,
|
||||||
|
record_pending_followup,
|
||||||
|
)
|
||||||
|
from nanobot.webui import session_list_index, transcript
|
||||||
|
|
||||||
|
|
||||||
|
def _persist(manager: SessionManager, session: Session) -> None:
|
||||||
|
session.metadata["webui"] = True
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinator(workspace: Path) -> tuple[RecoveryCoordinator, MessageBus, SessionManager]:
|
||||||
|
bus = MessageBus()
|
||||||
|
sessions = SessionManager(workspace)
|
||||||
|
return RecoveryCoordinator(sessions, bus), bus, sessions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restart_before_model_call_waits_for_confirmation(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "finish this"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert restored.metadata[PENDING_USER_TURN_KEY] is True
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "restart_requires_confirmation"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stale_incomplete_transcript_waits_for_confirmation(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
"""A materialized shutdown must not reappear as an endless Working state."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
_persist(sessions, session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.transcript.has_unfinished_transcript_tail",
|
||||||
|
lambda _key: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
||||||
|
assert state["status"] == "awaiting_user"
|
||||||
|
assert state["reason"] == "interrupted_without_checkpoint"
|
||||||
|
event = bus.outbound.get_nowait().event
|
||||||
|
assert isinstance(event, RecoveryStateEvent)
|
||||||
|
assert event.status == "awaiting_user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_materialized_interruption_can_continue_from_saved_context(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Older shutdowns may have cleared the checkpoint after saving partial history."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.extend(
|
||||||
|
[
|
||||||
|
{"role": "user", "content": "research this"},
|
||||||
|
{"role": "assistant", "content": "I will check."},
|
||||||
|
{"role": "tool", "tool_call_id": "search-1", "content": "saved result"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_persist(sessions, session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.transcript.has_unfinished_transcript_tail",
|
||||||
|
lambda _key: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
||||||
|
assert state["status"] == "awaiting_user"
|
||||||
|
assert state["reason"] == "interrupted_with_saved_context"
|
||||||
|
assert "can_continue" not in state
|
||||||
|
event = bus.outbound.get_nowait().event
|
||||||
|
assert isinstance(event, RecoveryStateEvent)
|
||||||
|
assert event.can_continue is None
|
||||||
|
|
||||||
|
await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation = bus.inbound.get_nowait()
|
||||||
|
assert continuation.session_key_override == "websocket:chat"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcript_only_interruption_is_discovered_without_materializing_completed_history(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir()
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
monkeypatch.setattr(transcript, "get_webui_dir", lambda: webui_dir)
|
||||||
|
unfinished_key = "websocket:unfinished"
|
||||||
|
completed_key = "websocket:completed"
|
||||||
|
(webui_dir / f"{SessionManager.safe_key(unfinished_key)}.jsonl").write_text(
|
||||||
|
'{"event":"user","chat_id":"unfinished","text":"keep going"}\n'
|
||||||
|
'{"event":"message","chat_id":"unfinished","kind":"progress","text":"Working"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(webui_dir / f"{SessionManager.safe_key(completed_key)}.jsonl").write_text(
|
||||||
|
'{"event":"user","chat_id":"completed","text":"done"}\n'
|
||||||
|
'{"event":"message","chat_id":"completed","text":"finished"}\n'
|
||||||
|
'{"event":"turn_end","chat_id":"completed"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
coordinator, bus, sessions = _coordinator(tmp_path / "workspace")
|
||||||
|
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
restored = sessions.get_or_create(unfinished_key)
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "interrupted_without_checkpoint"
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["can_continue"] is False
|
||||||
|
assert sessions.read_session_metadata(completed_key) is None
|
||||||
|
event = bus.outbound.get_nowait().event
|
||||||
|
assert isinstance(event, RecoveryStateEvent)
|
||||||
|
assert event.status == "awaiting_user"
|
||||||
|
assert event.can_continue is False
|
||||||
|
assert bus.outbound.get_nowait().event.scope == "thread"
|
||||||
|
assert bus.outbound.empty()
|
||||||
|
|
||||||
|
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
||||||
|
await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "unfinished", "recovery_id": event.recovery_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_loads_only_sessions_that_need_webui_recovery(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
for key in ("telegram:idle", "discord:pending", "websocket:idle"):
|
||||||
|
session = sessions.get_or_create(key)
|
||||||
|
if key == "discord:pending":
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
_persist(sessions, session)
|
||||||
|
pending = sessions.get_or_create("websocket:pending")
|
||||||
|
pending.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
_persist(sessions, pending)
|
||||||
|
|
||||||
|
coordinator, _, restarted = _coordinator(tmp_path)
|
||||||
|
loaded: list[str] = []
|
||||||
|
get_or_create = restarted.get_or_create
|
||||||
|
|
||||||
|
def tracked_get_or_create(key: str) -> Session:
|
||||||
|
loaded.append(key)
|
||||||
|
return get_or_create(key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(restarted, "get_or_create", tracked_get_or_create)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.transcript.has_unfinished_transcript_tail",
|
||||||
|
lambda _key: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert loaded == ["websocket:pending"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_turn_followup_survives_restart_until_it_is_committed(tmp_path: Path) -> None:
|
||||||
|
"""A message injected mid-turn is not lost between checkpoints."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
_persist(sessions, session)
|
||||||
|
followup_id = record_pending_followup(
|
||||||
|
session,
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="chat",
|
||||||
|
content="also check the logs",
|
||||||
|
metadata={"webui": True},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert followup_id is not None
|
||||||
|
sessions.save(session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
queued = bus.inbound.get_nowait()
|
||||||
|
assert queued.content == "also check the logs"
|
||||||
|
assert queued.metadata["_recovery_followup_id"] == followup_id
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert len(pending_followups(restored)) == 1
|
||||||
|
|
||||||
|
acknowledge_pending_followups(restored, [followup_id])
|
||||||
|
assert PENDING_FOLLOWUPS_KEY not in restored.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_followup_journal_keeps_every_uncommitted_message(tmp_path: Path) -> None:
|
||||||
|
"""A live queue limit must never truncate durable WebUI follow-ups."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
followup_ids = [
|
||||||
|
record_pending_followup(
|
||||||
|
session,
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="chat",
|
||||||
|
content=f"follow-up-{index}",
|
||||||
|
metadata={"webui": True},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for index in range(21)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert all(followup_ids)
|
||||||
|
sessions.save(session)
|
||||||
|
restarted = SessionManager(tmp_path)
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert [message.content for message in pending_followups(restored)] == [
|
||||||
|
f"follow-up-{index}" for index in range(21)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_requeued_followup_preserves_its_journal_id(tmp_path: Path) -> None:
|
||||||
|
"""Routing a recovered follow-up into a live turn must remain idempotent."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
original_id = record_pending_followup(
|
||||||
|
session,
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="chat",
|
||||||
|
content="also check the logs",
|
||||||
|
metadata={"webui": True},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert original_id is not None
|
||||||
|
|
||||||
|
recovered = pending_followups(session)[0]
|
||||||
|
assert record_pending_followup(session, recovered) == original_id
|
||||||
|
assert [record["id"] for record in session.metadata[PENDING_FOLLOWUPS_KEY]] == [
|
||||||
|
original_id
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_completed_tools_wait_for_confirmation_after_restart(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "inspect"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "tools_completed",
|
||||||
|
"assistant_message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{"id": "call-1", "function": {"name": "read_file"}}],
|
||||||
|
},
|
||||||
|
"completed_tool_results": [
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call-1",
|
||||||
|
"name": "read_file",
|
||||||
|
"content": "saved result",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert restored.messages[-1]["content"] == "saved result"
|
||||||
|
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uncertain_tool_is_never_replayed(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "send it"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "awaiting_tools",
|
||||||
|
"assistant_message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{"id": "call-1", "function": {"name": "send_email"}}],
|
||||||
|
},
|
||||||
|
"completed_tool_results": [],
|
||||||
|
"pending_tool_calls": [
|
||||||
|
{"id": "call-1", "function": {"name": "send_email"}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "tool_state_unknown"
|
||||||
|
assert restored.messages[-1]["_recovery_interrupted"] is True
|
||||||
|
event = bus.outbound.get_nowait().event
|
||||||
|
assert isinstance(event, RecoveryStateEvent)
|
||||||
|
assert event.status == "awaiting_user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_checkpoint_waits_for_confirmation(tmp_path: Path) -> None:
|
||||||
|
"""Malformed or newer checkpoint phases fail closed pending confirmation."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "deploy it"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {"phase": "future_phase"}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "checkpoint_unknown"
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["can_continue"] is False
|
||||||
|
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
||||||
|
assert PENDING_USER_TURN_KEY not in restored.metadata
|
||||||
|
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
|
||||||
|
assert restored.messages[-1]["_recovery_interrupted"] is True
|
||||||
|
|
||||||
|
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
||||||
|
await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{
|
||||||
|
"chat_id": "chat",
|
||||||
|
"recovery_id": restored.metadata[RECOVERY_METADATA_KEY]["recovery_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
dismissed = await coordinator.handle_action(
|
||||||
|
"dismiss",
|
||||||
|
{
|
||||||
|
"chat_id": "chat",
|
||||||
|
"recovery_id": restored.metadata[RECOVERY_METADATA_KEY]["recovery_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert dismissed["status"] == "recovered"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_malformed_checkpoint_can_always_be_dismissed(tmp_path: Path) -> None:
|
||||||
|
"""Corrupt private state must not trap the user in a failed recovery notice."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "deploy it"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "future_phase",
|
||||||
|
"assistant_message": "invalid",
|
||||||
|
"completed_tool_results": 3,
|
||||||
|
"pending_tool_calls": [{"id": "call-1", "function": "invalid"}],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, _, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
state = restored.metadata[RECOVERY_METADATA_KEY]
|
||||||
|
|
||||||
|
result = await coordinator.handle_action(
|
||||||
|
"dismiss",
|
||||||
|
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "recovered"
|
||||||
|
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_known_but_malformed_checkpoint_cannot_continue(tmp_path: Path) -> None:
|
||||||
|
"""A known phase does not make corrupt tool state safe to resume."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "send it"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "tools_completed",
|
||||||
|
"assistant_message": {"role": "assistant", "content": "working"},
|
||||||
|
"completed_tool_results": "missing durable results",
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, _, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
state = restored.metadata[RECOVERY_METADATA_KEY]
|
||||||
|
|
||||||
|
assert state["status"] == "awaiting_user"
|
||||||
|
assert state["reason"] == "checkpoint_invalid"
|
||||||
|
assert state["can_continue"] is False
|
||||||
|
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
||||||
|
await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_malformed_final_response_is_not_reported_as_restored(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "answer"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "final_response",
|
||||||
|
"assistant_message": "not an answer row",
|
||||||
|
"completed_tool_results": [],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, _, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
||||||
|
assert state["status"] == "awaiting_user"
|
||||||
|
assert state["reason"] == "checkpoint_invalid"
|
||||||
|
assert state["can_continue"] is False
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
||||||
|
assert PENDING_USER_TURN_KEY not in restored.metadata
|
||||||
|
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
|
||||||
|
assert restored.messages[-1]["_recovery_interrupted"] is True
|
||||||
|
assert all("tool_calls" not in message for message in restored.messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_checkpoint_with_missing_tool_result_cannot_continue(tmp_path: Path) -> None:
|
||||||
|
"""Never resume when persisted results do not cover every requested tool."""
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "send both"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "tools_completed",
|
||||||
|
"assistant_message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": "call-1", "function": {"name": "send_email"}},
|
||||||
|
{"id": "call-2", "function": {"name": "send_email"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"completed_tool_results": [
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call-1",
|
||||||
|
"name": "send_email",
|
||||||
|
"content": "sent",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, _, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
state = restored.metadata[RECOVERY_METADATA_KEY]
|
||||||
|
assert state["status"] == "awaiting_user"
|
||||||
|
assert state["reason"] == "checkpoint_invalid"
|
||||||
|
assert state["can_continue"] is False
|
||||||
|
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
||||||
|
assert PENDING_USER_TURN_KEY not in restored.metadata
|
||||||
|
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
|
||||||
|
assert restored.messages[-1]["_recovery_interrupted"] is True
|
||||||
|
assert all("tool_calls" not in message for message in restored.messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_final_response_is_not_reported_as_restored(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "answer"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "final_response",
|
||||||
|
"assistant_message": {"role": "assistant", "content": ""},
|
||||||
|
"completed_tool_results": [],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, _, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
||||||
|
assert state["status"] == "awaiting_user"
|
||||||
|
assert state["reason"] == "checkpoint_invalid"
|
||||||
|
assert state["can_continue"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_final_answer_is_restored_without_model_call(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "answer"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "final_response",
|
||||||
|
"assistant_message": {"role": "assistant", "content": "already finished"},
|
||||||
|
"completed_tool_results": [],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
restored = restarted.get_or_create("websocket:chat")
|
||||||
|
assert restored.messages[-1]["content"] == "already finished"
|
||||||
|
first = bus.outbound.get_nowait().event
|
||||||
|
second = bus.outbound.get_nowait().event
|
||||||
|
assert isinstance(first, RecoveryStateEvent) and first.status == "recovered"
|
||||||
|
assert isinstance(second, SessionUpdatedEvent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_explicit_recovery_continue_queues_once(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "continue"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
|
||||||
|
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
||||||
|
result = await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
||||||
|
)
|
||||||
|
assert result["status"] == "resuming"
|
||||||
|
assert bus.inbound.get_nowait().metadata["_webui_recovery_id"] == state["recovery_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_user_message_supersedes_waiting_recovery(tmp_path: Path) -> None:
|
||||||
|
coordinator, bus, sessions = _coordinator(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.append({"role": "user", "content": "old request"})
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
_persist(sessions, session)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
newer = InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="chat",
|
||||||
|
content="new request",
|
||||||
|
)
|
||||||
|
assert await coordinator.admit(newer) is True
|
||||||
|
restored = sessions.get_or_create("websocket:chat")
|
||||||
|
assert restored.messages[-1]["_recovery_interrupted"] is True
|
||||||
|
assert sum(
|
||||||
|
message.get("_recovery_interrupted") is True
|
||||||
|
for message in restored.messages
|
||||||
|
) == 1
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "superseded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_user_message_cancels_active_recovery_task(tmp_path: Path) -> None:
|
||||||
|
coordinator, bus, sessions = _coordinator(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.metadata[RECOVERY_METADATA_KEY] = {
|
||||||
|
"status": "resuming",
|
||||||
|
"recovery_id": "active",
|
||||||
|
"attempts": 1,
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def _active_recovery() -> None:
|
||||||
|
started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
task = asyncio.create_task(_active_recovery())
|
||||||
|
await started.wait()
|
||||||
|
coordinator.register_recovery_task("websocket:chat", task)
|
||||||
|
|
||||||
|
newer = InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="chat",
|
||||||
|
content="new request",
|
||||||
|
)
|
||||||
|
assert await coordinator.admit(newer) is True
|
||||||
|
assert task.cancelled()
|
||||||
|
restored = sessions.get_or_create("websocket:chat")
|
||||||
|
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "superseded"
|
||||||
|
assert restored.messages[-1]["_recovery_interrupted"] is True
|
||||||
|
first = bus.outbound.get_nowait().event
|
||||||
|
second = bus.outbound.get_nowait().event
|
||||||
|
assert isinstance(first, RecoveryStateEvent)
|
||||||
|
assert isinstance(second, SessionUpdatedEvent)
|
||||||
|
assert bus.outbound.empty()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recovery_action_rejects_stale_page_and_continues_current_state(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
coordinator, bus, sessions = _coordinator(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.metadata[RECOVERY_METADATA_KEY] = {
|
||||||
|
"status": "awaiting_user",
|
||||||
|
"recovery_id": "current",
|
||||||
|
"attempts": 0,
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
with pytest.raises(RecoveryActionError, match="stale"):
|
||||||
|
await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "chat", "recovery_id": "old"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "chat", "recovery_id": "current"},
|
||||||
|
)
|
||||||
|
assert result["status"] == "resuming"
|
||||||
|
assert bus.inbound.get_nowait().metadata["_webui_recovery_id"] == "current"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persisted_completion_wins_over_stale_resuming_marker(tmp_path: Path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.messages.extend(
|
||||||
|
[
|
||||||
|
{"role": "user", "content": "work"},
|
||||||
|
{"role": "assistant", "content": "done"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.metadata[RECOVERY_METADATA_KEY] = {
|
||||||
|
"status": "resuming",
|
||||||
|
"recovery_id": "recovery",
|
||||||
|
"attempts": 1,
|
||||||
|
"resume_message_count": 1,
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
coordinator, bus, restarted = _coordinator(tmp_path)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
||||||
|
assert state["status"] == "recovered"
|
||||||
|
assert state["reason"] == "committed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismiss_does_not_queue_work(tmp_path: Path) -> None:
|
||||||
|
coordinator, bus, sessions = _coordinator(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.metadata[RECOVERY_METADATA_KEY] = {
|
||||||
|
"status": "awaiting_user",
|
||||||
|
"recovery_id": "current",
|
||||||
|
"attempts": 0,
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
result = await coordinator.handle_action(
|
||||||
|
"dismiss",
|
||||||
|
{"chat_id": "chat", "recovery_id": "current"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "recovered"
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_failure_is_visible_instead_of_aborting_other_sessions(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
coordinator, bus, sessions = _coordinator(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
async def fail(*_args: object, **_kwargs: object) -> None:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(RecoveryCoordinator, "_recover_session", fail)
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
state = sessions.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
||||||
|
assert state["status"] == "failed"
|
||||||
|
assert state["can_continue"] is False
|
||||||
|
assert isinstance(bus.outbound.get_nowait().event, RecoveryStateEvent)
|
||||||
|
|
||||||
|
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
||||||
|
await coordinator.handle_action(
|
||||||
|
"continue",
|
||||||
|
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bus_remains_quiet_after_recovered_state(tmp_path: Path) -> None:
|
||||||
|
coordinator, bus, sessions = _coordinator(tmp_path)
|
||||||
|
session = sessions.get_or_create("websocket:chat")
|
||||||
|
session.metadata[RECOVERY_METADATA_KEY] = {
|
||||||
|
"status": "recovered",
|
||||||
|
"recovery_id": "done",
|
||||||
|
"attempts": 1,
|
||||||
|
}
|
||||||
|
_persist(sessions, session)
|
||||||
|
|
||||||
|
await coordinator.scan()
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert bus.inbound.empty()
|
||||||
|
assert bus.outbound.empty()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import nanobot.session as session_api
|
import nanobot.session as session_api
|
||||||
|
from nanobot.providers.base import ProviderConversationState
|
||||||
from nanobot.session import Session, SessionManager
|
from nanobot.session import Session, SessionManager
|
||||||
from nanobot.session.manager import SessionStore
|
from nanobot.session.manager import SessionStore
|
||||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||||
@@ -123,3 +124,93 @@ def test_manager_preserves_full_session_before_store_save(tmp_path) -> None:
|
|||||||
assert session.messages[0]["content"] == "0"
|
assert session.messages[0]["content"] == "0"
|
||||||
assert session.messages[-1]["content"] == "2000"
|
assert session.messages[-1]["content"] == "2000"
|
||||||
store.save.assert_called_once_with(session, fsync=False)
|
store.save.assert_called_once_with(session, fsync=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_checkpoint_does_not_rewrite_long_session(tmp_path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:long")
|
||||||
|
for index in range(256):
|
||||||
|
session.add_message("user", f"{index}:" + "x" * 4096)
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
main_path = manager._get_session_path(session.key)
|
||||||
|
main_before = main_path.read_bytes()
|
||||||
|
stat_before = main_path.stat()
|
||||||
|
session.metadata["runtime_checkpoint"] = {
|
||||||
|
"phase": "tools_completed",
|
||||||
|
"assistant_message": {"role": "assistant", "content": "working"},
|
||||||
|
"completed_tool_results": [],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
session.provider_state = ProviderConversationState(
|
||||||
|
kind="openai_responses",
|
||||||
|
provider="openai:test",
|
||||||
|
model="test-model",
|
||||||
|
version=1,
|
||||||
|
payload={"response_id": "private-response"},
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.save_runtime_checkpoint(session)
|
||||||
|
|
||||||
|
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
||||||
|
assert main_path.read_bytes() == main_before
|
||||||
|
assert main_path.stat().st_ino == stat_before.st_ino
|
||||||
|
assert main_path.stat().st_mtime_ns == stat_before.st_mtime_ns
|
||||||
|
assert checkpoint_path.stat().st_size < len(main_before) // 100
|
||||||
|
|
||||||
|
restored = SessionManager(tmp_path).get_or_create(session.key)
|
||||||
|
assert restored.metadata["runtime_checkpoint"]["phase"] == "tools_completed"
|
||||||
|
assert restored.provider_state is not None
|
||||||
|
assert restored.provider_state.payload == {"response_id": "private-response"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_session_supersedes_stale_checkpoint(tmp_path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:completed")
|
||||||
|
session.add_message("user", "question")
|
||||||
|
manager.save(session)
|
||||||
|
session.metadata["runtime_checkpoint"] = {"phase": "awaiting_tools"}
|
||||||
|
manager.save_runtime_checkpoint(session)
|
||||||
|
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
||||||
|
stale_checkpoint = checkpoint_path.read_bytes()
|
||||||
|
|
||||||
|
session.metadata.pop("runtime_checkpoint")
|
||||||
|
session.add_message("assistant", "answer")
|
||||||
|
manager.save(session)
|
||||||
|
assert not checkpoint_path.exists()
|
||||||
|
|
||||||
|
# Emulate a process dying after the main record was committed but before an
|
||||||
|
# obsolete sidecar could be removed. The base fingerprint keeps it stale.
|
||||||
|
checkpoint_path.write_bytes(stale_checkpoint)
|
||||||
|
restored = SessionManager(tmp_path).get_or_create(session.key)
|
||||||
|
assert "runtime_checkpoint" not in restored.metadata
|
||||||
|
assert restored.messages[-1]["content"] == "answer"
|
||||||
|
assert not checkpoint_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_session_removes_runtime_checkpoint(tmp_path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:delete")
|
||||||
|
session.add_message("user", "question")
|
||||||
|
manager.save(session)
|
||||||
|
session.metadata["runtime_checkpoint"] = {"phase": "awaiting_tools"}
|
||||||
|
manager.save_runtime_checkpoint(session)
|
||||||
|
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
||||||
|
assert checkpoint_path.exists()
|
||||||
|
|
||||||
|
assert manager.delete_session(session.key) is True
|
||||||
|
assert not checkpoint_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_runtime_checkpoint_is_discarded(tmp_path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:invalid-checkpoint")
|
||||||
|
session.add_message("user", "question")
|
||||||
|
manager.save(session)
|
||||||
|
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
||||||
|
checkpoint_path.write_text("{truncated", encoding="utf-8")
|
||||||
|
|
||||||
|
restored = SessionManager(tmp_path).get_or_create(session.key)
|
||||||
|
|
||||||
|
assert "runtime_checkpoint" not in restored.metadata
|
||||||
|
assert not checkpoint_path.exists()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import nanobot.webui.transcript as transcript_module
|
import nanobot.webui.transcript as transcript_module
|
||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.webui.transcript import (
|
from nanobot.webui.transcript import (
|
||||||
@@ -393,6 +395,55 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
|||||||
assert msgs[1]["latencyMs"] == 42
|
assert msgs[1]["latencyMs"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_canonical_completed_stream_records() -> None:
|
||||||
|
msgs = replay_transcript_to_ui_messages([
|
||||||
|
{"event": "user", "chat_id": "canonical", "text": "q"},
|
||||||
|
{"event": "reasoning_end", "chat_id": "canonical", "text": "think"},
|
||||||
|
{"event": "stream_end", "chat_id": "canonical", "text": "answer"},
|
||||||
|
{"event": "turn_end", "chat_id": "canonical", "latency_ms": 42},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert len(msgs) == 2
|
||||||
|
assert msgs[1]["content"] == "answer"
|
||||||
|
assert msgs[1]["reasoning"] == "think"
|
||||||
|
assert msgs[1]["latencyMs"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_turn_end_preserves_usage_semantics(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
key = "websocket:t-usage"
|
||||||
|
for event in (
|
||||||
|
{"event": "user", "chat_id": "t-usage", "text": "q"},
|
||||||
|
{"event": "message", "chat_id": "t-usage", "text": "a"},
|
||||||
|
{
|
||||||
|
"event": "turn_end",
|
||||||
|
"chat_id": "t-usage",
|
||||||
|
"latency_ms": 18_200,
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 12_400,
|
||||||
|
"completion_tokens": 823,
|
||||||
|
"cached_tokens": 9_672,
|
||||||
|
"context_tokens": 8_200,
|
||||||
|
"request_count": 3,
|
||||||
|
},
|
||||||
|
"context_window_tokens": 128_000,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
append_transcript_object(key, event)
|
||||||
|
|
||||||
|
messages = replay_transcript_to_ui_messages(read_transcript_lines(key))
|
||||||
|
|
||||||
|
assert messages[-1]["usage"] == {
|
||||||
|
"prompt_tokens": 12_400,
|
||||||
|
"completion_tokens": 823,
|
||||||
|
"cached_tokens": 9_672,
|
||||||
|
"context_tokens": 8_200,
|
||||||
|
"request_count": 3,
|
||||||
|
}
|
||||||
|
assert messages[-1]["contextWindowTokens"] == 128_000
|
||||||
|
assert messages[-1]["latencyMs"] == 18_200
|
||||||
|
|
||||||
|
|
||||||
def test_replay_uses_persisted_created_at_ms() -> None:
|
def test_replay_uses_persisted_created_at_ms() -> None:
|
||||||
msgs = replay_transcript_to_ui_messages(
|
msgs = replay_transcript_to_ui_messages(
|
||||||
[
|
[
|
||||||
@@ -505,6 +556,22 @@ def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatc
|
|||||||
assert out["completed_turn_ids"] == []
|
assert out["completed_turn_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_tail_check_reads_only_the_active_transcript(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
key = "websocket:recovery-tail"
|
||||||
|
active_path = transcript_module.webui_transcript_path(key)
|
||||||
|
reads: list[Path] = []
|
||||||
|
|
||||||
|
def read(path: Path) -> list[dict[str, object]]:
|
||||||
|
reads.append(path)
|
||||||
|
return [{"event": "message", "kind": "progress", "text": "running"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(transcript_module, "_read_transcript_file", read)
|
||||||
|
|
||||||
|
assert transcript_module.has_unfinished_transcript_tail(key) is True
|
||||||
|
assert reads == [active_path]
|
||||||
|
|
||||||
|
|
||||||
def test_thread_response_reports_active_registry_without_transcript(
|
def test_thread_response_reports_active_registry_without_transcript(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -1436,7 +1503,7 @@ def test_replay_keeps_every_file_from_one_apply_patch_call() -> None:
|
|||||||
assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
|
assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
|
||||||
|
|
||||||
|
|
||||||
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
def test_replay_keeps_interrupted_pre_tool_text_as_answer() -> None:
|
||||||
msgs = replay_transcript_to_ui_messages([
|
msgs = replay_transcript_to_ui_messages([
|
||||||
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
||||||
{"event": "stream_end", "chat_id": "t-stream"},
|
{"event": "stream_end", "chat_id": "t-stream"},
|
||||||
@@ -1455,8 +1522,10 @@ def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
|||||||
|
|
||||||
assert len(msgs) == 3
|
assert len(msgs) == 3
|
||||||
assert msgs[0]["role"] == "assistant"
|
assert msgs[0]["role"] == "assistant"
|
||||||
assert msgs[0]["content"] == ""
|
assert msgs[0]["content"] == "I will inspect first."
|
||||||
assert msgs[0]["reasoning"] == "I will inspect first."
|
assert msgs[0]["turnPhase"] == "answer"
|
||||||
|
assert "reasoning" not in msgs[0]
|
||||||
|
assert "activitySegmentId" not in msgs[0]
|
||||||
assert "isStreaming" not in msgs[0]
|
assert "isStreaming" not in msgs[0]
|
||||||
assert msgs[1]["kind"] == "trace"
|
assert msgs[1]["kind"] == "trace"
|
||||||
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
|
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.session.recovery import PENDING_USER_TURN_KEY, RUNTIME_CHECKPOINT_KEY
|
||||||
|
|
||||||
_BOOTSTRAP_SECRET = "smoke-secret"
|
_BOOTSTRAP_SECRET = "smoke-secret"
|
||||||
|
|
||||||
|
|
||||||
@@ -206,3 +209,84 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa
|
|||||||
assert any("shell-ok" in text for text in contents)
|
assert any("shell-ok" in text for text in contents)
|
||||||
finally:
|
finally:
|
||||||
_stop_gateway(process)
|
_stop_gateway(process)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_restart_restores_a_completed_answer_without_replaying_model(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Exercise recovery through two real gateway processes and durable files."""
|
||||||
|
ws_port = _free_port()
|
||||||
|
gateway_port = _free_port()
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
first_log = tmp_path / "gateway-first.log"
|
||||||
|
second_log = tmp_path / "gateway-second.log"
|
||||||
|
_write_smoke_config(
|
||||||
|
config_path,
|
||||||
|
workspace=workspace,
|
||||||
|
ws_port=ws_port,
|
||||||
|
gateway_port=gateway_port,
|
||||||
|
)
|
||||||
|
base_url = f"http://127.0.0.1:{ws_port}"
|
||||||
|
|
||||||
|
first = _start_gateway(config_path, first_log)
|
||||||
|
try:
|
||||||
|
_wait_for_bootstrap(base_url, first, first_log)
|
||||||
|
finally:
|
||||||
|
_stop_gateway(first)
|
||||||
|
|
||||||
|
sessions_root = tmp_path / "sessions"
|
||||||
|
sessions = SessionManager(workspace, sessions_root=sessions_root)
|
||||||
|
session = sessions.get_or_create("websocket:recovery-smoke")
|
||||||
|
session.messages.append({"role": "user", "content": "recover this answer"})
|
||||||
|
session.metadata["webui"] = True
|
||||||
|
session.metadata[PENDING_USER_TURN_KEY] = True
|
||||||
|
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
||||||
|
"phase": "final_response",
|
||||||
|
"assistant_message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "restored without another model request",
|
||||||
|
},
|
||||||
|
"completed_tool_results": [],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
}
|
||||||
|
sessions.save(session, fsync=True)
|
||||||
|
|
||||||
|
second = _start_gateway(config_path, second_log)
|
||||||
|
try:
|
||||||
|
bootstrap = _wait_for_bootstrap(base_url, second, second_log)
|
||||||
|
deadline = time.monotonic() + 20
|
||||||
|
restored = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
restored = SessionManager(
|
||||||
|
workspace,
|
||||||
|
sessions_root=sessions_root,
|
||||||
|
).get_or_create("websocket:recovery-smoke")
|
||||||
|
if any(
|
||||||
|
message.get("content") == "restored without another model request"
|
||||||
|
for message in restored.messages
|
||||||
|
):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
logs = second_log.read_text(encoding="utf-8", errors="replace")
|
||||||
|
raise AssertionError(f"answer was not recovered after restart\n{logs}")
|
||||||
|
|
||||||
|
assert restored is not None
|
||||||
|
assert PENDING_USER_TURN_KEY not in restored.metadata
|
||||||
|
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
||||||
|
assert restored.metadata["webui_recovery"]["reason"] == "answer_restored"
|
||||||
|
|
||||||
|
async def assert_attach_state() -> None:
|
||||||
|
ws_url = f'{bootstrap["ws_url"]}?token={bootstrap["token"]}&client_id=recovery-smoke'
|
||||||
|
async with websockets.connect(ws_url) as ws:
|
||||||
|
await _recv_until(ws, "ready")
|
||||||
|
await ws.send(json.dumps({"type": "attach", "chat_id": "recovery-smoke"}))
|
||||||
|
attached = await _recv_until(ws, "attached")
|
||||||
|
assert attached["recovery_state"]["status"] == "recovered"
|
||||||
|
assert attached["recovery_state"]["reason"] == "answer_restored"
|
||||||
|
|
||||||
|
asyncio.run(assert_attach_state())
|
||||||
|
finally:
|
||||||
|
_stop_gateway(second)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
|||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||||
|
from nanobot.session.recovery import RECOVERY_METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -66,6 +67,30 @@ def test_webui_session_list_refreshes_after_model_preset_rename(tmp_path: Path)
|
|||||||
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex"
|
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_surfaces_pending_recovery_state(tmp_path: Path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:needs-attention")
|
||||||
|
session.add_message("user", "the interrupted task")
|
||||||
|
session.metadata[RECOVERY_METADATA_KEY] = {
|
||||||
|
"status": "awaiting_user",
|
||||||
|
"recovery_id": "recovery-123",
|
||||||
|
"reason": "uncertain_tool_state",
|
||||||
|
"attempts": 1,
|
||||||
|
# Private checkpoint details must never leak into the sidebar index.
|
||||||
|
"checkpoint": {"tool_args": "secret"},
|
||||||
|
}
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
row = list_webui_sessions(manager)[0]
|
||||||
|
|
||||||
|
assert row["recovery_state"] == {
|
||||||
|
"status": "awaiting_user",
|
||||||
|
"recovery_id": "recovery-123",
|
||||||
|
"reason": "uncertain_tool_state",
|
||||||
|
"attempts": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
session = manager.get_or_create("websocket:unique-index-temp")
|
session = manager.get_or_create("websocket:unique-index-temp")
|
||||||
|
|||||||
+225
-1
@@ -7,7 +7,12 @@ import {
|
|||||||
} from "@opentui/core/testing"
|
} from "@opentui/core/testing"
|
||||||
|
|
||||||
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
|
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
|
||||||
import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
|
import type {
|
||||||
|
MessageOptions,
|
||||||
|
RecoveryState,
|
||||||
|
SlashCommand,
|
||||||
|
WorkspaceScopePayload,
|
||||||
|
} from "./protocol"
|
||||||
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
||||||
|
|
||||||
const options: AppOptions = {
|
const options: AppOptions = {
|
||||||
@@ -91,6 +96,13 @@ function client(
|
|||||||
setWorkspaceScope(scope: WorkspaceScopePayload) {
|
setWorkspaceScope(scope: WorkspaceScopePayload) {
|
||||||
scopes.push(scope)
|
scopes.push(scope)
|
||||||
},
|
},
|
||||||
|
updateRecovery(
|
||||||
|
_action: "continue" | "dismiss",
|
||||||
|
_chatId: string,
|
||||||
|
recoveryId: string,
|
||||||
|
): Promise<RecoveryState> {
|
||||||
|
return Promise.resolve({ status: "recovered" as const, recovery_id: recoveryId })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +567,97 @@ describe("NanobotTui layout", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("switches away from a running session without losing its queued follow-ups", async () => {
|
||||||
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
|
const original = globalThis.fetch
|
||||||
|
globalThis.fetch = ((input: string | URL | Request) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url.endsWith("/api/sessions")) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
sessions: [
|
||||||
|
{ key: "websocket:chat", title: "Running chat", run_started_at: 1_700_000_000 },
|
||||||
|
{ key: "websocket:other", title: "Other chat" },
|
||||||
|
],
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/webui/sidebar-state")) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({})))
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
messages: [],
|
||||||
|
page: { has_more_before: false },
|
||||||
|
})))
|
||||||
|
}) as typeof fetch
|
||||||
|
const sent: string[] = []
|
||||||
|
const attached: string[] = []
|
||||||
|
let activeChatId = "chat"
|
||||||
|
const base = client(sent, attached)
|
||||||
|
const transport = {
|
||||||
|
...base,
|
||||||
|
get activeChatId() { return activeChatId },
|
||||||
|
attach(chatId: string) {
|
||||||
|
attached.push(chatId)
|
||||||
|
activeChatId = chatId
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const app = NanobotTui.mount(
|
||||||
|
setup.renderer,
|
||||||
|
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
|
||||||
|
transport,
|
||||||
|
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||||
|
)
|
||||||
|
const ui = app as unknown as {
|
||||||
|
ready: boolean
|
||||||
|
activeTurn: boolean
|
||||||
|
composer: TextareaRenderable
|
||||||
|
sessionMenu: { visible: boolean }
|
||||||
|
queuePreview: { root: { visible: boolean } }
|
||||||
|
status: { plainText: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
|
await waitUntil(() => ui.ready)
|
||||||
|
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
|
||||||
|
ui.composer.setText("follow up in chat")
|
||||||
|
setup.mockInput.pressTab()
|
||||||
|
await waitUntil(() => ui.composer.plainText === "")
|
||||||
|
expect(ui.queuePreview.root.visible).toBe(true)
|
||||||
|
|
||||||
|
ui.composer.setText("/sessions")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => ui.sessionMenu.visible)
|
||||||
|
await Bun.sleep(120)
|
||||||
|
expect(ui.status.plainText).toContain("2 sessions")
|
||||||
|
|
||||||
|
ui.composer.setText("other")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => attached.at(-1) === "other")
|
||||||
|
app.accept({ event: "attached", chat_id: "other" })
|
||||||
|
await waitUntil(() => ui.ready)
|
||||||
|
expect(ui.activeTurn).toBe(false)
|
||||||
|
expect(ui.queuePreview.root.visible).toBe(false)
|
||||||
|
|
||||||
|
ui.composer.setText("/sessions")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => ui.sessionMenu.visible)
|
||||||
|
ui.composer.setText("running")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => attached.at(-1) === "chat")
|
||||||
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
|
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
|
||||||
|
await waitUntil(() => ui.ready && ui.activeTurn)
|
||||||
|
expect(ui.queuePreview.root.visible).toBe(true)
|
||||||
|
expect(sent).toEqual([])
|
||||||
|
|
||||||
|
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
|
||||||
|
await waitUntil(() => sent.length === 1)
|
||||||
|
expect(sent).toEqual(["follow up in chat"])
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("refreshes expired API credentials before opening sessions", async () => {
|
test("refreshes expired API credentials before opening sessions", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
@@ -903,6 +1006,127 @@ describe("NanobotTui layout", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("offers clickable recovery actions without letting a late response revive stale state", async () => {
|
||||||
|
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
|
||||||
|
const calls: Array<{ action: string; chatId: string; recoveryId: string }> = []
|
||||||
|
let deferredResolve: ((state: RecoveryState) => void) | undefined
|
||||||
|
const recoveryClient = client()
|
||||||
|
recoveryClient.updateRecovery = (action, chatId, recoveryId) => {
|
||||||
|
calls.push({ action, chatId, recoveryId })
|
||||||
|
if (recoveryId === "recovery-1") {
|
||||||
|
return Promise.resolve({ status: "resuming", recovery_id: recoveryId })
|
||||||
|
}
|
||||||
|
if (action === "dismiss") {
|
||||||
|
return Promise.resolve({ status: "recovered", recovery_id: recoveryId })
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => { deferredResolve = resolve })
|
||||||
|
}
|
||||||
|
const app = NanobotTui.mount(
|
||||||
|
setup.renderer,
|
||||||
|
options,
|
||||||
|
recoveryClient,
|
||||||
|
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||||
|
)
|
||||||
|
app.accept({
|
||||||
|
event: "attached",
|
||||||
|
chat_id: "chat",
|
||||||
|
recovery_state: {
|
||||||
|
status: "awaiting_user",
|
||||||
|
recovery_id: "recovery-1",
|
||||||
|
reason: "tool execution interrupted",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const ui = app as unknown as {
|
||||||
|
activeTurn: boolean
|
||||||
|
composer: TextareaRenderable
|
||||||
|
recoveryNotice: {
|
||||||
|
visible: boolean
|
||||||
|
dismiss: TextRenderable
|
||||||
|
resume: TextRenderable
|
||||||
|
}
|
||||||
|
status: TextRenderable
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("⚠ Task interrupted")
|
||||||
|
expect(setup.captureCharFrame()).toContain("Tools will not replay automatically")
|
||||||
|
expect(ui.status.plainText).toContain("continue or dismiss")
|
||||||
|
expect(ui.activeTurn).toBe(false)
|
||||||
|
expect(ui.composer.focused).toBe(true)
|
||||||
|
|
||||||
|
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
|
||||||
|
await waitUntil(() => calls.length === 1 && ui.activeTurn)
|
||||||
|
expect(calls[0]).toEqual({
|
||||||
|
action: "continue",
|
||||||
|
chatId: "chat",
|
||||||
|
recoveryId: "recovery-1",
|
||||||
|
})
|
||||||
|
expect(ui.recoveryNotice.visible).toBe(false)
|
||||||
|
expect(ui.status.plainText).toContain("Continuing")
|
||||||
|
|
||||||
|
app.accept({
|
||||||
|
event: "recovery_state",
|
||||||
|
chat_id: "chat",
|
||||||
|
status: "awaiting_user",
|
||||||
|
recovery_id: "recovery-2",
|
||||||
|
})
|
||||||
|
await setup.renderOnce()
|
||||||
|
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
|
||||||
|
await waitUntil(() => calls.length === 2)
|
||||||
|
app.accept({
|
||||||
|
event: "recovery_state",
|
||||||
|
chat_id: "chat",
|
||||||
|
status: "recovered",
|
||||||
|
recovery_id: "recovery-2",
|
||||||
|
})
|
||||||
|
deferredResolve?.({ status: "resuming", recovery_id: "recovery-2" })
|
||||||
|
await Bun.sleep(1)
|
||||||
|
|
||||||
|
expect(ui.recoveryNotice.visible).toBe(false)
|
||||||
|
expect(ui.activeTurn).toBe(false)
|
||||||
|
expect(ui.composer.focused).toBe(true)
|
||||||
|
|
||||||
|
app.accept({
|
||||||
|
event: "recovery_state",
|
||||||
|
chat_id: "chat",
|
||||||
|
status: "awaiting_user",
|
||||||
|
recovery_id: "recovery-unavailable",
|
||||||
|
can_continue: false,
|
||||||
|
})
|
||||||
|
await setup.renderOnce()
|
||||||
|
const unavailableFrame = setup.captureCharFrame()
|
||||||
|
expect(unavailableFrame).toContain("can’t be resumed safely")
|
||||||
|
expect(unavailableFrame).not.toContain("Continue")
|
||||||
|
expect(ui.status.plainText).toContain("dismiss to start a new message")
|
||||||
|
|
||||||
|
app.accept({
|
||||||
|
event: "recovery_state",
|
||||||
|
chat_id: "chat",
|
||||||
|
status: "awaiting_user",
|
||||||
|
recovery_id: "recovery-3",
|
||||||
|
})
|
||||||
|
await setup.renderOnce()
|
||||||
|
await setup.mockMouse.click(ui.recoveryNotice.dismiss.x + 1, ui.recoveryNotice.dismiss.y)
|
||||||
|
await waitUntil(() => calls.length === 3 && !ui.recoveryNotice.visible)
|
||||||
|
expect(calls[2]).toEqual({
|
||||||
|
action: "dismiss",
|
||||||
|
chatId: "chat",
|
||||||
|
recoveryId: "recovery-3",
|
||||||
|
})
|
||||||
|
|
||||||
|
app.accept({
|
||||||
|
event: "recovery_state",
|
||||||
|
chat_id: "chat",
|
||||||
|
status: "awaiting_user",
|
||||||
|
recovery_id: "recovery-4",
|
||||||
|
can_continue: false,
|
||||||
|
})
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(ui.recoveryNotice.resume.visible).toBe(false)
|
||||||
|
expect(ui.recoveryNotice.dismiss.visible).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
|
test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
|
|||||||
+220
-20
@@ -34,6 +34,7 @@ import {
|
|||||||
type InboundEvent,
|
type InboundEvent,
|
||||||
type MentionCandidate,
|
type MentionCandidate,
|
||||||
type MessageOptions,
|
type MessageOptions,
|
||||||
|
type RecoveryState,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
type TokenUsage,
|
type TokenUsage,
|
||||||
@@ -70,6 +71,7 @@ import {
|
|||||||
} from "./mention-menu"
|
} from "./mention-menu"
|
||||||
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
||||||
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
||||||
|
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
|
||||||
import { RuntimeControls } from "./runtime-controls"
|
import { RuntimeControls } from "./runtime-controls"
|
||||||
import {
|
import {
|
||||||
contextualFooterHints,
|
contextualFooterHints,
|
||||||
@@ -107,6 +109,11 @@ interface ChatClient {
|
|||||||
newChat(scope?: WorkspaceScopePayload): void
|
newChat(scope?: WorkspaceScopePayload): void
|
||||||
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
|
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
|
||||||
setWorkspaceScope(scope: WorkspaceScopePayload): void
|
setWorkspaceScope(scope: WorkspaceScopePayload): void
|
||||||
|
updateRecovery(
|
||||||
|
action: "continue" | "dismiss",
|
||||||
|
chatId: string,
|
||||||
|
recoveryId: string,
|
||||||
|
): Promise<RecoveryState>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Palette {
|
interface Palette {
|
||||||
@@ -118,6 +125,7 @@ interface Palette {
|
|||||||
accent: string
|
accent: string
|
||||||
link: string
|
link: string
|
||||||
success: string
|
success: string
|
||||||
|
warning: string
|
||||||
error: string
|
error: string
|
||||||
user: string
|
user: string
|
||||||
userBackground: string
|
userBackground: string
|
||||||
@@ -134,6 +142,7 @@ const DARK: Palette = {
|
|||||||
accent: "#EF8E30",
|
accent: "#EF8E30",
|
||||||
link: "#60A5FA",
|
link: "#60A5FA",
|
||||||
success: "#5CC489",
|
success: "#5CC489",
|
||||||
|
warning: "#F5C451",
|
||||||
error: "#F87171",
|
error: "#F87171",
|
||||||
user: "#EF8E30",
|
user: "#EF8E30",
|
||||||
// Codex-style turn anchor: 12% white over the reference dark background.
|
// Codex-style turn anchor: 12% white over the reference dark background.
|
||||||
@@ -151,6 +160,7 @@ const LIGHT: Palette = {
|
|||||||
accent: "#B94D0B",
|
accent: "#B94D0B",
|
||||||
link: "#1D4ED8",
|
link: "#1D4ED8",
|
||||||
success: "#166534",
|
success: "#166534",
|
||||||
|
warning: "#A16207",
|
||||||
error: "#B91C1C",
|
error: "#B91C1C",
|
||||||
user: "#B94D0B",
|
user: "#B94D0B",
|
||||||
// Codex-style turn anchor: 4% black over the reference light background.
|
// Codex-style turn anchor: 4% black over the reference light background.
|
||||||
@@ -164,6 +174,7 @@ const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…"
|
|||||||
const SHIMMER_PAUSE = 16
|
const SHIMMER_PAUSE = 16
|
||||||
const SHIMMER_BAND = 4
|
const SHIMMER_BAND = 4
|
||||||
const SHIMMER_INTERVAL_MS = 80
|
const SHIMMER_INTERVAL_MS = 80
|
||||||
|
const SESSION_REFRESH_INTERVAL_MS = 1_000
|
||||||
const LOCAL_COMMANDS: TuiCommand[] = [
|
const LOCAL_COMMANDS: TuiCommand[] = [
|
||||||
{
|
{
|
||||||
command: "/sessions",
|
command: "/sessions",
|
||||||
@@ -252,6 +263,8 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme {
|
|||||||
text: palette.text,
|
text: palette.text,
|
||||||
muted: palette.muted,
|
muted: palette.muted,
|
||||||
border: palette.border,
|
border: palette.border,
|
||||||
|
accent: palette.accent,
|
||||||
|
warning: palette.warning,
|
||||||
selectedBackground: palette.userBackground,
|
selectedBackground: palette.userBackground,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +308,17 @@ function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recoveryNoticeTheme(palette: Palette): RecoveryNoticeTheme {
|
||||||
|
return {
|
||||||
|
text: palette.text,
|
||||||
|
muted: palette.muted,
|
||||||
|
border: palette.border,
|
||||||
|
accent: palette.accent,
|
||||||
|
warning: palette.warning,
|
||||||
|
error: palette.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function footerHintTheme(palette: Palette): FooterHintTheme {
|
function footerHintTheme(palette: Palette): FooterHintTheme {
|
||||||
return {
|
return {
|
||||||
accent: palette.accent,
|
accent: palette.accent,
|
||||||
@@ -380,6 +404,7 @@ export class NanobotTui {
|
|||||||
private readonly contextPanel: ContextPanel
|
private readonly contextPanel: ContextPanel
|
||||||
private readonly diffViewer: DiffViewer
|
private readonly diffViewer: DiffViewer
|
||||||
private readonly queuePreview: QueuePreview
|
private readonly queuePreview: QueuePreview
|
||||||
|
private readonly recoveryNotice: RecoveryNotice
|
||||||
private readonly client: ChatClient
|
private readonly client: ChatClient
|
||||||
private readonly shell: BoxRenderable
|
private readonly shell: BoxRenderable
|
||||||
private readonly title: BoxRenderable
|
private readonly title: BoxRenderable
|
||||||
@@ -390,7 +415,8 @@ export class NanobotTui {
|
|||||||
private readonly meta: TextRenderable
|
private readonly meta: TextRenderable
|
||||||
private readonly host: TuiHost
|
private readonly host: TuiHost
|
||||||
private readonly draft = new ComposerDraft()
|
private readonly draft = new ComposerDraft()
|
||||||
private readonly promptQueue = new PromptQueue()
|
private readonly promptQueues = new Map<string, PromptQueue>()
|
||||||
|
private currentChatId = ""
|
||||||
private palette: Palette
|
private palette: Palette
|
||||||
private activeThemeMode: ThemeMode
|
private activeThemeMode: ThemeMode
|
||||||
private backgroundKnown: boolean
|
private backgroundKnown: boolean
|
||||||
@@ -436,6 +462,8 @@ export class NanobotTui {
|
|||||||
private quitting = false
|
private quitting = false
|
||||||
private sessionLoadId = 0
|
private sessionLoadId = 0
|
||||||
private sessionLoading = false
|
private sessionLoading = false
|
||||||
|
private sessionRefreshPending = false
|
||||||
|
private sessionRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
||||||
private readonly modelCommandTurns = new Set<string>()
|
private readonly modelCommandTurns = new Set<string>()
|
||||||
private readonly silentCommandTurns = new Set<string>()
|
private readonly silentCommandTurns = new Set<string>()
|
||||||
@@ -444,6 +472,8 @@ export class NanobotTui {
|
|||||||
private currentTask = ""
|
private currentTask = ""
|
||||||
private currentAction = ""
|
private currentAction = ""
|
||||||
private hostBlocked = false
|
private hostBlocked = false
|
||||||
|
private recoveryState: RecoveryState | null = null
|
||||||
|
private recoveryPending = false
|
||||||
private hostWorkspace: string
|
private hostWorkspace: string
|
||||||
private hostBranch: string
|
private hostBranch: string
|
||||||
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
||||||
@@ -495,6 +525,14 @@ export class NanobotTui {
|
|||||||
treeSitterClient,
|
treeSitterClient,
|
||||||
)
|
)
|
||||||
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
||||||
|
this.recoveryNotice = new RecoveryNotice(
|
||||||
|
renderer,
|
||||||
|
recoveryNoticeTheme(this.palette),
|
||||||
|
{
|
||||||
|
onContinue: () => void this.updateRecovery("continue"),
|
||||||
|
onDismiss: () => void this.updateRecovery("dismiss"),
|
||||||
|
},
|
||||||
|
)
|
||||||
this.client = client || new NanobotClient({
|
this.client = client || new NanobotClient({
|
||||||
...(options.bootstrapUrl
|
...(options.bootstrapUrl
|
||||||
? {
|
? {
|
||||||
@@ -723,6 +761,7 @@ export class NanobotTui {
|
|||||||
this.shell.add(this.runtimeControls.menuRoot)
|
this.shell.add(this.runtimeControls.menuRoot)
|
||||||
if (!host.hosted) this.shell.add(this.title)
|
if (!host.hosted) this.shell.add(this.title)
|
||||||
this.shell.add(this.queuePreview.root)
|
this.shell.add(this.queuePreview.root)
|
||||||
|
this.shell.add(this.recoveryNotice.root)
|
||||||
this.shell.add(this.composerFrame)
|
this.shell.add(this.composerFrame)
|
||||||
this.shell.add(statusRow)
|
this.shell.add(statusRow)
|
||||||
this.shell.add(this.diffViewer.root)
|
this.shell.add(this.diffViewer.root)
|
||||||
@@ -831,6 +870,12 @@ export class NanobotTui {
|
|||||||
this.quit()
|
this.quit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) {
|
||||||
|
this.clearComposer()
|
||||||
|
this.commandMenu.hide()
|
||||||
|
void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss")
|
||||||
|
return
|
||||||
|
}
|
||||||
const completion = this.commandMenu.completion(visibleContent)
|
const completion = this.commandMenu.completion(visibleContent)
|
||||||
if (completion) {
|
if (completion) {
|
||||||
this.setComposer(completion)
|
this.setComposer(completion)
|
||||||
@@ -925,6 +970,8 @@ export class NanobotTui {
|
|||||||
|
|
||||||
accept(event: InboundEvent): void {
|
accept(event: InboundEvent): void {
|
||||||
if (event.event === "attached") {
|
if (event.event === "attached") {
|
||||||
|
const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id)
|
||||||
|
this.currentChatId = event.chat_id
|
||||||
this.host.reportSession(event.chat_id)
|
this.host.reportSession(event.chat_id)
|
||||||
if (event.usage) this.lastUsage = event.usage
|
if (event.usage) this.lastUsage = event.usage
|
||||||
if (event.model_preset !== undefined) {
|
if (event.model_preset !== undefined) {
|
||||||
@@ -946,7 +993,11 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
const hydrationId = ++this.hydrationId
|
const hydrationId = ++this.hydrationId
|
||||||
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
|
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
|
||||||
if (hydrationId === this.hydrationId) this.flushPendingEvents()
|
if (hydrationId !== this.hydrationId) return
|
||||||
|
this.applyRecoveryState(event.recovery_state ?? null)
|
||||||
|
this.flushPendingEvents()
|
||||||
|
this.syncQueuePreview()
|
||||||
|
if (switchedSession) this.sendNextFollowUp()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1083,6 +1134,9 @@ export class NanobotTui {
|
|||||||
this.applyHostGoalState(event.goal_state)
|
this.applyHostGoalState(event.goal_state)
|
||||||
if (!this.activeTurn) this.reportHostResting()
|
if (!this.activeTurn) this.reportHostResting()
|
||||||
return
|
return
|
||||||
|
case "recovery_state":
|
||||||
|
this.applyRecoveryState(event)
|
||||||
|
return
|
||||||
case "turn_model_updated":
|
case "turn_model_updated":
|
||||||
if (typeof event.context_window_tokens === "number") {
|
if (typeof event.context_window_tokens === "number") {
|
||||||
this.contextWindowTokens = event.context_window_tokens
|
this.contextWindowTokens = event.context_window_tokens
|
||||||
@@ -1191,6 +1245,87 @@ export class NanobotTui {
|
|||||||
for (const event of events || []) this.accept(event)
|
for (const event of events || []) this.accept(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearRecoveryState(): void {
|
||||||
|
this.recoveryState = null
|
||||||
|
this.recoveryPending = false
|
||||||
|
this.recoveryNotice.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRecoveryState(state: RecoveryState | null): void {
|
||||||
|
if (!state) {
|
||||||
|
this.clearRecoveryState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.recoveryState = state
|
||||||
|
this.recoveryPending = false
|
||||||
|
if (state.status === "resuming") {
|
||||||
|
this.recoveryNotice.hide()
|
||||||
|
this.hostBlocked = false
|
||||||
|
this.activeLabel = "Continuing"
|
||||||
|
this.setCurrentAction("Continuing interrupted task")
|
||||||
|
this.setActive(true)
|
||||||
|
this.reportHostWorking()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.status === "awaiting_user" || state.status === "failed") {
|
||||||
|
this.activeTurnId = null
|
||||||
|
this.setActive(false)
|
||||||
|
this.hostBlocked = true
|
||||||
|
this.recoveryNotice.show(state)
|
||||||
|
const detail = state.reason || (state.status === "failed"
|
||||||
|
? "Recovery failed"
|
||||||
|
: "Task interrupted")
|
||||||
|
this.setCurrentAction(detail)
|
||||||
|
this.status.content = state.can_continue === false
|
||||||
|
? "Interrupted · dismiss to start a new message"
|
||||||
|
: "Interrupted · continue or dismiss"
|
||||||
|
this.host.reportState("blocked", detail)
|
||||||
|
this.composer.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.clearRecoveryState()
|
||||||
|
this.activeTurnId = null
|
||||||
|
this.hostBlocked = false
|
||||||
|
this.setActive(false)
|
||||||
|
if (this.ready) this.status.content = this.readyStatus()
|
||||||
|
this.reportHostResting()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
|
||||||
|
const state = this.recoveryState
|
||||||
|
if (
|
||||||
|
!state
|
||||||
|
|| (state.status !== "awaiting_user" && state.status !== "failed")
|
||||||
|
|| (action === "continue" && state.can_continue === false)
|
||||||
|
) {
|
||||||
|
this.status.content = "No interrupted task"
|
||||||
|
this.composer.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.recoveryPending) return
|
||||||
|
this.recoveryPending = true
|
||||||
|
this.recoveryNotice.setBusy(true)
|
||||||
|
this.status.content = action === "continue" ? "Continuing…" : "Dismissing…"
|
||||||
|
try {
|
||||||
|
const next = await this.client.updateRecovery(
|
||||||
|
action,
|
||||||
|
this.client.activeChatId,
|
||||||
|
state.recovery_id,
|
||||||
|
)
|
||||||
|
if (this.recoveryState?.recovery_id === state.recovery_id) {
|
||||||
|
this.applyRecoveryState(next)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.recoveryState?.recovery_id !== state.recovery_id) return
|
||||||
|
this.recoveryPending = false
|
||||||
|
this.recoveryNotice.setBusy(false)
|
||||||
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
|
this.host.reportState("blocked", state.reason || "Task interrupted")
|
||||||
|
} finally {
|
||||||
|
this.composer.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
|
private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
|
||||||
this.options.apiUrl = apiUrl
|
this.options.apiUrl = apiUrl
|
||||||
this.options.apiToken = apiToken
|
this.options.apiToken = apiToken
|
||||||
@@ -1282,6 +1417,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderActiveStatus(): void {
|
private renderActiveStatus(): void {
|
||||||
|
if (this.sessionLoading || this.sessionMenu.visible) return
|
||||||
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
||||||
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
||||||
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
|
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
|
||||||
@@ -1311,6 +1447,16 @@ export class NanobotTui {
|
|||||||
this.sendPrompt(prompt)
|
this.sendPrompt(prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get promptQueue(): PromptQueue {
|
||||||
|
const chatId = this.currentChatId || this.client.activeChatId
|
||||||
|
let queue = this.promptQueues.get(chatId)
|
||||||
|
if (!queue) {
|
||||||
|
queue = new PromptQueue()
|
||||||
|
this.promptQueues.set(chatId, queue)
|
||||||
|
}
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
private restoreQueuedPrompts(): void {
|
private restoreQueuedPrompts(): void {
|
||||||
const queued = this.promptQueue.restore()
|
const queued = this.promptQueue.restore()
|
||||||
if (!queued.length) return
|
if (!queued.length) return
|
||||||
@@ -1576,6 +1722,7 @@ export class NanobotTui {
|
|||||||
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||||
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
||||||
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
|
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
|
||||||
|
this.recoveryNotice.setTheme(recoveryNoticeTheme(this.palette))
|
||||||
this.updateComposerAppearance()
|
this.updateComposerAppearance()
|
||||||
this.composer.textColor = this.palette.text
|
this.composer.textColor = this.palette.text
|
||||||
this.composer.focusedTextColor = this.palette.text
|
this.composer.focusedTextColor = this.palette.text
|
||||||
@@ -1858,7 +2005,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private closeTransientMenus(): void {
|
private closeTransientMenus(): void {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
@@ -1912,7 +2059,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.status.content = "Loading branch points…"
|
this.status.content = "Loading branch points…"
|
||||||
@@ -1974,10 +2121,6 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async openSessions(): Promise<void> {
|
private async openSessions(): Promise<void> {
|
||||||
if (this.activeTurn) {
|
|
||||||
this.status.content = "Wait for the current turn or press Ctrl+C"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.dismissRuntimeControls()
|
this.dismissRuntimeControls()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
@@ -2001,10 +2144,17 @@ export class NanobotTui {
|
|||||||
this.sessionTitle = sessionLabel(current)
|
this.sessionTitle = sessionLabel(current)
|
||||||
this.applySessionModel(current)
|
this.applySessionModel(current)
|
||||||
this.applySessionScope(current)
|
this.applySessionScope(current)
|
||||||
|
this.applyRecoveryState(current.recoveryState ?? null)
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
}
|
}
|
||||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||||
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
|
this.sessionMenu.open(
|
||||||
|
sessions,
|
||||||
|
this.client.activeChatId,
|
||||||
|
limit,
|
||||||
|
this.defaultModelPreset,
|
||||||
|
)
|
||||||
|
this.startSessionRefresh()
|
||||||
this.renderTitleColor()
|
this.renderTitleColor()
|
||||||
this.sessionMenu.update(this.composer.plainText, limit)
|
this.sessionMenu.update(this.composer.plainText, limit)
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
@@ -2019,17 +2169,14 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private switchSession(session: SessionSummary): void {
|
private switchSession(session: SessionSummary): void {
|
||||||
if (this.activeTurn) {
|
this.sessionMenu.markRead(session.chatId)
|
||||||
this.status.content = "Wait for the current turn or press Ctrl+C"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (session.chatId === this.client.activeChatId) {
|
if (session.chatId === this.client.activeChatId) {
|
||||||
this.sessionTitle = sessionLabel(session)
|
this.sessionTitle = sessionLabel(session)
|
||||||
this.applySessionModel(session)
|
this.applySessionModel(session)
|
||||||
this.applySessionScope(session)
|
this.applySessionScope(session)
|
||||||
|
this.applyRecoveryState(session.recoveryState ?? null)
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
this.closeSessions()
|
this.closeSessions()
|
||||||
this.status.content = this.readyStatus()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
@@ -2039,7 +2186,10 @@ export class NanobotTui {
|
|||||||
this.closeSessions()
|
this.closeSessions()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.clearPromptQueue()
|
this.activeTurnId = null
|
||||||
|
this.setActive(false)
|
||||||
|
this.clearRecoveryState()
|
||||||
|
this.queuePreview.update([])
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.clearHostContext()
|
this.clearHostContext()
|
||||||
this.sessionTitle = sessionLabel(session)
|
this.sessionTitle = sessionLabel(session)
|
||||||
@@ -2066,13 +2216,14 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
|
this.clearRecoveryState()
|
||||||
this.clearPromptQueue()
|
this.clearPromptQueue()
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.clearHostContext()
|
this.clearHostContext()
|
||||||
@@ -2173,18 +2324,65 @@ export class NanobotTui {
|
|||||||
private closeSessions(): void {
|
private closeSessions(): void {
|
||||||
this.sessionLoadId += 1
|
this.sessionLoadId += 1
|
||||||
this.sessionLoading = false
|
this.sessionLoading = false
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.renderTitleColor()
|
this.renderTitleColor()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
this.composer.focus()
|
this.composer.focus()
|
||||||
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
|
if (this.activeTurn) this.renderActiveStatus()
|
||||||
|
else if (this.ready) this.status.content = this.readyStatus()
|
||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hideSessionMenu(): void {
|
||||||
|
this.stopSessionRefresh()
|
||||||
|
this.sessionMenu.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSessionRefresh(): void {
|
||||||
|
if (this.sessionRefreshTimer) return
|
||||||
|
this.sessionRefreshTimer = setInterval(() => {
|
||||||
|
if (!this.sessionMenu.visible) {
|
||||||
|
this.stopSessionRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void this.refreshSessionMenu()
|
||||||
|
}, SESSION_REFRESH_INTERVAL_MS)
|
||||||
|
;(this.sessionRefreshTimer as unknown as { unref?: () => void }).unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSessionRefresh(): void {
|
||||||
|
if (this.sessionRefreshTimer) clearInterval(this.sessionRefreshTimer)
|
||||||
|
this.sessionRefreshTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshSessionMenu(): Promise<void> {
|
||||||
|
if (this.sessionRefreshPending || !this.sessionMenu.visible || this.quitting) return
|
||||||
|
this.sessionRefreshPending = true
|
||||||
|
const loadId = this.sessionLoadId
|
||||||
|
try {
|
||||||
|
const sessions = await fetchSessions(
|
||||||
|
this.options.apiUrl,
|
||||||
|
this.options.apiToken,
|
||||||
|
this.apiReauthenticator,
|
||||||
|
)
|
||||||
|
if (this.quitting || loadId !== this.sessionLoadId || !this.sessionMenu.visible) return
|
||||||
|
this.sessionMenu.replace(
|
||||||
|
sessions,
|
||||||
|
this.client.activeChatId,
|
||||||
|
this.defaultModelPreset,
|
||||||
|
)
|
||||||
|
this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
|
||||||
|
} catch {
|
||||||
|
// Keep the existing picker usable during a transient refresh failure.
|
||||||
|
} finally {
|
||||||
|
this.sessionRefreshPending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async openContext(): Promise<void> {
|
private async openContext(): Promise<void> {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
@@ -2256,7 +2454,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private openDiff(): void {
|
private openDiff(): void {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
@@ -2320,6 +2518,7 @@ export class NanobotTui {
|
|||||||
this.quitting = true
|
this.quitting = true
|
||||||
this.submitGeneration += 1
|
this.submitGeneration += 1
|
||||||
this.submitPending = false
|
this.submitPending = false
|
||||||
|
this.stopSessionRefresh()
|
||||||
this.host.release()
|
this.host.release()
|
||||||
this.client.close()
|
this.client.close()
|
||||||
this.renderer.destroy()
|
this.renderer.destroy()
|
||||||
@@ -2330,6 +2529,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private handleDestroy = (): void => {
|
private handleDestroy = (): void => {
|
||||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||||
|
this.stopSessionRefresh()
|
||||||
this.transcript.destroy()
|
this.transcript.destroy()
|
||||||
this.diffViewer.destroy()
|
this.diffViewer.destroy()
|
||||||
this.host.release()
|
this.host.release()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
|||||||
|
|
||||||
export type CommandMenuTheme = PickerMenuTheme
|
export type CommandMenuTheme = PickerMenuTheme
|
||||||
|
|
||||||
export type TuiCommandAction =
|
type TuiCommandAction =
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "new-chat"
|
| "new-chat"
|
||||||
| "context"
|
| "context"
|
||||||
|
|||||||
+46
-3
@@ -1,22 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
BoxRenderable,
|
BoxRenderable,
|
||||||
RGBA,
|
RGBA,
|
||||||
|
StyledText,
|
||||||
TextAttributes,
|
TextAttributes,
|
||||||
TextRenderable,
|
TextRenderable,
|
||||||
type CliRenderer,
|
type CliRenderer,
|
||||||
|
type TextChunk,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
|
|
||||||
export interface PickerMenuTheme {
|
export interface PickerMenuTheme {
|
||||||
text: string
|
text: string
|
||||||
muted: string
|
muted: string
|
||||||
border: string
|
border: string
|
||||||
|
accent?: string
|
||||||
|
warning?: string
|
||||||
selectedBackground?: string
|
selectedBackground?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickerMenuOptions<T> {
|
interface PickerMenuOptions<T> {
|
||||||
id: string
|
id: string
|
||||||
|
key?: (item: T) => string
|
||||||
searchText: (item: T) => string
|
searchText: (item: T) => string
|
||||||
render: (item: T) => string
|
render: (item: T, selected: boolean) => string | TextChunk[]
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
onSelect?: (item: T) => void
|
onSelect?: (item: T) => void
|
||||||
@@ -68,9 +73,21 @@ export class PickerMenu<T> {
|
|||||||
this.update(query, limit)
|
this.update(query, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replace(items: T[]): void {
|
||||||
|
if (!this.visible) return
|
||||||
|
this.items = items
|
||||||
|
this.update(this.query, this.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw(): void {
|
||||||
|
if (this.visible) this.render()
|
||||||
|
}
|
||||||
|
|
||||||
update(query: string, limit = this.limit): void {
|
update(query: string, limit = this.limit): void {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
const changed = query !== this.query
|
const changed = query !== this.query
|
||||||
|
const previous = this.matches[this.selected]
|
||||||
|
const previousKey = previous === undefined ? null : this.options.key?.(previous)
|
||||||
this.query = query
|
this.query = query
|
||||||
this.limit = Math.max(1, limit)
|
this.limit = Math.max(1, limit)
|
||||||
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
|
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
|
||||||
@@ -80,7 +97,18 @@ export class PickerMenu<T> {
|
|||||||
return words.every((word) => haystack.includes(word))
|
return words.every((word) => haystack.includes(word))
|
||||||
})
|
})
|
||||||
.slice(0, this.limit)
|
.slice(0, this.limit)
|
||||||
this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1))
|
if (changed) {
|
||||||
|
this.selected = 0
|
||||||
|
} else {
|
||||||
|
const preserved = previous === undefined
|
||||||
|
? -1
|
||||||
|
: previousKey === null || previousKey === undefined
|
||||||
|
? this.matches.indexOf(previous)
|
||||||
|
: this.matches.findIndex((item) => this.options.key?.(item) === previousKey)
|
||||||
|
this.selected = preserved >= 0
|
||||||
|
? preserved
|
||||||
|
: Math.min(this.selected, Math.max(0, this.matches.length - 1))
|
||||||
|
}
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +153,16 @@ export class PickerMenu<T> {
|
|||||||
}
|
}
|
||||||
for (const [index, item] of this.matches.entries()) {
|
for (const [index, item] of this.matches.entries()) {
|
||||||
const selected = index === this.selected
|
const selected = index === this.selected
|
||||||
|
const rendered = this.options.render(item, selected)
|
||||||
|
const content = typeof rendered === "string"
|
||||||
|
? `${selected ? "›" : " "} ${rendered}`
|
||||||
|
: new StyledText([
|
||||||
|
chunk(`${selected ? "›" : " "} `, selected ? this.theme.text : this.theme.muted),
|
||||||
|
...rendered,
|
||||||
|
])
|
||||||
this.root.add(new TextRenderable(this.renderer, {
|
this.root.add(new TextRenderable(this.renderer, {
|
||||||
id: `${this.options.id}-${index}`,
|
id: `${this.options.id}-${index}`,
|
||||||
content: `${selected ? "›" : " "} ${this.options.render(item)}`,
|
content,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 1,
|
height: 1,
|
||||||
wrapMode: "none",
|
wrapMode: "none",
|
||||||
@@ -161,3 +196,11 @@ export class PickerMenu<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chunk(text: string, color: string): TextChunk {
|
||||||
|
return {
|
||||||
|
__isChunk: true,
|
||||||
|
text,
|
||||||
|
fg: RGBA.fromHex(color),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -595,6 +595,62 @@ describe("gateway protocol", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sends stale-safe recovery mutations and validates their response", async () => {
|
||||||
|
const original = globalThis.WebSocket
|
||||||
|
let socket: FakeSocket | undefined
|
||||||
|
Object.defineProperty(globalThis, "WebSocket", {
|
||||||
|
configurable: true,
|
||||||
|
value: class extends FakeSocket {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
socket = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statuses: string[] = []
|
||||||
|
const client = new NanobotClient({
|
||||||
|
url: "ws://nanobot.test/ws",
|
||||||
|
onEvent: () => undefined,
|
||||||
|
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
||||||
|
})
|
||||||
|
client.connect()
|
||||||
|
if (!socket) throw new Error("socket was not created")
|
||||||
|
|
||||||
|
const result = client.updateRecovery("continue", "chat", "recovery-1")
|
||||||
|
const request = JSON.parse(socket.sent.at(-1) || "{}") as {
|
||||||
|
request_id: string
|
||||||
|
action: string
|
||||||
|
payload: Record<string, string>
|
||||||
|
}
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: "webui_request",
|
||||||
|
action: "recovery.continue",
|
||||||
|
payload: { chat_id: "chat", recovery_id: "recovery-1" },
|
||||||
|
})
|
||||||
|
socket.emit("message", { data: JSON.stringify({
|
||||||
|
event: "webui_response",
|
||||||
|
request_id: request.request_id,
|
||||||
|
ok: true,
|
||||||
|
result: { status: "resuming", recovery_id: "recovery-1", attempts: 1 },
|
||||||
|
}) })
|
||||||
|
expect(await result).toEqual({
|
||||||
|
status: "resuming",
|
||||||
|
recovery_id: "recovery-1",
|
||||||
|
attempts: 1,
|
||||||
|
})
|
||||||
|
expect(statuses).not.toContain("error:gateway sent an invalid event")
|
||||||
|
|
||||||
|
const interrupted = client.updateRecovery("dismiss", "chat", "recovery-2")
|
||||||
|
socket.emit("close")
|
||||||
|
await expect(interrupted).rejects.toThrow("gateway connection closed")
|
||||||
|
client.close()
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("reports when the bounded history snapshot omits earlier turns", async () => {
|
test("reports when the bounded history snapshot omits earlier turns", async () => {
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
let requested = ""
|
let requested = ""
|
||||||
|
|||||||
+135
-4
@@ -29,14 +29,14 @@ export interface FileEditEvent {
|
|||||||
diff?: FileDiff
|
diff?: FileDiff
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileDiff {
|
interface FileDiff {
|
||||||
format: "unified" | string
|
format: "unified" | string
|
||||||
context?: number
|
context?: number
|
||||||
truncated?: boolean
|
truncated?: boolean
|
||||||
text?: string
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaAttachment {
|
interface MediaAttachment {
|
||||||
kind: "image" | "video" | "file"
|
kind: "image" | "video" | "file"
|
||||||
url: string
|
url: string
|
||||||
name?: string
|
name?: string
|
||||||
@@ -54,6 +54,16 @@ export interface RuntimeControls {
|
|||||||
canUseFullAccess: boolean
|
canUseFullAccess: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed"
|
||||||
|
|
||||||
|
export interface RecoveryState {
|
||||||
|
status: RecoveryStatus
|
||||||
|
recovery_id: string
|
||||||
|
reason?: string
|
||||||
|
attempts?: number
|
||||||
|
can_continue?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type InboundEvent =
|
export type InboundEvent =
|
||||||
| { event: "ready"; chat_id: string; client_id: string }
|
| { event: "ready"; chat_id: string; client_id: string }
|
||||||
| {
|
| {
|
||||||
@@ -61,6 +71,7 @@ export type InboundEvent =
|
|||||||
chat_id: string
|
chat_id: string
|
||||||
model_preset?: string | null
|
model_preset?: string | null
|
||||||
usage?: TokenUsage
|
usage?: TokenUsage
|
||||||
|
recovery_state?: RecoveryState
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
event: "message_accepted"
|
event: "message_accepted"
|
||||||
@@ -118,6 +129,7 @@ export type InboundEvent =
|
|||||||
turn_id?: string
|
turn_id?: string
|
||||||
}
|
}
|
||||||
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
|
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
|
||||||
|
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
|
||||||
| {
|
| {
|
||||||
event: "session_updated"
|
event: "session_updated"
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@@ -139,6 +151,12 @@ type OutboundEvent =
|
|||||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||||
|
| {
|
||||||
|
type: "webui_request"
|
||||||
|
request_id: string
|
||||||
|
action: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "message"
|
type: "message"
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@@ -225,7 +243,7 @@ export interface SessionContextSnapshot {
|
|||||||
lastUsage: TokenUsage | null
|
lastUsage: TokenUsage | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionMention {
|
interface SessionMention {
|
||||||
name: string
|
name: string
|
||||||
session_key: string
|
session_key: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -271,6 +289,7 @@ export interface SessionSummary {
|
|||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
runStartedAt: number | null
|
runStartedAt: number | null
|
||||||
modelPreset: string | null
|
modelPreset: string | null
|
||||||
|
recoveryState?: RecoveryState | null
|
||||||
workspaceScope?: WorkspaceScopePayload | null
|
workspaceScope?: WorkspaceScopePayload | null
|
||||||
pinned: boolean
|
pinned: boolean
|
||||||
archived: boolean
|
archived: boolean
|
||||||
@@ -297,6 +316,7 @@ const CHAT_EVENTS = new Set([
|
|||||||
"turn_end",
|
"turn_end",
|
||||||
"goal_status",
|
"goal_status",
|
||||||
"goal_state",
|
"goal_state",
|
||||||
|
"recovery_state",
|
||||||
"session_updated",
|
"session_updated",
|
||||||
"turn_model_updated",
|
"turn_model_updated",
|
||||||
"error",
|
"error",
|
||||||
@@ -377,6 +397,34 @@ function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
|
|||||||
&& optional(value.restrict_to_workspace, "boolean")
|
&& optional(value.restrict_to_workspace, "boolean")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecoveryState(value: unknown): value is RecoveryState {
|
||||||
|
return isRecord(value)
|
||||||
|
&& ["resuming", "awaiting_user", "recovered", "failed"].includes(String(value.status))
|
||||||
|
&& typeof value.recovery_id === "string"
|
||||||
|
&& optional(value.reason, "string")
|
||||||
|
&& optional(value.attempts, "number")
|
||||||
|
&& optional(value.can_continue, "boolean")
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebUIResponseEvent {
|
||||||
|
event: "webui_response"
|
||||||
|
request_id: string
|
||||||
|
ok: boolean
|
||||||
|
result?: unknown
|
||||||
|
error?: { status: number; message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeWebUIResponse(value: unknown): WebUIResponseEvent | null | undefined {
|
||||||
|
if (!isRecord(value) || value.event !== "webui_response") return undefined
|
||||||
|
if (typeof value.request_id !== "string" || typeof value.ok !== "boolean") return null
|
||||||
|
if (value.ok) return value as unknown as WebUIResponseEvent
|
||||||
|
return isRecord(value.error)
|
||||||
|
&& typeof value.error.status === "number"
|
||||||
|
&& typeof value.error.message === "string"
|
||||||
|
? value as unknown as WebUIResponseEvent
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||||
if (!isRecord(value)) return null
|
if (!isRecord(value)) return null
|
||||||
const record = value
|
const record = value
|
||||||
@@ -407,7 +455,8 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
&& ((record.model_preset !== undefined
|
&& ((record.model_preset !== undefined
|
||||||
&& record.model_preset !== null
|
&& record.model_preset !== null
|
||||||
&& typeof record.model_preset !== "string")
|
&& typeof record.model_preset !== "string")
|
||||||
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|
||||||
|
|| (record.recovery_state !== undefined && !isRecoveryState(record.recovery_state)))
|
||||||
) return null
|
) return null
|
||||||
if (
|
if (
|
||||||
["user_message", "message", "delta", "reasoning_delta"].includes(name)
|
["user_message", "message", "delta", "reasoning_delta"].includes(name)
|
||||||
@@ -452,6 +501,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
) return null
|
) return null
|
||||||
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
||||||
if (name === "goal_state" && !isRecord(record.goal_state)) return null
|
if (name === "goal_state" && !isRecord(record.goal_state)) return null
|
||||||
|
if (name === "recovery_state" && !isRecoveryState(record)) return null
|
||||||
if (
|
if (
|
||||||
name === "session_updated"
|
name === "session_updated"
|
||||||
&& (!optional(record.scope, "string")
|
&& (!optional(record.scope, "string")
|
||||||
@@ -700,6 +750,9 @@ export async function fetchSessions(
|
|||||||
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
|
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
|
||||||
? value.model_preset.trim()
|
? value.model_preset.trim()
|
||||||
: null,
|
: null,
|
||||||
|
...(isRecoveryState(value.recovery_state)
|
||||||
|
? { recoveryState: value.recovery_state }
|
||||||
|
: {}),
|
||||||
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
|
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
|
||||||
pinned: pinned.has(value.key),
|
pinned: pinned.has(value.key),
|
||||||
archived: archived.has(value.key),
|
archived: archived.has(value.key),
|
||||||
@@ -855,6 +908,11 @@ export class NanobotClient {
|
|||||||
private closedByClient = false
|
private closedByClient = false
|
||||||
private opening = false
|
private opening = false
|
||||||
private connectedOnce = false
|
private connectedOnce = false
|
||||||
|
private readonly pendingMutations = new Map<string, {
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}>()
|
||||||
|
|
||||||
constructor(private readonly options: ClientOptions) {}
|
constructor(private readonly options: ClientOptions) {}
|
||||||
|
|
||||||
@@ -916,6 +974,7 @@ export class NanobotClient {
|
|||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
if (this.socket !== socket) return
|
if (this.socket !== socket) return
|
||||||
this.socket = null
|
this.socket = null
|
||||||
|
this.rejectPendingMutations("gateway connection closed")
|
||||||
if (this.closedByClient) {
|
if (this.closedByClient) {
|
||||||
this.options.onStatus("closed")
|
this.options.onStatus("closed")
|
||||||
return
|
return
|
||||||
@@ -931,6 +990,7 @@ export class NanobotClient {
|
|||||||
const socket = this.socket
|
const socket = this.socket
|
||||||
this.socket = null
|
this.socket = null
|
||||||
socket?.close()
|
socket?.close()
|
||||||
|
this.rejectPendingMutations("gateway connection closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
send(content: string, options: MessageOptions = {}): string {
|
send(content: string, options: MessageOptions = {}): string {
|
||||||
@@ -975,6 +1035,63 @@ export class NanobotClient {
|
|||||||
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateRecovery(
|
||||||
|
action: "continue" | "dismiss",
|
||||||
|
chatId: string,
|
||||||
|
recoveryId: string,
|
||||||
|
): Promise<RecoveryState> {
|
||||||
|
return this.requestMutation<unknown>(`recovery.${action}`, {
|
||||||
|
chat_id: chatId,
|
||||||
|
recovery_id: recoveryId,
|
||||||
|
}).then((result) => {
|
||||||
|
if (!isRecoveryState(result)) throw new Error("gateway returned an invalid recovery state")
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestMutation<T>(
|
||||||
|
action: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
timeoutMs = 20_000,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return Promise.reject(new Error("gateway connection is not open"))
|
||||||
|
}
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
const frame = JSON.stringify({
|
||||||
|
type: "webui_request",
|
||||||
|
request_id: requestId,
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
} satisfies OutboundEvent)
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingMutations.delete(requestId)
|
||||||
|
reject(new Error(`gateway request timed out after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
this.pendingMutations.set(requestId, {
|
||||||
|
resolve: (value) => resolve(value as T),
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
this.socket?.send(frame)
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.pendingMutations.delete(requestId)
|
||||||
|
reject(new Error("could not send gateway request"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectPendingMutations(message: string): void {
|
||||||
|
for (const pending of this.pendingMutations.values()) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
pending.reject(new Error(message))
|
||||||
|
}
|
||||||
|
this.pendingMutations.clear()
|
||||||
|
}
|
||||||
|
|
||||||
private handleMessage(raw: string): void {
|
private handleMessage(raw: string): void {
|
||||||
let value: unknown
|
let value: unknown
|
||||||
try {
|
try {
|
||||||
@@ -983,6 +1100,20 @@ export class NanobotClient {
|
|||||||
this.options.onStatus("error", "gateway sent invalid JSON")
|
this.options.onStatus("error", "gateway sent invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const response = decodeWebUIResponse(value)
|
||||||
|
if (response === null) {
|
||||||
|
this.options.onStatus("error", "gateway sent an invalid event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response) {
|
||||||
|
const pending = this.pendingMutations.get(response.request_id)
|
||||||
|
if (!pending) return
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this.pendingMutations.delete(response.request_id)
|
||||||
|
if (response.ok) pending.resolve(response.result)
|
||||||
|
else pending.reject(new Error(response.error?.message || "gateway request failed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
const event = decodeInboundEvent(value)
|
const event = decodeInboundEvent(value)
|
||||||
if (event === undefined) return
|
if (event === undefined) return
|
||||||
if (event === null) {
|
if (event === null) {
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
BoxRenderable,
|
||||||
|
RGBA,
|
||||||
|
StyledText,
|
||||||
|
TextAttributes,
|
||||||
|
TextRenderable,
|
||||||
|
type CliRenderer,
|
||||||
|
type TextChunk,
|
||||||
|
} from "@opentui/core"
|
||||||
|
|
||||||
|
import type { RecoveryState } from "./protocol"
|
||||||
|
|
||||||
|
export interface RecoveryNoticeTheme {
|
||||||
|
text: string
|
||||||
|
muted: string
|
||||||
|
border: string
|
||||||
|
accent: string
|
||||||
|
warning: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryNoticeOptions {
|
||||||
|
onContinue: () => void
|
||||||
|
onDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A quiet action surface for a gateway-owned interrupted turn. */
|
||||||
|
export class RecoveryNotice {
|
||||||
|
readonly root: BoxRenderable
|
||||||
|
private readonly title: TextRenderable
|
||||||
|
private readonly detail: TextRenderable
|
||||||
|
private readonly dismiss: TextRenderable
|
||||||
|
private readonly resume: TextRenderable
|
||||||
|
private state: RecoveryState | null = null
|
||||||
|
private busy = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
renderer: CliRenderer,
|
||||||
|
private theme: RecoveryNoticeTheme,
|
||||||
|
options: RecoveryNoticeOptions,
|
||||||
|
) {
|
||||||
|
this.root = new BoxRenderable(renderer, {
|
||||||
|
id: "nanobot-tui-recovery-notice",
|
||||||
|
width: "100%",
|
||||||
|
height: 4,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexDirection: "column",
|
||||||
|
border: true,
|
||||||
|
borderStyle: "rounded",
|
||||||
|
borderColor: theme.border,
|
||||||
|
paddingLeft: 1,
|
||||||
|
paddingRight: 1,
|
||||||
|
visible: false,
|
||||||
|
backgroundColor: RGBA.defaultBackground(),
|
||||||
|
})
|
||||||
|
const header = new BoxRenderable(renderer, {
|
||||||
|
id: "nanobot-tui-recovery-header",
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
})
|
||||||
|
this.title = new TextRenderable(renderer, {
|
||||||
|
id: "nanobot-tui-recovery-title",
|
||||||
|
width: "auto",
|
||||||
|
minWidth: 0,
|
||||||
|
flexGrow: 1,
|
||||||
|
height: 1,
|
||||||
|
truncate: true,
|
||||||
|
selectable: false,
|
||||||
|
})
|
||||||
|
this.detail = new TextRenderable(renderer, {
|
||||||
|
id: "nanobot-tui-recovery-detail",
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
truncate: true,
|
||||||
|
selectable: false,
|
||||||
|
})
|
||||||
|
this.dismiss = this.action(renderer, "dismiss", "Dismiss", options.onDismiss)
|
||||||
|
this.resume = this.action(renderer, "continue", "Continue", options.onContinue, true)
|
||||||
|
header.add(this.title)
|
||||||
|
header.add(this.dismiss)
|
||||||
|
header.add(this.resume)
|
||||||
|
this.root.add(header)
|
||||||
|
this.root.add(this.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
get visible(): boolean {
|
||||||
|
return this.root.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
show(state: RecoveryState): void {
|
||||||
|
this.state = state
|
||||||
|
this.busy = false
|
||||||
|
this.root.visible = true
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
this.state = null
|
||||||
|
this.busy = false
|
||||||
|
this.root.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(busy: boolean): void {
|
||||||
|
this.busy = busy
|
||||||
|
if (this.visible) this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: RecoveryNoticeTheme): void {
|
||||||
|
this.theme = theme
|
||||||
|
this.root.borderColor = theme.border
|
||||||
|
if (this.visible) this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
private action(
|
||||||
|
renderer: CliRenderer,
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
callback: () => void,
|
||||||
|
primary = false,
|
||||||
|
): TextRenderable {
|
||||||
|
return new TextRenderable(renderer, {
|
||||||
|
id: `nanobot-tui-recovery-${id}`,
|
||||||
|
content: label,
|
||||||
|
width: label.length,
|
||||||
|
height: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
selectable: false,
|
||||||
|
onMouseOver: () => {
|
||||||
|
if (this.busy) return
|
||||||
|
const target = primary ? this.resume : this.dismiss
|
||||||
|
target.attributes = TextAttributes.BOLD | TextAttributes.UNDERLINE
|
||||||
|
},
|
||||||
|
onMouseOut: () => this.render(),
|
||||||
|
onMouseDown: (event) => {
|
||||||
|
if (event.button !== 0 || this.busy) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
renderer.clearSelection()
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
if (!this.state) return
|
||||||
|
const failed = this.state.status === "failed"
|
||||||
|
const contextUnavailable = this.state.can_continue === false
|
||||||
|
const title = failed ? "Recovery failed" : "Task interrupted"
|
||||||
|
const detail = failed
|
||||||
|
? "Review the saved task before continuing."
|
||||||
|
: contextUnavailable
|
||||||
|
? "This task can’t be resumed safely. Dismiss to start a new message."
|
||||||
|
: "Review the saved context. Tools will not replay automatically."
|
||||||
|
this.title.content = new StyledText([
|
||||||
|
chunk("⚠ ", failed ? this.theme.error : this.theme.warning),
|
||||||
|
chunk(title, this.theme.text, true),
|
||||||
|
])
|
||||||
|
this.detail.content = new StyledText([chunk(` ${detail}`, this.theme.muted)])
|
||||||
|
this.dismiss.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.text)
|
||||||
|
this.resume.visible = !contextUnavailable
|
||||||
|
this.resume.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.accent)
|
||||||
|
this.dismiss.attributes = 0
|
||||||
|
this.resume.attributes = TextAttributes.BOLD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(text: string, color: string, bold = false): TextChunk {
|
||||||
|
return {
|
||||||
|
__isChunk: true,
|
||||||
|
text,
|
||||||
|
fg: RGBA.fromHex(color),
|
||||||
|
attributes: bold ? TextAttributes.BOLD : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ const sessions: SessionSummary[] = [
|
|||||||
updatedAt: "2026-08-12T10:00:00Z",
|
updatedAt: "2026-08-12T10:00:00Z",
|
||||||
runStartedAt: null,
|
runStartedAt: null,
|
||||||
modelPreset: null,
|
modelPreset: null,
|
||||||
|
recoveryState: {
|
||||||
|
status: "awaiting_user",
|
||||||
|
recovery_id: "recovery-two",
|
||||||
|
reason: "tool execution interrupted",
|
||||||
|
},
|
||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
},
|
},
|
||||||
@@ -43,17 +48,21 @@ describe("SessionMenu", () => {
|
|||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
menu.open(sessions, "one", 6)
|
menu.open(sessions, "one", 6, "Codex")
|
||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
|
|
||||||
expect(setup.captureCharFrame()).toContain("› ● API migration")
|
expect(setup.captureCharFrame()).toContain("› ● API migration")
|
||||||
|
expect(setup.captureCharFrame()).not.toContain("Move authentication")
|
||||||
|
expect(setup.captureCharFrame()).not.toContain("Codex")
|
||||||
expect(menu.choose()?.chatId).toBe("one")
|
expect(menu.choose()?.chatId).toBe("one")
|
||||||
|
|
||||||
menu.update("release stable", 6)
|
menu.update("release stable", 6)
|
||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
expect(setup.captureCharFrame()).toContain("Release checklist")
|
expect(setup.captureCharFrame()).toContain("⚠ Release checklist")
|
||||||
expect(menu.choose()?.chatId).toBe("two")
|
expect(menu.choose()?.chatId).toBe("two")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,12 +72,80 @@ describe("SessionMenu", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("animates running sessions and marks completed background sessions unread", async () => {
|
||||||
|
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||||
|
const menu = new SessionMenu(setup.renderer, {
|
||||||
|
text: "#FFFFFF",
|
||||||
|
muted: "#999999",
|
||||||
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
|
})
|
||||||
|
setup.renderer.root.add(menu.root)
|
||||||
|
const running = {
|
||||||
|
...sessions[0]!,
|
||||||
|
chatId: "running",
|
||||||
|
title: "Background task",
|
||||||
|
runStartedAt: Date.now(),
|
||||||
|
pinned: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.open([sessions[0]!, running], "one", 6)
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Background task/u)
|
||||||
|
|
||||||
|
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "one")
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("• Background task")
|
||||||
|
|
||||||
|
menu.markRead("running")
|
||||||
|
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "running")
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("● Background task")
|
||||||
|
menu.hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prioritizes actionable sessions without moving keyboard selection on refresh", async () => {
|
||||||
|
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||||
|
const menu = new SessionMenu(setup.renderer, {
|
||||||
|
text: "#FFFFFF",
|
||||||
|
muted: "#999999",
|
||||||
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
|
})
|
||||||
|
setup.renderer.root.add(menu.root)
|
||||||
|
const running = {
|
||||||
|
...sessions[0]!,
|
||||||
|
chatId: "running",
|
||||||
|
title: "Background task",
|
||||||
|
runStartedAt: Date.now(),
|
||||||
|
pinned: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = { ...sessions[1]!, recoveryState: null }
|
||||||
|
menu.open([sessions[0]!, running, idle], "one", 6)
|
||||||
|
expect(menu.choose()?.chatId).toBe("one")
|
||||||
|
expect(menu.move(1)).toBe(true)
|
||||||
|
expect(menu.choose()?.chatId).toBe("running")
|
||||||
|
|
||||||
|
menu.replace([sessions[0]!, running, sessions[1]!], "one")
|
||||||
|
await setup.renderOnce()
|
||||||
|
|
||||||
|
expect(menu.choose()?.chatId).toBe("running")
|
||||||
|
const frame = setup.captureCharFrame()
|
||||||
|
expect(frame.indexOf("Release checklist")).toBeLessThan(frame.indexOf("Background task"))
|
||||||
|
menu.hide()
|
||||||
|
})
|
||||||
|
|
||||||
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
|
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
|
||||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||||
const menu = new SessionMenu(setup.renderer, {
|
const menu = new SessionMenu(setup.renderer, {
|
||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
menu.open(sessions, "one", 6)
|
menu.open(sessions, "one", 6)
|
||||||
@@ -97,6 +174,8 @@ describe("SessionMenu", () => {
|
|||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
const scoped = sessions.map((session) => ({
|
const scoped = sessions.map((session) => ({
|
||||||
@@ -154,4 +233,31 @@ describe("SessionMenu", () => {
|
|||||||
expect(duplicates).toContain("frontend/nanobot")
|
expect(duplicates).toContain("frontend/nanobot")
|
||||||
expect(duplicates).toContain("backend/nanobot")
|
expect(duplicates).toContain("backend/nanobot")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("shows only model overrides and keeps previews searchable", async () => {
|
||||||
|
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||||
|
const menu = new SessionMenu(setup.renderer, {
|
||||||
|
text: "#FFFFFF",
|
||||||
|
muted: "#999999",
|
||||||
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
|
})
|
||||||
|
setup.renderer.root.add(menu.root)
|
||||||
|
|
||||||
|
menu.open(sessions, "one", 6, "Codex")
|
||||||
|
await setup.renderOnce()
|
||||||
|
const frame = setup.captureCharFrame()
|
||||||
|
expect(frame).not.toContain("Codex")
|
||||||
|
expect(frame).not.toContain("Prepare the stable release")
|
||||||
|
|
||||||
|
menu.update("prepare stable", 6)
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(menu.choose()?.chatId).toBe("two")
|
||||||
|
|
||||||
|
menu.replace([{ ...sessions[1]!, modelPreset: "Deep Research" }], "one", "Codex")
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("Deep Research")
|
||||||
|
menu.hide()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+165
-14
@@ -1,9 +1,11 @@
|
|||||||
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
|
import { RGBA, type BoxRenderable, type CliRenderer, type TextChunk } from "@opentui/core"
|
||||||
|
|
||||||
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||||
import type { SessionSummary } from "./protocol"
|
import type { SessionSummary } from "./protocol"
|
||||||
|
|
||||||
type SessionMenuRow = SessionSummary & { active: boolean }
|
type SessionMenuRow = SessionSummary & { active: boolean; unread: boolean }
|
||||||
|
|
||||||
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
|
|
||||||
export function sessionLabel(session: SessionSummary): string {
|
export function sessionLabel(session: SessionSummary): string {
|
||||||
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
|
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
|
||||||
@@ -27,14 +29,24 @@ export class SessionMenu {
|
|||||||
private readonly picker: PickerMenu<SessionMenuRow>
|
private readonly picker: PickerMenu<SessionMenuRow>
|
||||||
private readonly workspaceLabels = new Map<string, string>()
|
private readonly workspaceLabels = new Map<string, string>()
|
||||||
private showWorkspaces = false
|
private showWorkspaces = false
|
||||||
|
private spinnerFrame = 0
|
||||||
|
private spinnerTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
private rows: SessionMenuRow[] = []
|
||||||
|
private defaultModelPreset = ""
|
||||||
|
private readonly snapshots = new Map<string, {
|
||||||
|
preview: string
|
||||||
|
runStartedAt: number | null
|
||||||
|
}>()
|
||||||
|
private readonly unreadChatIds = new Set<string>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
renderer: CliRenderer,
|
renderer: CliRenderer,
|
||||||
theme: PickerMenuTheme,
|
private theme: PickerMenuTheme,
|
||||||
onSelect?: (session: SessionSummary) => void,
|
onSelect?: (session: SessionSummary) => void,
|
||||||
) {
|
) {
|
||||||
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
||||||
id: "nanobot-tui-session-menu",
|
id: "nanobot-tui-session-menu",
|
||||||
|
key: (session) => session.chatId,
|
||||||
searchText: (session) => [
|
searchText: (session) => [
|
||||||
sessionLabel(session),
|
sessionLabel(session),
|
||||||
session.modelPreset || "",
|
session.modelPreset || "",
|
||||||
@@ -42,20 +54,27 @@ export class SessionMenu {
|
|||||||
session.chatId,
|
session.chatId,
|
||||||
session.workspaceScope?.project_name || "",
|
session.workspaceScope?.project_name || "",
|
||||||
session.workspaceScope?.project_path || "",
|
session.workspaceScope?.project_path || "",
|
||||||
|
session.recoveryState?.status || "",
|
||||||
|
session.recoveryState?.reason || "",
|
||||||
].join(" "),
|
].join(" "),
|
||||||
render: (session) => {
|
render: (session, selected) => {
|
||||||
const age = updatedLabel(session.updatedAt)
|
const age = updatedLabel(session.updatedAt)
|
||||||
const preview = session.preview.trim()
|
|
||||||
const detail = [
|
const detail = [
|
||||||
this.showWorkspaces ? this.workspaceLabel(session) : "",
|
this.showWorkspaces ? this.workspaceLabel(session) : "",
|
||||||
session.modelPreset,
|
this.modelOverride(session),
|
||||||
age,
|
|
||||||
preview && preview !== sessionLabel(session) ? preview : "",
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")
|
.join(" · ")
|
||||||
const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
|
const marker = this.marker(session)
|
||||||
return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}`
|
const foreground = this.interrupted(session)
|
||||||
|
? this.theme.warning || this.theme.accent || this.theme.text
|
||||||
|
: selected ? this.theme.text : this.theme.muted
|
||||||
|
return [
|
||||||
|
...(marker ? [chunk(`${marker.text} `, marker.color)] : []),
|
||||||
|
chunk(sessionLabel(session), foreground),
|
||||||
|
...(detail ? [chunk(` ${detail}`, this.theme.muted)] : []),
|
||||||
|
...(age ? [chunk(` ${age}`, this.theme.muted)] : []),
|
||||||
|
]
|
||||||
},
|
},
|
||||||
emptyText: "No matching sessions",
|
emptyText: "No matching sessions",
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -67,16 +86,51 @@ export class SessionMenu {
|
|||||||
return this.picker.visible
|
return this.picker.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
|
open(
|
||||||
|
sessions: SessionSummary[],
|
||||||
|
currentChatId: string,
|
||||||
|
limit: number,
|
||||||
|
defaultModelPreset = "",
|
||||||
|
): void {
|
||||||
|
this.defaultModelPreset = defaultModelPreset
|
||||||
|
this.observe(sessions, currentChatId)
|
||||||
|
this.rows = this.prepareRows(sessions, currentChatId)
|
||||||
|
this.picker.show(this.rows, "", limit)
|
||||||
|
this.syncSpinner()
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(
|
||||||
|
sessions: SessionSummary[],
|
||||||
|
currentChatId: string,
|
||||||
|
defaultModelPreset = this.defaultModelPreset,
|
||||||
|
): void {
|
||||||
|
this.defaultModelPreset = defaultModelPreset
|
||||||
|
this.observe(sessions, currentChatId)
|
||||||
|
this.rows = this.prepareRows(sessions, currentChatId)
|
||||||
|
this.picker.replace(this.rows)
|
||||||
|
this.syncSpinner()
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(chatId: string): void {
|
||||||
|
this.unreadChatIds.delete(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareRows(sessions: SessionSummary[], currentChatId: string): SessionMenuRow[] {
|
||||||
this.prepareWorkspaceLabels(sessions)
|
this.prepareWorkspaceLabels(sessions)
|
||||||
const rows = sessions
|
return sessions
|
||||||
.map((session) => ({ ...session, active: session.chatId === currentChatId }))
|
.map((session) => ({
|
||||||
|
...session,
|
||||||
|
active: session.chatId === currentChatId,
|
||||||
|
unread: this.unreadChatIds.has(session.chatId),
|
||||||
|
}))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
return Number(right.active) - Number(left.active)
|
return Number(right.active) - Number(left.active)
|
||||||
|
|| sessionPriority(right) - sessionPriority(left)
|
||||||
|| Number(right.pinned) - Number(left.pinned)
|
|| Number(right.pinned) - Number(left.pinned)
|
||||||
|| Number(left.archived) - Number(right.archived)
|
|| Number(left.archived) - Number(right.archived)
|
||||||
|
|| timestamp(right.updatedAt) - timestamp(left.updatedAt)
|
||||||
|
|| sessionLabel(left).localeCompare(sessionLabel(right))
|
||||||
})
|
})
|
||||||
this.picker.show(rows, "", limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(query: string, limit: number): void {
|
update(query: string, limit: number): void {
|
||||||
@@ -92,13 +146,84 @@ export class SessionMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hide(): void {
|
hide(): void {
|
||||||
|
this.stopSpinner()
|
||||||
|
this.rows = []
|
||||||
this.picker.hide()
|
this.picker.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: PickerMenuTheme): void {
|
setTheme(theme: PickerMenuTheme): void {
|
||||||
|
this.theme = theme
|
||||||
this.picker.setTheme(theme)
|
this.picker.setTheme(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private marker(session: SessionMenuRow): { text: string; color: string } | null {
|
||||||
|
if (this.interrupted(session)) {
|
||||||
|
return {
|
||||||
|
text: "⚠",
|
||||||
|
color: this.theme.warning || this.theme.accent || this.theme.text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.runStartedAt !== null) {
|
||||||
|
return {
|
||||||
|
text: SPINNER_FRAMES[this.spinnerFrame % SPINNER_FRAMES.length] || SPINNER_FRAMES[0]!,
|
||||||
|
color: this.theme.accent || this.theme.text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.active) return { text: "●", color: this.theme.text }
|
||||||
|
if (session.unread) return { text: "•", color: this.theme.accent || this.theme.text }
|
||||||
|
if (session.pinned) return { text: "◆", color: this.theme.muted }
|
||||||
|
if (session.archived) return { text: "◇", color: this.theme.muted }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private interrupted(session: SessionMenuRow): boolean {
|
||||||
|
return session.recoveryState?.status === "awaiting_user"
|
||||||
|
|| session.recoveryState?.status === "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private observe(sessions: SessionSummary[], currentChatId: string): void {
|
||||||
|
const present = new Set<string>()
|
||||||
|
for (const session of sessions) {
|
||||||
|
present.add(session.chatId)
|
||||||
|
const previous = this.snapshots.get(session.chatId)
|
||||||
|
const active = session.chatId === currentChatId
|
||||||
|
const completed = previous !== undefined
|
||||||
|
&& previous.runStartedAt !== null
|
||||||
|
&& session.runStartedAt === null
|
||||||
|
const receivedContent = previous !== undefined && previous.preview !== session.preview
|
||||||
|
if (active) this.unreadChatIds.delete(session.chatId)
|
||||||
|
else if (completed || receivedContent) this.unreadChatIds.add(session.chatId)
|
||||||
|
this.snapshots.set(session.chatId, {
|
||||||
|
preview: session.preview,
|
||||||
|
runStartedAt: session.runStartedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const chatId of this.snapshots.keys()) {
|
||||||
|
if (present.has(chatId)) continue
|
||||||
|
this.snapshots.delete(chatId)
|
||||||
|
this.unreadChatIds.delete(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSpinner(): void {
|
||||||
|
if (!this.visible || !this.rows.some((session) => session.runStartedAt !== null)) {
|
||||||
|
this.stopSpinner()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.spinnerTimer) return
|
||||||
|
this.spinnerTimer = setInterval(() => {
|
||||||
|
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length
|
||||||
|
this.picker.redraw()
|
||||||
|
}, 90)
|
||||||
|
;(this.spinnerTimer as unknown as { unref?: () => void }).unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSpinner(): void {
|
||||||
|
if (this.spinnerTimer) clearInterval(this.spinnerTimer)
|
||||||
|
this.spinnerTimer = null
|
||||||
|
this.spinnerFrame = 0
|
||||||
|
}
|
||||||
|
|
||||||
private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
|
private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
|
||||||
this.workspaceLabels.clear()
|
this.workspaceLabels.clear()
|
||||||
const scopes = sessions.flatMap((session) => {
|
const scopes = sessions.flatMap((session) => {
|
||||||
@@ -122,6 +247,19 @@ export class SessionMenu {
|
|||||||
private workspaceLabel(session: SessionSummary): string {
|
private workspaceLabel(session: SessionSummary): string {
|
||||||
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || ""
|
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private modelOverride(session: SessionSummary): string {
|
||||||
|
const preset = session.modelPreset?.trim() || ""
|
||||||
|
return preset && preset !== this.defaultModelPreset ? preset : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(text: string, color: string): TextChunk {
|
||||||
|
return {
|
||||||
|
__isChunk: true,
|
||||||
|
text,
|
||||||
|
fg: RGBA.fromHex(color),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWorkspacePath(value: string | undefined): string {
|
function normalizeWorkspacePath(value: string | undefined): string {
|
||||||
@@ -136,3 +274,16 @@ function shortPath(path: string): string {
|
|||||||
const parts = path.split("/").filter(Boolean)
|
const parts = path.split("/").filter(Boolean)
|
||||||
return parts.slice(-2).join("/") || path
|
return parts.slice(-2).join("/") || path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionPriority(session: SessionMenuRow): number {
|
||||||
|
if (session.recoveryState?.status === "awaiting_user"
|
||||||
|
|| session.recoveryState?.status === "failed") return 3
|
||||||
|
if (session.runStartedAt !== null) return 2
|
||||||
|
return session.unread ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value: string | null): number {
|
||||||
|
if (!value) return 0
|
||||||
|
const parsed = Date.parse(value)
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed
|
||||||
|
}
|
||||||
|
|||||||
@@ -1274,6 +1274,15 @@ function Shell({
|
|||||||
}, [activeKey, activeTabKey, activeTabState]);
|
}, [activeKey, activeTabKey, activeTabState]);
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
|
const recoveryChatIdList = useMemo(
|
||||||
|
() => sessions
|
||||||
|
.filter((session) => (
|
||||||
|
session.recoveryState?.status === "awaiting_user"
|
||||||
|
|| session.recoveryState?.status === "failed"
|
||||||
|
))
|
||||||
|
.map((session) => session.chatId),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
const activeChatId = activePaneSession?.chatId ?? null;
|
const activeChatId = activePaneSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
@@ -2552,6 +2561,7 @@ function Shell({
|
|||||||
collapsedGroups: sidebarState.collapsed_groups,
|
collapsedGroups: sidebarState.collapsed_groups,
|
||||||
runningChatIds: runningChatIdList,
|
runningChatIds: runningChatIdList,
|
||||||
updatedChatIds: updatedChatIdList,
|
updatedChatIds: updatedChatIdList,
|
||||||
|
recoveryChatIds: recoveryChatIdList,
|
||||||
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: sidebarArchivedTabKeys.length,
|
archivedCount: sidebarArchivedTabKeys.length,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
export type ChannelFieldMessages = {
|
type ChannelFieldMessages = {
|
||||||
label: string;
|
label: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
help?: string;
|
help?: string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement } from
|
|||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
|
AlertTriangle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
@@ -50,7 +51,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
||||||
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
|
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
|
||||||
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
import { relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
COLLAPSED_CHATS_VISIBLE_COUNT,
|
COLLAPSED_CHATS_VISIBLE_COUNT,
|
||||||
displayTitle,
|
displayTitle,
|
||||||
@@ -260,6 +261,7 @@ interface ChatListProps {
|
|||||||
collapsedGroups?: Record<string, boolean>;
|
collapsedGroups?: Record<string, boolean>;
|
||||||
runningChatIds?: string[];
|
runningChatIds?: string[];
|
||||||
updatedChatIds?: string[];
|
updatedChatIds?: string[];
|
||||||
|
recoveryChatIds?: string[];
|
||||||
density?: SidebarDensity;
|
density?: SidebarDensity;
|
||||||
showPreviews?: boolean;
|
showPreviews?: boolean;
|
||||||
showTimestamps?: boolean;
|
showTimestamps?: boolean;
|
||||||
@@ -302,6 +304,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
collapsedGroups = {},
|
collapsedGroups = {},
|
||||||
runningChatIds = [],
|
runningChatIds = [],
|
||||||
updatedChatIds = [],
|
updatedChatIds = [],
|
||||||
|
recoveryChatIds = [],
|
||||||
density = "comfortable",
|
density = "comfortable",
|
||||||
showPreviews = false,
|
showPreviews = false,
|
||||||
showTimestamps = false,
|
showTimestamps = false,
|
||||||
@@ -558,6 +561,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
|
|
||||||
const running = new Set(runningChatIds);
|
const running = new Set(runningChatIds);
|
||||||
const updated = new Set(updatedChatIds);
|
const updated = new Set(updatedChatIds);
|
||||||
|
const recovery = new Set(recoveryChatIds);
|
||||||
const compact = density === "compact";
|
const compact = density === "compact";
|
||||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||||
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
|
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
|
||||||
@@ -881,6 +885,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
running={running}
|
running={running}
|
||||||
updated={updated}
|
updated={updated}
|
||||||
|
recovery={recovery}
|
||||||
onSelectPane={onSelectPane}
|
onSelectPane={onSelectPane}
|
||||||
onRequestDelete={onRequestDelete}
|
onRequestDelete={onRequestDelete}
|
||||||
onRequestRename={onRequestRename}
|
onRequestRename={onRequestRename}
|
||||||
@@ -906,14 +911,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackTitle = t("chat.fallbackTitle", {
|
|
||||||
id: s.chatId.slice(0, 6),
|
|
||||||
});
|
|
||||||
const generatedTitle = s.title?.trim() || "";
|
|
||||||
const tooltipTitle =
|
|
||||||
titleOverrides[s.key]?.trim() ||
|
|
||||||
generatedTitle ||
|
|
||||||
deriveTitle(s.preview, fallbackTitle);
|
|
||||||
const isPinned = pinned.has(s.key);
|
const isPinned = pinned.has(s.key);
|
||||||
const isArchived = archived.has(s.key);
|
const isArchived = archived.has(s.key);
|
||||||
const preview = visibleSessionPreview(s.preview);
|
const preview = visibleSessionPreview(s.preview);
|
||||||
@@ -923,6 +920,8 @@ export const ChatList = memo(function ChatList({
|
|||||||
: "";
|
: "";
|
||||||
const activityState = running.has(s.chatId)
|
const activityState = running.has(s.chatId)
|
||||||
? "running"
|
? "running"
|
||||||
|
: recovery.has(s.chatId)
|
||||||
|
? "recovery"
|
||||||
: updated.has(s.chatId) && !topicActive
|
: updated.has(s.chatId) && !topicActive
|
||||||
? "updated"
|
? "updated"
|
||||||
: null;
|
: null;
|
||||||
@@ -959,7 +958,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarItemTooltip label={tooltipTitle}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -1030,7 +1028,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarItemTooltip>
|
|
||||||
<SessionActivityIndicator state={activityState} />
|
<SessionActivityIndicator state={activityState} />
|
||||||
{!deleteSelectionMode ? (
|
{!deleteSelectionMode ? (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@@ -1340,6 +1337,7 @@ function ActivePaneRows({
|
|||||||
compact,
|
compact,
|
||||||
running,
|
running,
|
||||||
updated,
|
updated,
|
||||||
|
recovery,
|
||||||
onSelectPane,
|
onSelectPane,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
@@ -1364,6 +1362,7 @@ function ActivePaneRows({
|
|||||||
compact: boolean;
|
compact: boolean;
|
||||||
running: ReadonlySet<string>;
|
running: ReadonlySet<string>;
|
||||||
updated: ReadonlySet<string>;
|
updated: ReadonlySet<string>;
|
||||||
|
recovery: ReadonlySet<string>;
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
@@ -1400,6 +1399,8 @@ function ActivePaneRows({
|
|||||||
const active = tabActive && pane.key === group.activePaneKey;
|
const active = tabActive && pane.key === group.activePaneKey;
|
||||||
const activityState = running.has(pane.chatId)
|
const activityState = running.has(pane.chatId)
|
||||||
? "running"
|
? "running"
|
||||||
|
: recovery.has(pane.chatId)
|
||||||
|
? "recovery"
|
||||||
: updated.has(pane.chatId) && !active
|
: updated.has(pane.chatId) && !active
|
||||||
? "updated"
|
? "updated"
|
||||||
: null;
|
: null;
|
||||||
@@ -1434,9 +1435,6 @@ function ActivePaneRows({
|
|||||||
deleteSelectionMode && selected
|
deleteSelectionMode && selected
|
||||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<SidebarItemTooltip
|
|
||||||
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1475,7 +1473,6 @@ function ActivePaneRows({
|
|||||||
<SidebarSelectionTrack active={active} handle={pane.handle} />
|
<SidebarSelectionTrack active={active} handle={pane.handle} />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarItemTooltip>
|
|
||||||
<SessionActivityIndicator state={activityState} />
|
<SessionActivityIndicator state={activityState} />
|
||||||
{!deleteSelectionMode ? <DropdownMenu
|
{!deleteSelectionMode ? <DropdownMenu
|
||||||
modal={false}
|
modal={false}
|
||||||
@@ -1652,7 +1649,6 @@ function TemporaryChatSection({
|
|||||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarItemTooltip label={title}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(session.key)}
|
onClick={() => onSelect(session.key)}
|
||||||
@@ -1667,7 +1663,6 @@ function TemporaryChatSection({
|
|||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarItemTooltip>
|
|
||||||
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
|
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
|
||||||
{onClose ? (
|
{onClose ? (
|
||||||
<button
|
<button
|
||||||
@@ -1868,10 +1863,27 @@ function ChatsFoldFooter({
|
|||||||
function SessionActivityIndicator({
|
function SessionActivityIndicator({
|
||||||
state,
|
state,
|
||||||
}: {
|
}: {
|
||||||
state: "running" | "updated" | null;
|
state: "running" | "updated" | "recovery" | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (state === "recovery") {
|
||||||
|
const label = t("chat.activity.recovery", {
|
||||||
|
defaultValue: "This conversation needs your attention",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<SidebarItemTooltip label={label}>
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
className="grid h-4 w-4 shrink-0 place-items-center text-[#ff8a3d]"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
</SidebarItemTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (state === "running") {
|
if (state === "running") {
|
||||||
const label = t("chat.activity.running");
|
const label = t("chat.activity.running");
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function CapabilityMentionToken({
|
|||||||
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
|
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionMentionToken({
|
function SessionMentionToken({
|
||||||
mention,
|
mention,
|
||||||
label,
|
label,
|
||||||
variant,
|
variant,
|
||||||
@@ -172,7 +172,7 @@ export function SessionMentionToken({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CliAppMentionToken({
|
function CliAppMentionToken({
|
||||||
app,
|
app,
|
||||||
label,
|
label,
|
||||||
variant,
|
variant,
|
||||||
@@ -229,7 +229,7 @@ export function CliAppMentionToken({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function McpPresetMentionToken({
|
function McpPresetMentionToken({
|
||||||
preset,
|
preset,
|
||||||
label,
|
label,
|
||||||
variant,
|
variant,
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function splitFilePath(path: string): { directory: string; name: string }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileKindForPath(path: string): FileReferenceKind {
|
function fileKindForPath(path: string): FileReferenceKind {
|
||||||
const normalized = path.toLowerCase();
|
const normalized = path.toLowerCase();
|
||||||
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
||||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||||
@@ -193,7 +193,7 @@ export function fileKindForPath(path: string): FileReferenceKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||||
if (kind === "python") {
|
if (kind === "python") {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
import {
|
||||||
|
fmtDateTime,
|
||||||
|
formatCompactTokenCount,
|
||||||
|
formatMessageEndTime,
|
||||||
|
} from "@/lib/format";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
import { matchingSlashCommand } from "@/lib/slash-command";
|
import { matchingSlashCommand } from "@/lib/slash-command";
|
||||||
import { sessionHandleColor } from "@/lib/session-handle";
|
import { sessionHandleColor } from "@/lib/session-handle";
|
||||||
@@ -50,6 +54,7 @@ import type {
|
|||||||
UIMessage,
|
UIMessage,
|
||||||
MessageDeliveryErrorKind,
|
MessageDeliveryErrorKind,
|
||||||
MessageDeliveryStatus,
|
MessageDeliveryStatus,
|
||||||
|
TurnUsage,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
@@ -176,6 +181,71 @@ function MessageCopyButton({ content }: { content: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactDuration(milliseconds: number): string {
|
||||||
|
const seconds = milliseconds / 1_000;
|
||||||
|
if (seconds < 10) return `${seconds.toFixed(1)}s`;
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TurnUsageMeta({
|
||||||
|
usage,
|
||||||
|
latencyMs,
|
||||||
|
}: {
|
||||||
|
usage: TurnUsage;
|
||||||
|
latencyMs?: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const prompt = usage.prompt_tokens;
|
||||||
|
const completion = usage.completion_tokens;
|
||||||
|
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`);
|
||||||
|
if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
|
||||||
|
if (
|
||||||
|
typeof usage.cached_tokens === "number"
|
||||||
|
&& typeof prompt === "number"
|
||||||
|
&& prompt > 0
|
||||||
|
) {
|
||||||
|
parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`);
|
||||||
|
}
|
||||||
|
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
|
||||||
|
const details: string[] = [];
|
||||||
|
if (approximate) {
|
||||||
|
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
|
||||||
|
}
|
||||||
|
const usageMeta = (
|
||||||
|
<span
|
||||||
|
data-turn-usage
|
||||||
|
tabIndex={details.length ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] leading-none text-muted-foreground/70 tabular-nums",
|
||||||
|
details.length && "cursor-help",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{parts.join(" · ")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (details.length === 0) return usageMeta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{usageMeta}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
className="max-w-96 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{details.join(" · ")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function deliveryErrorCopy(
|
function deliveryErrorCopy(
|
||||||
kind: MessageDeliveryErrorKind | undefined,
|
kind: MessageDeliveryErrorKind | undefined,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
@@ -479,7 +549,9 @@ export function MessageBubble({
|
|||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
|
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
|
||||||
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
|
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
|
||||||
const showAssistantFooterRow = showCopyButton || showForkButton || showAssistantTimestamp;
|
const showUsage = message.role === "assistant" && !!message.usage && !message.isStreaming;
|
||||||
|
const showAssistantFooterRow =
|
||||||
|
showCopyButton || showForkButton || showAssistantTimestamp || showUsage;
|
||||||
const showAssistantFooterSlot =
|
const showAssistantFooterSlot =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
@@ -545,6 +617,12 @@ export function MessageBubble({
|
|||||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showUsage ? (
|
||||||
|
<TurnUsageMeta
|
||||||
|
usage={message.usage!}
|
||||||
|
latencyMs={message.latencyMs}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showAssistantTimestamp ? (
|
{showAssistantTimestamp ? (
|
||||||
<MessageTimestamp
|
<MessageTimestamp
|
||||||
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
||||||
@@ -576,7 +654,6 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
|
|||||||
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
|
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={text}
|
|
||||||
>
|
>
|
||||||
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
<p className="min-w-0 line-clamp-3 whitespace-pre-wrap text-[13px]/[1.45] [overflow-wrap:anywhere]">
|
<p className="min-w-0 line-clamp-3 whitespace-pre-wrap text-[13px]/[1.45] [overflow-wrap:anywhere]">
|
||||||
@@ -890,7 +967,7 @@ interface ReasoningBubbleProps {
|
|||||||
hasBodyBelow: boolean;
|
hasBodyBelow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReasoningBubble({
|
function ReasoningBubble({
|
||||||
text,
|
text,
|
||||||
streaming,
|
streaming,
|
||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
@@ -916,7 +993,7 @@ interface TraceGroupProps {
|
|||||||
* collapsed because tool traces are supporting evidence, not the answer.
|
* collapsed because tool traces are supporting evidence, not the answer.
|
||||||
* A single click expands the exact calls when the user wants details.
|
* A single click expands the exact calls when the user wants details.
|
||||||
*/
|
*/
|
||||||
export function TraceGroup({ message }: TraceGroupProps) {
|
function TraceGroup({ message }: TraceGroupProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lines = message.traces ?? [message.content];
|
const lines = message.traces ?? [message.content];
|
||||||
const count = lines.length;
|
const count = lines.length;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ interface SidebarProps {
|
|||||||
collapsedGroups?: Record<string, boolean>;
|
collapsedGroups?: Record<string, boolean>;
|
||||||
runningChatIds?: string[];
|
runningChatIds?: string[];
|
||||||
updatedChatIds?: string[];
|
updatedChatIds?: string[];
|
||||||
|
recoveryChatIds?: string[];
|
||||||
viewState?: SidebarViewState;
|
viewState?: SidebarViewState;
|
||||||
showArchived?: boolean;
|
showArchived?: boolean;
|
||||||
archivedCount?: number;
|
archivedCount?: number;
|
||||||
@@ -270,6 +271,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
collapsedGroups={props.collapsedGroups}
|
collapsedGroups={props.collapsedGroups}
|
||||||
runningChatIds={props.runningChatIds}
|
runningChatIds={props.runningChatIds}
|
||||||
updatedChatIds={props.updatedChatIds}
|
updatedChatIds={props.updatedChatIds}
|
||||||
|
recoveryChatIds={props.recoveryChatIds}
|
||||||
density={props.viewState?.density}
|
density={props.viewState?.density}
|
||||||
showPreviews={props.viewState?.show_previews}
|
showPreviews={props.viewState?.show_previews}
|
||||||
showTimestamps={props.viewState?.show_timestamps}
|
showTimestamps={props.viewState?.show_timestamps}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function ChannelLogo({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelDisplayName(feature: NanobotFeatureInfo): string {
|
function channelDisplayName(feature: NanobotFeatureInfo): string {
|
||||||
return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name;
|
return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function ChannelSetupLinks({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelOfficialLink({
|
function ChannelOfficialLink({
|
||||||
feature,
|
feature,
|
||||||
setup,
|
setup,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
|
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
|
function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
|
||||||
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
|
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type ChannelSetupPresentation = {
|
|||||||
presets?: ChannelProviderPreset[];
|
presets?: ChannelProviderPreset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelCatalogSetupPresentation = {
|
type ChannelCatalogSetupPresentation = {
|
||||||
mode?: "webui" | "credentials" | "connect";
|
mode?: "webui" | "credentials" | "connect";
|
||||||
command?: string;
|
command?: string;
|
||||||
docsUrl?: string;
|
docsUrl?: string;
|
||||||
@@ -38,15 +38,15 @@ export type ChannelCatalogSetupPresentation = {
|
|||||||
presets?: ChannelProviderPresetDefinition[];
|
presets?: ChannelProviderPresetDefinition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelFieldPresentation = {
|
type ChannelFieldPresentation = {
|
||||||
key: string;
|
key: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">;
|
type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">;
|
||||||
|
|
||||||
export type ChannelProviderPresetDefinition = Omit<ChannelProviderPreset, "label">;
|
export type ChannelProviderPresetDefinition = Omit<ChannelProviderPreset, "label">;
|
||||||
|
|
||||||
export type ChannelSetupAction = {
|
type ChannelSetupAction = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -72,7 +72,7 @@ export type ChannelConfigField = {
|
|||||||
options?: ChannelConfigOption[];
|
options?: ChannelConfigOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelConfigOption = {
|
type ChannelConfigOption = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type SettingsSectionKey =
|
|||||||
| "runtime"
|
| "runtime"
|
||||||
| "advanced";
|
| "advanced";
|
||||||
|
|
||||||
export type PendingRestartSection = "runtime" | "browser" | "image";
|
type PendingRestartSection = "runtime" | "browser" | "image";
|
||||||
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
||||||
|
|
||||||
export type RestartAwarePayload = {
|
export type RestartAwarePayload = {
|
||||||
|
|||||||
@@ -421,6 +421,37 @@ export function AppearanceSettings({
|
|||||||
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
title={tx("settings.rows.browserNotifications", "Task notifications")}
|
||||||
|
description={tx(
|
||||||
|
"settings.help.browserNotifications",
|
||||||
|
"Notify only when this page is in the background. Off by default.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
checked={localPrefs.browserNotifications}
|
||||||
|
onChange={(enabled) => {
|
||||||
|
if (!enabled) {
|
||||||
|
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof Notification === "undefined") return;
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Notification.requestPermission().then((permission) => {
|
||||||
|
if (permission === "granted") {
|
||||||
|
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
ariaLabel={tx("settings.rows.browserNotifications", "Task notifications")}
|
||||||
|
label={localPrefs.browserNotifications
|
||||||
|
? tx("settings.values.on", "On")
|
||||||
|
: tx("settings.values.off", "Off")}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { MarkdownText } from "@/components/MarkdownText";
|
||||||
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
|
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
|
||||||
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
|
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
|
||||||
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
|
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
|
||||||
@@ -50,11 +51,12 @@ import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessag
|
|||||||
|
|
||||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
|
|
||||||
export { isAgentActivityMember, isReasoningOnlyAssistant };
|
export { isAgentActivityMember };
|
||||||
|
|
||||||
interface ActivityCounts {
|
interface ActivityCounts {
|
||||||
reasoningSteps: number;
|
reasoningSteps: number;
|
||||||
toolCalls: number;
|
toolCalls: number;
|
||||||
|
modelSegments: number;
|
||||||
cliCount: number;
|
cliCount: number;
|
||||||
mcpCount: number;
|
mcpCount: number;
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
@@ -91,9 +93,14 @@ function countActivity(
|
|||||||
): ActivityCounts {
|
): ActivityCounts {
|
||||||
let reasoningSteps = 0;
|
let reasoningSteps = 0;
|
||||||
let toolCalls = 0;
|
let toolCalls = 0;
|
||||||
|
let modelSegments = 0;
|
||||||
const cliCount = cliRuns.length;
|
const cliCount = cliRuns.length;
|
||||||
const mcpCount = mcpRuns.length;
|
const mcpCount = mcpRuns.length;
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
|
if (m.activityKind === "model") {
|
||||||
|
modelSegments += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (isReasoningOnlyAssistant(m)) {
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
reasoningSteps += 1;
|
reasoningSteps += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -110,6 +117,7 @@ function countActivity(
|
|||||||
return {
|
return {
|
||||||
reasoningSteps,
|
reasoningSteps,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
modelSegments,
|
||||||
cliCount,
|
cliCount,
|
||||||
mcpCount,
|
mcpCount,
|
||||||
fileCount: fileEdits.length,
|
fileCount: fileEdits.length,
|
||||||
@@ -131,8 +139,8 @@ interface AgentActivityClusterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows.
|
* One fold wrapping the complete middle of a turn: reasoning, model segments,
|
||||||
* Fixed max height with inner scroll and a single flat list of activity rows.
|
* tool traces, and file edits. The final assistant answer stays outside it.
|
||||||
*/
|
*/
|
||||||
export function AgentActivityCluster({
|
export function AgentActivityCluster({
|
||||||
messages,
|
messages,
|
||||||
@@ -165,6 +173,7 @@ export function AgentActivityCluster({
|
|||||||
const {
|
const {
|
||||||
reasoningSteps,
|
reasoningSteps,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
modelSegments,
|
||||||
cliCount,
|
cliCount,
|
||||||
mcpCount,
|
mcpCount,
|
||||||
fileCount,
|
fileCount,
|
||||||
@@ -186,9 +195,8 @@ export function AgentActivityCluster({
|
|||||||
? outerOpenLocal
|
? outerOpenLocal
|
||||||
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming);
|
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming);
|
||||||
|
|
||||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || modelSegments > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||||
const hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
|
const hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
|
||||||
const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
|
||||||
const durationMs = activityDurationMs(
|
const durationMs = activityDurationMs(
|
||||||
activityMessages,
|
activityMessages,
|
||||||
isTurnStreaming,
|
isTurnStreaming,
|
||||||
@@ -197,8 +205,7 @@ export function AgentActivityCluster({
|
|||||||
startedAtMs,
|
startedAtMs,
|
||||||
);
|
);
|
||||||
const activityDuration = formatActivityDuration(durationMs);
|
const activityDuration = formatActivityDuration(durationMs);
|
||||||
const thoughtLabel = hasNonReasoningActivity
|
const activityLabel = isTurnStreaming
|
||||||
? isTurnStreaming
|
|
||||||
? t("message.activityWorkingFor", {
|
? t("message.activityWorkingFor", {
|
||||||
duration: activityDuration,
|
duration: activityDuration,
|
||||||
defaultValue: "Working for {{duration}}",
|
defaultValue: "Working for {{duration}}",
|
||||||
@@ -208,17 +215,6 @@ export function AgentActivityCluster({
|
|||||||
: t("message.activityWorkedFor", {
|
: t("message.activityWorkedFor", {
|
||||||
duration: activityDuration,
|
duration: activityDuration,
|
||||||
defaultValue: "Worked for {{duration}}",
|
defaultValue: "Worked for {{duration}}",
|
||||||
})
|
|
||||||
: isTurnStreaming
|
|
||||||
? t("message.activityThinkingFor", {
|
|
||||||
duration: activityDuration,
|
|
||||||
defaultValue: "Thinking for {{duration}}",
|
|
||||||
})
|
|
||||||
: durationMs <= 0
|
|
||||||
? t("message.activityThought", { defaultValue: "Thought" })
|
|
||||||
: t("message.activityThoughtFor", {
|
|
||||||
duration: activityDuration,
|
|
||||||
defaultValue: "Thought for {{duration}}",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelActivityScrollFrame = useCallback(() => {
|
const cancelActivityScrollFrame = useCallback(() => {
|
||||||
@@ -338,7 +334,7 @@ export function AgentActivityCluster({
|
|||||||
<ThinkingReasoningShell
|
<ThinkingReasoningShell
|
||||||
active={isTurnStreaming}
|
active={isTurnStreaming}
|
||||||
expanded={outerExpanded}
|
expanded={outerExpanded}
|
||||||
label={thoughtLabel}
|
label={activityLabel}
|
||||||
viewportRef={activityScrollRef}
|
viewportRef={activityScrollRef}
|
||||||
contentRef={activityContentRef}
|
contentRef={activityContentRef}
|
||||||
fadeTop={activityScrollFade.top}
|
fadeTop={activityScrollFade.top}
|
||||||
@@ -352,6 +348,7 @@ export function AgentActivityCluster({
|
|||||||
active={isTurnStreaming}
|
active={isTurnStreaming}
|
||||||
cliAppsByName={cliAppsByName}
|
cliAppsByName={cliAppsByName}
|
||||||
mcpPresetsByName={mcpPresetsByName}
|
mcpPresetsByName={mcpPresetsByName}
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
/>
|
/>
|
||||||
{fileEdits.length ? (
|
{fileEdits.length ? (
|
||||||
<FileEditGroup
|
<FileEditGroup
|
||||||
@@ -417,15 +414,28 @@ function ActivityMessageTimeline({
|
|||||||
active,
|
active,
|
||||||
cliAppsByName,
|
cliAppsByName,
|
||||||
mcpPresetsByName,
|
mcpPresetsByName,
|
||||||
|
onOpenFilePreview,
|
||||||
}: {
|
}: {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
cliAppsByName: Map<string, CliAppInfo>;
|
cliAppsByName: Map<string, CliAppInfo>;
|
||||||
mcpPresetsByName: Map<string, McpPresetInfo>;
|
mcpPresetsByName: Map<string, McpPresetInfo>;
|
||||||
|
onOpenFilePreview?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const items: ReactNode[] = [];
|
const items: ReactNode[] = [];
|
||||||
|
|
||||||
messages.forEach((message, index) => {
|
messages.forEach((message, index) => {
|
||||||
|
if (message.activityKind === "model") {
|
||||||
|
items.push(
|
||||||
|
<ActivityModelMessage
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
active={active}
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isReasoningOnlyAssistant(message)) {
|
if (isReasoningOnlyAssistant(message)) {
|
||||||
items.push(
|
items.push(
|
||||||
<ReasoningRow
|
<ReasoningRow
|
||||||
@@ -451,6 +461,40 @@ function ActivityMessageTimeline({
|
|||||||
return <>{items}</>;
|
return <>{items}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep an intermediate assistant segment as normal Markdown. The activity
|
||||||
|
* surface owns ordering and lifecycle, not a reduced rendering mode: users
|
||||||
|
* should see the same prose, links, and code treatment before and after the
|
||||||
|
* surrounding turn is folded.
|
||||||
|
*/
|
||||||
|
function ActivityModelMessage({
|
||||||
|
message,
|
||||||
|
active,
|
||||||
|
onOpenFilePreview,
|
||||||
|
}: {
|
||||||
|
message: UIMessage;
|
||||||
|
active: boolean;
|
||||||
|
onOpenFilePreview?: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
if (!message.content.trim()) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="activity-model-message"
|
||||||
|
data-assistant-selectable={active && message.isStreaming ? undefined : "true"}
|
||||||
|
className="w-full min-w-0 py-1 text-[15px]"
|
||||||
|
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||||
|
>
|
||||||
|
<MarkdownText
|
||||||
|
streaming={active && !!message.isStreaming}
|
||||||
|
preserveStreamingLayout
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</MarkdownText>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ActivityTraceList({
|
function ActivityTraceList({
|
||||||
lines,
|
lines,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -6,37 +6,27 @@ import {
|
|||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { CircleHelp, Sparkles } from "lucide-react";
|
import { Check, CircleHelp, Sparkles } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
floatingItemClassName,
|
||||||
TooltipContent,
|
floatingItemFocusClassName,
|
||||||
TooltipProvider,
|
} from "@/components/ui/floating-surface";
|
||||||
TooltipTrigger,
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
|
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface ModelPresetOption {
|
const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]";
|
||||||
name: string;
|
const LONG_PRESS_MS = 400;
|
||||||
model?: string | null;
|
const PRESS_SLOP_PX = 8;
|
||||||
provider?: string | null;
|
const PILL_GAP_PX = 4;
|
||||||
}
|
const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
|
||||||
|
const HANDOFF_THRESHOLD = 0.56;
|
||||||
interface ModelPresetBadgeProps {
|
const DOCK_MAX_SCALE = 1.08;
|
||||||
label: string;
|
const DOCK_RADIUS = 1.5;
|
||||||
modelDetail?: string | null;
|
const SETTLE_MS = 200;
|
||||||
modelPreset?: string | null;
|
|
||||||
modelPresets?: ModelPresetOption[];
|
|
||||||
onPresetChange?: (name: string) => void;
|
|
||||||
provider?: string | null;
|
|
||||||
providerLabel?: string | null;
|
|
||||||
needsSetup?: boolean;
|
|
||||||
fallbackModelName?: string | null;
|
|
||||||
isHero: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresetGesture {
|
interface PresetGesture {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -55,15 +45,6 @@ interface PresetMotion {
|
|||||||
settling: boolean;
|
settling: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LONG_PRESS_MS = 400;
|
|
||||||
const PRESS_SLOP_PX = 8;
|
|
||||||
const PILL_GAP_PX = 4;
|
|
||||||
const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
|
|
||||||
const HANDOFF_THRESHOLD = 0.56;
|
|
||||||
const DOCK_MAX_SCALE = 1.08;
|
|
||||||
const DOCK_RADIUS = 1.5;
|
|
||||||
const SETTLE_MS = 180;
|
|
||||||
|
|
||||||
function wrapIndex(index: number, length: number): number {
|
function wrapIndex(index: number, length: number): number {
|
||||||
return ((index % length) + length) % length;
|
return ((index % length) + length) % length;
|
||||||
}
|
}
|
||||||
@@ -86,12 +67,40 @@ function preventTouchScroll(event: TouchEvent) {
|
|||||||
if (event.cancelable) event.preventDefault();
|
if (event.cancelable) event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactModelName(model?: string | null): string | null {
|
||||||
|
const value = model?.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
return value.split("/").at(-1) || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelPresetOption {
|
||||||
|
name: string;
|
||||||
|
model?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelPresetBadgeProps {
|
||||||
|
label: string;
|
||||||
|
modelDetail?: string | null;
|
||||||
|
modelPreset?: string | null;
|
||||||
|
modelPresets?: ModelPresetOption[];
|
||||||
|
onPresetChange?: (name: string) => void;
|
||||||
|
onRequestComposerFocus?: () => void;
|
||||||
|
provider?: string | null;
|
||||||
|
providerLabel?: string | null;
|
||||||
|
needsSetup?: boolean;
|
||||||
|
fallbackModelName?: string | null;
|
||||||
|
isHero: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelPresetBadge({
|
export function ModelPresetBadge({
|
||||||
label,
|
label,
|
||||||
modelDetail,
|
modelDetail,
|
||||||
modelPreset,
|
modelPreset,
|
||||||
modelPresets = [],
|
modelPresets = [],
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
|
onRequestComposerFocus,
|
||||||
provider,
|
provider,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
needsSetup = false,
|
needsSetup = false,
|
||||||
@@ -99,6 +108,12 @@ export function ModelPresetBadge({
|
|||||||
isHero,
|
isHero,
|
||||||
onClick,
|
onClick,
|
||||||
}: ModelPresetBadgeProps) {
|
}: ModelPresetBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [motion, setMotion] = useState<PresetMotion | null>(null);
|
||||||
|
const [motionWidth, setMotionWidth] = useState<number | null>(null);
|
||||||
|
const gestureRef = useRef<PresetGesture | null>(null);
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
const activeName = modelPreset?.trim() || "";
|
const activeName = modelPreset?.trim() || "";
|
||||||
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
|
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
|
||||||
const activePreset: ModelPresetOption = {
|
const activePreset: ModelPresetOption = {
|
||||||
@@ -107,97 +122,78 @@ export function ModelPresetBadge({
|
|||||||
model: modelDetail ?? modelPresets[listedIndex]?.model,
|
model: modelDetail ?? modelPresets[listedIndex]?.model,
|
||||||
provider: provider || modelPresets[listedIndex]?.provider,
|
provider: provider || modelPresets[listedIndex]?.provider,
|
||||||
};
|
};
|
||||||
|
const fallbackPreset = fallbackModelName
|
||||||
|
? modelPresets.find((preset) => preset.model?.trim() === fallbackModelName.trim())
|
||||||
|
: undefined;
|
||||||
|
const fallbackDisplayLabel = fallbackPreset?.name
|
||||||
|
|| fallbackModelName?.trim().split(/[/:]/).pop()
|
||||||
|
|| null;
|
||||||
|
const displayLabel = fallbackDisplayLabel || label;
|
||||||
|
const displayModelDetail = fallbackPreset
|
||||||
|
? fallbackPreset.model
|
||||||
|
: fallbackModelName
|
||||||
|
? null
|
||||||
|
: modelDetail;
|
||||||
|
const displayProvider = fallbackPreset?.provider
|
||||||
|
|| (fallbackModelName ? inferProviderFromModelName(fallbackModelName) : provider);
|
||||||
const presets = !activeName
|
const presets = !activeName
|
||||||
? modelPresets
|
? modelPresets
|
||||||
: listedIndex < 0
|
: listedIndex < 0
|
||||||
? [activePreset, ...modelPresets]
|
? [activePreset, ...modelPresets]
|
||||||
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
|
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
|
||||||
const interactive = Boolean(onClick);
|
const opensSetup = Boolean(onClick);
|
||||||
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
|
const canSwitch = !opensSetup && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
|
||||||
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
|
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
|
||||||
const pillHeight = isHero ? 32 : 36;
|
const pillHeight = isHero ? 32 : 36;
|
||||||
const pillStride = pillHeight + PILL_GAP_PX;
|
const pillStride = pillHeight + PILL_GAP_PX;
|
||||||
const [motion, setMotion] = useState<PresetMotion | null>(null);
|
const switchModelLabel = t("thread.composer.switchModel", {
|
||||||
const gestureRef = useRef<PresetGesture | null>(null);
|
defaultValue: "Switch model for this chat",
|
||||||
const clickAnimationFrameRef = useRef<number | null>(null);
|
});
|
||||||
const suppressClickRef = useRef(false);
|
|
||||||
const suppressClickTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
function clearGesture() {
|
const selectPreset = (name: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
if (name !== activeName) onPresetChange?.(name);
|
||||||
|
requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearGesture = () => {
|
||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
if (gesture?.timer) clearTimeout(gesture.timer);
|
if (gesture?.timer) clearTimeout(gesture.timer);
|
||||||
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
|
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
|
||||||
gestureRef.current = null;
|
gestureRef.current = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const clearMotion = () => {
|
||||||
|
setMotion(null);
|
||||||
|
setMotionWidth(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canSwitch) {
|
if (!canSwitch) {
|
||||||
clearGesture();
|
clearGesture();
|
||||||
setMotion(null);
|
clearMotion();
|
||||||
}
|
}
|
||||||
return () => {
|
return clearGesture;
|
||||||
clearGesture();
|
|
||||||
if (clickAnimationFrameRef.current !== null) {
|
|
||||||
window.cancelAnimationFrame(clickAnimationFrameRef.current);
|
|
||||||
clickAnimationFrameRef.current = null;
|
|
||||||
}
|
|
||||||
if (suppressClickTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(suppressClickTimerRef.current);
|
|
||||||
suppressClickTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [canSwitch]);
|
}, [canSwitch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!motion?.settling) return;
|
if (!motion?.settling) return;
|
||||||
const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80);
|
const timer = setTimeout(clearMotion, SETTLE_MS + 80);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [motion?.settling]);
|
}, [motion?.settling]);
|
||||||
|
|
||||||
function updateMotion(gesture: PresetGesture, clientY: number) {
|
const updateMotion = (gesture: PresetGesture, clientY: number) => {
|
||||||
const raw = -(clientY - gesture.startY) / pillStride;
|
const raw = -(clientY - gesture.startY) / pillStride;
|
||||||
gesture.step = stepWithHysteresis(raw, gesture.step);
|
gesture.step = stepWithHysteresis(raw, gesture.step);
|
||||||
setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false });
|
setMotion({
|
||||||
}
|
index: gesture.baseIndex + gesture.step,
|
||||||
|
remainder: raw - gesture.step,
|
||||||
function suppressFollowingClick() {
|
settling: false,
|
||||||
suppressClickRef.current = true;
|
|
||||||
if (suppressClickTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(suppressClickTimerRef.current);
|
|
||||||
}
|
|
||||||
suppressClickTimerRef.current = window.setTimeout(() => {
|
|
||||||
suppressClickRef.current = false;
|
|
||||||
suppressClickTimerRef.current = null;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleToNextPreset() {
|
|
||||||
if (!canSwitch || motion) return;
|
|
||||||
const nextVirtualIndex = currentIndex + 1;
|
|
||||||
const next = presets[wrapIndex(nextVirtualIndex, presets.length)];
|
|
||||||
if (!next || next.name === activeName) return;
|
|
||||||
|
|
||||||
// Mount the same five-pill track one step before its destination, then
|
|
||||||
// settle it into place so clicks share the drag interaction's motion.
|
|
||||||
setMotion({ index: nextVirtualIndex, remainder: -1, settling: false });
|
|
||||||
clickAnimationFrameRef.current = window.requestAnimationFrame(() => {
|
|
||||||
clickAnimationFrameRef.current = null;
|
|
||||||
setMotion({ index: nextVirtualIndex, remainder: 0, settling: true });
|
|
||||||
onPresetChange?.(next.name);
|
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleClick() {
|
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||||
if (interactive) {
|
if (!canSwitch || gestureRef.current || motion) return;
|
||||||
onClick?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (suppressClickRef.current) return;
|
|
||||||
cycleToNextPreset();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent<HTMLElement>) {
|
|
||||||
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
|
|
||||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||||
const gesture: PresetGesture = {
|
const gesture: PresetGesture = {
|
||||||
active: false,
|
active: false,
|
||||||
@@ -212,16 +208,19 @@ export function ModelPresetBadge({
|
|||||||
gesture.timer = setTimeout(() => {
|
gesture.timer = setTimeout(() => {
|
||||||
if (gestureRef.current !== gesture) return;
|
if (gestureRef.current !== gesture) return;
|
||||||
gesture.active = true;
|
gesture.active = true;
|
||||||
|
setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null);
|
||||||
updateMotion(gesture, gesture.latestY);
|
updateMotion(gesture, gesture.latestY);
|
||||||
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
|
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
|
||||||
try {
|
try {
|
||||||
gesture.target.setPointerCapture(gesture.pointerId);
|
gesture.target.setPointerCapture(gesture.pointerId);
|
||||||
} catch { /* The pointer may already have ended. */ }
|
} catch {
|
||||||
|
// The pointer may already have ended.
|
||||||
|
}
|
||||||
}, LONG_PRESS_MS);
|
}, LONG_PRESS_MS);
|
||||||
gestureRef.current = gesture;
|
gestureRef.current = gesture;
|
||||||
}
|
};
|
||||||
|
|
||||||
function handlePointerMove(event: PointerEvent<HTMLElement>) {
|
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
gesture.latestY = event.clientY;
|
gesture.latestY = event.clientY;
|
||||||
@@ -231,27 +230,26 @@ export function ModelPresetBadge({
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
updateMotion(gesture, event.clientY);
|
updateMotion(gesture, event.clientY);
|
||||||
}
|
};
|
||||||
|
|
||||||
function finishGesture(event: PointerEvent<HTMLElement>, commit: boolean) {
|
const finishGesture = (event: PointerEvent<HTMLButtonElement>, commit: boolean) => {
|
||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
clearGesture();
|
clearGesture();
|
||||||
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
|
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
|
||||||
event.currentTarget.releasePointerCapture?.(gesture.pointerId);
|
event.currentTarget.releasePointerCapture?.(gesture.pointerId);
|
||||||
}
|
}
|
||||||
if (gesture.active) suppressFollowingClick();
|
|
||||||
if (!commit || !gesture.active) {
|
if (!commit || !gesture.active) {
|
||||||
setMotion(null);
|
clearMotion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
suppressClickRef.current = true;
|
||||||
const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)];
|
const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)];
|
||||||
setMotion((current) => current && { ...current, remainder: 0, settling: true });
|
setMotion((current) => current && { ...current, remainder: 0, settling: true });
|
||||||
if (selected && selected.name !== activeName) onPresetChange?.(selected.name);
|
if (selected && selected.name !== activeName) selectPreset(selected.name);
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
|
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
if (!canSwitch) return;
|
|
||||||
const targetByKey: Record<string, number> = {
|
const targetByKey: Record<string, number> = {
|
||||||
ArrowUp: currentIndex - 1,
|
ArrowUp: currentIndex - 1,
|
||||||
ArrowDown: currentIndex + 1,
|
ArrowDown: currentIndex + 1,
|
||||||
@@ -262,31 +260,54 @@ export function ModelPresetBadge({
|
|||||||
if (target === undefined) return;
|
if (target === undefined) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const next = presets[wrapIndex(target, presets.length)];
|
const next = presets[wrapIndex(target, presets.length)];
|
||||||
if (next?.name !== activeName) onPresetChange?.(next.name);
|
if (next?.name !== activeName) selectPreset(next.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pill = (
|
||||||
|
<PresetPill
|
||||||
|
label={displayLabel}
|
||||||
|
modelDetail={displayModelDetail}
|
||||||
|
provider={displayProvider}
|
||||||
|
providerLabel={fallbackModelName ? null : providerLabel}
|
||||||
|
needsSetup={needsSetup}
|
||||||
|
fallbackModelName={fallbackModelName}
|
||||||
|
fallbackFromLabel={fallbackModelName ? label : null}
|
||||||
|
isHero={isHero}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canSwitch) {
|
||||||
|
const Container = opensSetup ? "button" : "span";
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
aria-label={fallbackModelName ? `${displayLabel} (fallback from ${label})` : label}
|
||||||
|
type={opensSetup ? "button" : undefined}
|
||||||
|
onClick={opensSetup ? onClick : undefined}
|
||||||
|
className={cn(
|
||||||
|
"thread-composer-model-badge group inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] appearance-none border-0 bg-transparent p-0 shadow-none",
|
||||||
|
opensSetup && "cursor-pointer focus-visible:outline-none",
|
||||||
|
isHero ? "h-8" : "h-9",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pill}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length);
|
return (
|
||||||
const previewPreset = presets[previewIndex];
|
<Popover
|
||||||
const Container = interactive || canSwitch ? "button" : "span";
|
open={open}
|
||||||
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0;
|
onOpenChange={(nextOpen) => {
|
||||||
const tooltipLabel = fallbackModelName
|
setOpen(nextOpen);
|
||||||
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||||
|
}}
|
||||||
const badge = (
|
>
|
||||||
<Container
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
data-switching={motion ? "true" : undefined}
|
data-switching={motion ? "true" : undefined}
|
||||||
data-settling={motion?.settling ? "true" : undefined}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-orientation={canSwitch ? "vertical" : undefined}
|
aria-expanded={open}
|
||||||
aria-valuemax={canSwitch ? presets.length - 1 : undefined}
|
|
||||||
aria-valuemin={canSwitch ? 0 : undefined}
|
|
||||||
aria-valuenow={canSwitch ? previewIndex : undefined}
|
|
||||||
aria-valuetext={canSwitch ? previewPreset?.name || label : undefined}
|
|
||||||
role={canSwitch ? "spinbutton" : undefined}
|
|
||||||
tabIndex={!interactive && !canSwitch ? 0 : undefined}
|
|
||||||
type={interactive || canSwitch ? "button" : undefined}
|
|
||||||
onClick={interactive || canSwitch ? handleClick : undefined}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={(event) => {
|
onPointerLeave={(event) => {
|
||||||
@@ -300,29 +321,33 @@ export function ModelPresetBadge({
|
|||||||
if (gestureRef.current?.active) event.preventDefault();
|
if (gestureRef.current?.active) event.preventDefault();
|
||||||
}}
|
}}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
style={{ touchAction: canSwitch ? "manipulation" : undefined }}
|
onKeyDown={handleKeyDown}
|
||||||
|
onClickCapture={(event) => {
|
||||||
|
if (!suppressClickRef.current) return;
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
touchAction: "manipulation",
|
||||||
|
width: motionWidth ? `${motionWidth}px` : undefined,
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none",
|
"thread-composer-model-badge group relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] cursor-pointer appearance-none border-0 bg-transparent p-0 shadow-none focus-visible:outline-none",
|
||||||
interactive && "cursor-pointer",
|
|
||||||
canSwitch && "cursor-grab select-none focus-visible:outline-none",
|
|
||||||
motion && "z-10 cursor-grabbing",
|
motion && "z-10 cursor-grabbing",
|
||||||
|
!motion && "cursor-grab",
|
||||||
isHero ? "h-8" : "h-9",
|
isHero ? "h-8" : "h-9",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PresetPill
|
|
||||||
className={motion && "invisible"}
|
|
||||||
label={label}
|
|
||||||
modelDetail={modelDetail}
|
|
||||||
provider={provider}
|
|
||||||
needsSetup={needsSetup}
|
|
||||||
fallbackModelName={fallbackModelName}
|
|
||||||
isHero={isHero}
|
|
||||||
/>
|
|
||||||
{motion ? (
|
{motion ? (
|
||||||
|
<>
|
||||||
|
<span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
|
||||||
|
{pill}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
data-testid="composer-model-pill-viewport"
|
data-testid="composer-model-pill-viewport"
|
||||||
className={cn(
|
className={cn(
|
||||||
"composer-model-pill-viewport pointer-events-none absolute right-0 w-max max-w-[calc(44vw+0.5rem)] overflow-hidden bg-transparent pl-2 sm:max-w-[18.5rem]",
|
"composer-model-pill-viewport pointer-events-none absolute -left-2 right-0 overflow-hidden bg-transparent",
|
||||||
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
|
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -330,73 +355,134 @@ export function ModelPresetBadge({
|
|||||||
<span
|
<span
|
||||||
data-testid="composer-model-pill-track"
|
data-testid="composer-model-pill-track"
|
||||||
data-settling={motion.settling ? "true" : undefined}
|
data-settling={motion.settling ? "true" : undefined}
|
||||||
className="composer-model-pill-track ml-auto flex w-max max-w-full flex-col items-end gap-1 will-change-transform"
|
className="composer-model-pill-track ml-auto flex w-[calc(100%-0.5rem)] flex-col items-end gap-1 will-change-transform"
|
||||||
onTransitionEnd={(event) => {
|
onTransitionEnd={(event) => {
|
||||||
if (motion.settling && event.currentTarget === event.target) setMotion(null);
|
if (motion.settling && event.currentTarget === event.target) clearMotion();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
paddingTop: isHero ? "10px" : "12px",
|
paddingTop: isHero ? "10px" : "12px",
|
||||||
transform: `translate3d(0, ${trackOffset}px, 0)`,
|
transform: `translate3d(0, ${-pillStride * (2 + motion.remainder)}px, 0)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PILL_OFFSETS.map((offset) => {
|
{PILL_OFFSETS.map((offset) => {
|
||||||
const virtualIndex = motion.index + offset;
|
const preset = presets[wrapIndex(motion.index + offset, presets.length)];
|
||||||
const preset = presets[wrapIndex(virtualIndex, presets.length)];
|
|
||||||
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
|
|
||||||
return (
|
return (
|
||||||
<PresetPill
|
<PresetPill
|
||||||
key={virtualIndex}
|
key={motion.index + offset}
|
||||||
label={preset.name}
|
label={preset.name}
|
||||||
modelDetail={preset.model}
|
modelDetail={preset.model}
|
||||||
provider={preset.provider}
|
provider={preset.provider}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
scale={scale}
|
scale={motion.settling ? 1 : dockScale(offset - motion.remainder)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</>
|
||||||
</Container>
|
) : pill}
|
||||||
);
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
if (!tooltipLabel) return badge;
|
<PopoverContent
|
||||||
return (
|
align="end"
|
||||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="top"
|
side="top"
|
||||||
align="center"
|
sideOffset={10}
|
||||||
sideOffset={8}
|
role="dialog"
|
||||||
collisionPadding={12}
|
aria-label={switchModelLabel}
|
||||||
className="max-w-[min(24rem,calc(100vw-2rem))] break-all"
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const content = event.currentTarget;
|
||||||
|
if (!(content instanceof HTMLElement)) return;
|
||||||
|
const selected = content.querySelector<HTMLElement>(
|
||||||
|
'[role="option"][aria-selected="true"]',
|
||||||
|
);
|
||||||
|
selected?.focus();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
pickerWidthClassName,
|
||||||
|
"origin-[var(--radix-popover-content-transform-origin)] p-1.5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1 data-[state=open]:slide-in-from-bottom-1 duration-200 ease-out motion-reduce:animate-none",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tooltipLabel}
|
<div
|
||||||
</TooltipContent>
|
role="listbox"
|
||||||
</Tooltip>
|
aria-label={switchModelLabel}
|
||||||
</TooltipProvider>
|
className="max-h-[min(16rem,var(--radix-popover-content-available-height))] overflow-y-auto py-1 scrollbar-thin scrollbar-track-transparent"
|
||||||
|
>
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<PresetOption
|
||||||
|
key={preset.name}
|
||||||
|
preset={preset}
|
||||||
|
selected={preset.name === activeName}
|
||||||
|
onSelect={selectPreset}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetOption({
|
||||||
|
preset,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
preset: ModelPresetOption;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const detail = compactModelName(preset.model);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-label={preset.name}
|
||||||
|
aria-selected={selected}
|
||||||
|
onClick={() => onSelect(preset.name)}
|
||||||
|
className={cn(
|
||||||
|
floatingItemClassName,
|
||||||
|
floatingItemFocusClassName,
|
||||||
|
"flex min-h-9 w-full cursor-pointer gap-2.5 px-2.5 py-1.5 text-left hover:bg-muted/55",
|
||||||
|
selected && "bg-muted/55 text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PresetProviderIcon
|
||||||
|
label={preset.name}
|
||||||
|
modelDetail={detail}
|
||||||
|
provider={preset.provider}
|
||||||
|
isHero={false}
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-1 items-baseline gap-1.5 overflow-hidden whitespace-nowrap">
|
||||||
|
<span className="shrink-0 text-[13px] font-medium text-foreground">{preset.name}</span>
|
||||||
|
{detail && detail !== preset.name ? (
|
||||||
|
<span className="truncate text-[12px] text-muted-foreground">{detail}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{selected ? <Check className="h-4 w-4 shrink-0 text-foreground/80" aria-hidden /> : null}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PresetPill({
|
function PresetPill({
|
||||||
className,
|
|
||||||
label,
|
label,
|
||||||
modelDetail,
|
modelDetail,
|
||||||
provider,
|
provider,
|
||||||
|
providerLabel,
|
||||||
needsSetup = false,
|
needsSetup = false,
|
||||||
fallbackModelName,
|
fallbackModelName,
|
||||||
|
fallbackFromLabel,
|
||||||
isHero,
|
isHero,
|
||||||
offset,
|
offset,
|
||||||
scale,
|
scale,
|
||||||
}: {
|
}: {
|
||||||
className?: string | false | null;
|
|
||||||
label: string;
|
label: string;
|
||||||
modelDetail?: string | null;
|
modelDetail?: string | null;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
|
providerLabel?: string | null;
|
||||||
needsSetup?: boolean;
|
needsSetup?: boolean;
|
||||||
fallbackModelName?: string | null;
|
fallbackModelName?: string | null;
|
||||||
|
fallbackFromLabel?: string | null;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
scale?: number;
|
scale?: number;
|
||||||
@@ -406,13 +492,10 @@ function PresetPill({
|
|||||||
const inferredProvider = needsSetup
|
const inferredProvider = needsSetup
|
||||||
? null
|
? null
|
||||||
: provider || inferProviderFromModelName(modelDetail || label);
|
: provider || inferProviderFromModelName(modelDetail || label);
|
||||||
const brand = providerBrand(inferredProvider);
|
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
||||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
const fallbackTitle = fallbackModelName
|
||||||
const logoTestId = offset !== undefined
|
? `${fallbackFromLabel || label} · using ${fallbackModelName}`
|
||||||
? undefined
|
: title;
|
||||||
: needsSetup
|
|
||||||
? "composer-model-setup-icon"
|
|
||||||
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const node = labelRef.current;
|
const node = labelRef.current;
|
||||||
@@ -428,14 +511,14 @@ function PresetPill({
|
|||||||
<span
|
<span
|
||||||
data-fallback={fallbackModelName ? "true" : undefined}
|
data-fallback={fallbackModelName ? "true" : undefined}
|
||||||
data-preset-offset={offset}
|
data-preset-offset={offset}
|
||||||
|
title={fallbackTitle || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
"composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||||
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
"w-fit",
|
||||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45",
|
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45",
|
||||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||||
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
|
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
|
||||||
offset !== undefined && "composer-model-pill-dock",
|
offset !== undefined && "composer-model-pill-dock",
|
||||||
className,
|
|
||||||
)}
|
)}
|
||||||
style={scale === undefined ? undefined : {
|
style={scale === undefined ? undefined : {
|
||||||
height: `${isHero ? 32 : 36}px`,
|
height: `${isHero ? 32 : 36}px`,
|
||||||
@@ -443,17 +526,55 @@ function PresetPill({
|
|||||||
zIndex: Math.round(scale * 100),
|
zIndex: Math.round(scale * 100),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<PresetProviderIcon
|
||||||
|
label={label}
|
||||||
|
modelDetail={modelDetail}
|
||||||
|
provider={inferredProvider}
|
||||||
|
needsSetup={needsSetup}
|
||||||
|
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
|
||||||
|
isHero={isHero}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
data-testid={logoTestId}
|
ref={labelRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid shrink-0 place-items-center overflow-hidden",
|
"thread-composer-model-label min-w-0 overflow-hidden whitespace-nowrap text-center",
|
||||||
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background",
|
labelOverflows && "thread-composer-model-label-fade",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetProviderIcon({
|
||||||
|
label,
|
||||||
|
modelDetail,
|
||||||
|
provider,
|
||||||
|
needsSetup = false,
|
||||||
|
testId,
|
||||||
|
isHero,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
modelDetail?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
|
needsSetup?: boolean;
|
||||||
|
testId?: string;
|
||||||
|
isHero: boolean;
|
||||||
|
}) {
|
||||||
|
const inferredProvider = needsSetup
|
||||||
|
? null
|
||||||
|
: provider || inferProviderFromModelName(modelDetail || label);
|
||||||
|
const brand = providerBrand(inferredProvider);
|
||||||
|
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid={testId}
|
||||||
|
className={cn(
|
||||||
|
"grid shrink-0 place-items-center",
|
||||||
|
needsSetup && "text-amber-800 dark:text-amber-200",
|
||||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
|
||||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
|
||||||
}}
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{needsSetup ? (
|
{needsSetup ? (
|
||||||
@@ -465,7 +586,7 @@ function PresetPill({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
className={cn("object-contain", isHero ? "h-3.5 w-3.5" : "h-[18px] w-[18px]")}
|
||||||
onLoad={onLogoLoad}
|
onLoad={onLogoLoad}
|
||||||
onError={onLogoError}
|
onError={onLogoError}
|
||||||
/>
|
/>
|
||||||
@@ -483,15 +604,5 @@ function PresetPill({
|
|||||||
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
ref={labelRef}
|
|
||||||
className={cn(
|
|
||||||
"thread-composer-model-label min-w-0 overflow-hidden whitespace-nowrap text-center",
|
|
||||||
labelOverflows && "thread-composer-model-label-fade",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { RecoveryState } from "@/lib/types";
|
||||||
|
|
||||||
|
interface RecoveryNoticeProps {
|
||||||
|
state: RecoveryState;
|
||||||
|
onContinue: () => Promise<void>;
|
||||||
|
onDismiss: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecoveryNotice({ state, onContinue, onDismiss }: RecoveryNoticeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [pending, setPending] = useState<"continue" | "dismiss" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hiddenRecoveryId, setHiddenRecoveryId] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
// A continuation can be interrupted again with the same recovery ID.
|
||||||
|
// Reveal the decision surface when the server returns to a waiting state.
|
||||||
|
if (state.status === "awaiting_user" || state.status === "failed") {
|
||||||
|
setHiddenRecoveryId(null);
|
||||||
|
}
|
||||||
|
}, [state.recovery_id, state.status]);
|
||||||
|
if (state.status === "recovered" || hiddenRecoveryId === state.recovery_id) return null;
|
||||||
|
|
||||||
|
const waiting = state.status === "awaiting_user" || state.status === "failed";
|
||||||
|
const contextUnavailable = state.can_continue === false;
|
||||||
|
const title = state.status === "failed"
|
||||||
|
? t("recovery.failed", { defaultValue: "Task recovery failed" })
|
||||||
|
: waiting
|
||||||
|
? t("recovery.interrupted", { defaultValue: "Task interrupted" })
|
||||||
|
: t("recovery.resuming", { defaultValue: "Restoring interrupted task…" });
|
||||||
|
const detail = state.status === "failed" || contextUnavailable
|
||||||
|
? t("recovery.failedHelp", {
|
||||||
|
defaultValue: "The saved task could not be restored safely. Review it before continuing.",
|
||||||
|
})
|
||||||
|
: waiting
|
||||||
|
? t("recovery.review", { defaultValue: "Review the task before continuing. Tools will not be replayed automatically." })
|
||||||
|
: t("recovery.safeResume", { defaultValue: "Continuing from saved conversation context." });
|
||||||
|
const run = (action: "continue" | "dismiss") => {
|
||||||
|
setPending(action);
|
||||||
|
setError(null);
|
||||||
|
// ``resuming`` is an internal transition, not another task for the user
|
||||||
|
// to monitor. Hide the notice optimistically and only bring it back if
|
||||||
|
// the explicit action is rejected.
|
||||||
|
if (action === "continue") setHiddenRecoveryId(state.recovery_id);
|
||||||
|
const operation = action === "continue" ? onContinue() : onDismiss();
|
||||||
|
void operation.catch(() => {
|
||||||
|
if (action === "continue") setHiddenRecoveryId(null);
|
||||||
|
setError(t("recovery.actionFailed", { defaultValue: "Recovery action failed. Try again." }));
|
||||||
|
}).finally(() => setPending(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={waiting ? "alert" : "status"}
|
||||||
|
aria-live={waiting ? "assertive" : "polite"}
|
||||||
|
aria-busy={state.status === "resuming"}
|
||||||
|
data-recovery-status={state.status}
|
||||||
|
className="mx-auto mb-2 flex w-full max-w-[49.5rem] items-center gap-3 rounded-control border border-border/70 bg-muted/35 px-3 py-2 text-sm transition-[background-color,border-color,opacity,transform] duration-200 ease-out motion-reduce:transition-none animate-in fade-in-0 slide-in-from-bottom-1 duration-200 motion-reduce:animate-none"
|
||||||
|
>
|
||||||
|
{waiting ? (
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className={cn(
|
||||||
|
"mt-0.5 text-xs",
|
||||||
|
error ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{error ?? detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{waiting ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => run("dismiss")}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t("recovery.dismiss", { defaultValue: "Dismiss" })}
|
||||||
|
</Button>
|
||||||
|
{!contextUnavailable ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => run("continue")}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t("recovery.continue", { defaultValue: "Continue" })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ import {
|
|||||||
readDraggedSession,
|
readDraggedSession,
|
||||||
} from "@/lib/session-drag";
|
} from "@/lib/session-drag";
|
||||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
|
import { formatCompactTokenCount } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||||
@@ -178,6 +179,107 @@ function getVoiceShortcutLabel(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComposerContextUsage {
|
||||||
|
contextTokens: number;
|
||||||
|
contextWindowTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerContextBadge({ usage }: { usage: ComposerContextUsage | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!usage || !Number.isFinite(usage.contextTokens) || usage.contextTokens < 0) return null;
|
||||||
|
|
||||||
|
const context = formatCompactTokenCount(usage.contextTokens);
|
||||||
|
const contextWindow = typeof usage.contextWindowTokens === "number"
|
||||||
|
&& Number.isFinite(usage.contextWindowTokens)
|
||||||
|
&& usage.contextWindowTokens > 0
|
||||||
|
? usage.contextWindowTokens
|
||||||
|
: null;
|
||||||
|
const capacity = contextWindow !== null
|
||||||
|
? ` / ${formatCompactTokenCount(contextWindow)}`
|
||||||
|
: "";
|
||||||
|
// The meter is meaningful only with a known capacity. Do not turn an
|
||||||
|
// otherwise unknown total into a second, text-heavy control beside the
|
||||||
|
// model picker.
|
||||||
|
if (contextWindow === null) return null;
|
||||||
|
const percentage = Math.min(100, Math.round(usage.contextTokens / contextWindow * 100));
|
||||||
|
const status = percentage >= 90
|
||||||
|
? "critical"
|
||||||
|
: percentage >= 75
|
||||||
|
? "caution"
|
||||||
|
: "normal";
|
||||||
|
const contextDescription = t("thread.composer.context.tooltip", {
|
||||||
|
defaultValue: "Context · {{tokens}}{{capacity}}",
|
||||||
|
tokens: context,
|
||||||
|
capacity,
|
||||||
|
});
|
||||||
|
const meterDescription = t("thread.composer.context.meterDescription", {
|
||||||
|
defaultValue: "{{context}}. {{percent}}% used.",
|
||||||
|
context: contextDescription,
|
||||||
|
percent: percentage,
|
||||||
|
});
|
||||||
|
const ringCircumference = 2 * Math.PI * 6;
|
||||||
|
const ringLength = ringCircumference * percentage / 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={300} skipDelayDuration={80}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="composer-context-usage"
|
||||||
|
aria-label={meterDescription}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||||
|
"text-muted-foreground/75 transition-colors hover:text-foreground/85",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"size-[15px] shrink-0 -rotate-90",
|
||||||
|
status === "critical" && "text-destructive",
|
||||||
|
status === "caution" && "text-amber-600 dark:text-amber-400",
|
||||||
|
status === "normal" && "text-muted-foreground/75",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
className="opacity-20"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${ringLength} ${ringCircumference}`}
|
||||||
|
data-testid="composer-context-meter"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
align="center"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-fit max-w-[calc(100vw-2rem)] rounded-full border-border/70 px-2.5 py-1 text-xs font-medium shadow-[0_8px_24px_rgba(15,23,42,0.13)]"
|
||||||
|
>
|
||||||
|
<span className="whitespace-nowrap tabular-nums">{contextDescription}</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ThreadComposerProps {
|
interface ThreadComposerProps {
|
||||||
onSend: (
|
onSend: (
|
||||||
content: string,
|
content: string,
|
||||||
@@ -198,6 +300,7 @@ interface ThreadComposerProps {
|
|||||||
modelNeedsSetup?: boolean;
|
modelNeedsSetup?: boolean;
|
||||||
fallbackModelName?: string | null;
|
fallbackModelName?: string | null;
|
||||||
onModelBadgeClick?: () => void;
|
onModelBadgeClick?: () => void;
|
||||||
|
contextUsage?: ComposerContextUsage | null;
|
||||||
variant?: "thread" | "hero";
|
variant?: "thread" | "hero";
|
||||||
slashCommands?: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
@@ -895,6 +998,7 @@ export function ThreadComposer({
|
|||||||
modelNeedsSetup = false,
|
modelNeedsSetup = false,
|
||||||
fallbackModelName = null,
|
fallbackModelName = null,
|
||||||
onModelBadgeClick,
|
onModelBadgeClick,
|
||||||
|
contextUsage = null,
|
||||||
variant = "thread",
|
variant = "thread",
|
||||||
slashCommands = [],
|
slashCommands = [],
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
@@ -2435,6 +2539,7 @@ export function ThreadComposer({
|
|||||||
modelPreset={modelPreset}
|
modelPreset={modelPreset}
|
||||||
modelPresets={modelPresets}
|
modelPresets={modelPresets}
|
||||||
onPresetChange={onModelPresetChange}
|
onPresetChange={onModelPresetChange}
|
||||||
|
onRequestComposerFocus={() => textareaRef.current?.focus()}
|
||||||
provider={modelProvider}
|
provider={modelProvider}
|
||||||
providerLabel={modelProviderLabel}
|
providerLabel={modelProviderLabel}
|
||||||
needsSetup={modelNeedsSetup}
|
needsSetup={modelNeedsSetup}
|
||||||
@@ -2443,6 +2548,7 @@ export function ThreadComposer({
|
|||||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
|
||||||
{showVoiceButton ? (
|
{showVoiceButton ? (
|
||||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadMessagesProps {
|
interface ThreadMessagesProps {
|
||||||
@@ -29,10 +29,9 @@ export type DisplayUnit = TurnUnit;
|
|||||||
export function buildDisplayUnits(
|
export function buildDisplayUnits(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
activeTurnId: string | null = null,
|
||||||
): DisplayUnit[] {
|
): DisplayUnit[] {
|
||||||
return normalizeActivityTimeline(messages, {
|
return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
|
||||||
preserveTrailingActivity: isStreaming,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
||||||
@@ -69,7 +68,10 @@ export function ThreadMessages({
|
|||||||
}: ThreadMessagesProps) {
|
}: ThreadMessagesProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const messageListRef = useRef<HTMLDivElement>(null);
|
const messageListRef = useRef<HTMLDivElement>(null);
|
||||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
const units = useMemo(
|
||||||
|
() => buildDisplayUnits(messages, isStreaming, activeTurnId),
|
||||||
|
[activeTurnId, isStreaming, messages],
|
||||||
|
);
|
||||||
const forkBoundaryAfterUnitIndex = useMemo(
|
const forkBoundaryAfterUnitIndex = useMemo(
|
||||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||||
[forkBoundaryMessageCount, units],
|
[forkBoundaryMessageCount, units],
|
||||||
@@ -353,11 +355,15 @@ function activeTurnStartIndex(units: DisplayUnit[], activeTurnId: string | null)
|
|||||||
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
||||||
if (previous.type !== next.type) return false;
|
if (previous.type !== next.type) return false;
|
||||||
if (previous.type === "message" && next.type === "message") {
|
if (previous.type === "message" && next.type === "message") {
|
||||||
return shallowMessageEqual(previous.message, next.message);
|
return (
|
||||||
|
previous.sourceMessageCount === next.sourceMessageCount
|
||||||
|
&& shallowMessageEqual(previous.message, next.message)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (previous.type !== "activity" || next.type !== "activity") return false;
|
if (previous.type !== "activity" || next.type !== "activity") return false;
|
||||||
return (
|
return (
|
||||||
previous.turnLatencyMs === next.turnLatencyMs
|
previous.sourceMessageCount === next.sourceMessageCount
|
||||||
|
&& previous.turnLatencyMs === next.turnLatencyMs
|
||||||
&& previous.startedAtMs === next.startedAtMs
|
&& previous.startedAtMs === next.startedAtMs
|
||||||
&& previous.messages.length === next.messages.length
|
&& previous.messages.length === next.messages.length
|
||||||
&& previous.messages.every((message, index) =>
|
&& previous.messages.every((message, index) =>
|
||||||
@@ -381,7 +387,7 @@ function unitIndexAfterMessageCount(
|
|||||||
let seen = 0;
|
let seen = 0;
|
||||||
for (let i = 0; i < units.length; i += 1) {
|
for (let i = 0; i < units.length; i += 1) {
|
||||||
const unit = units[i];
|
const unit = units[i];
|
||||||
seen += unit.type === "activity" ? unit.messages.length : 1;
|
seen += unit.sourceMessageCount;
|
||||||
if (seen >= messageCount) return i;
|
if (seen >= messageCount) return i;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailab
|
|||||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||||
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
||||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||||
|
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
|
||||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
import {
|
||||||
|
ThreadComposer,
|
||||||
|
type ComposerContextUsage,
|
||||||
|
} from "@/components/thread/ThreadComposer";
|
||||||
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
|
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
|
||||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||||
@@ -83,6 +87,30 @@ function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function latestComposerContextUsage(messages: UIMessage[]): ComposerContextUsage | null {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
const contextTokens = message.usage?.context_tokens;
|
||||||
|
if (
|
||||||
|
message.role !== "assistant"
|
||||||
|
|| message.kind === "trace"
|
||||||
|
|| message.isStreaming
|
||||||
|
|| typeof contextTokens !== "number"
|
||||||
|
|| !Number.isFinite(contextTokens)
|
||||||
|
|| contextTokens < 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contextTokens,
|
||||||
|
...(typeof message.contextWindowTokens === "number"
|
||||||
|
? { contextWindowTokens: message.contextWindowTokens }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function snapshotPreservesMessage(
|
function snapshotPreservesMessage(
|
||||||
current: MessageShape,
|
current: MessageShape,
|
||||||
candidate: MessageShape,
|
candidate: MessageShape,
|
||||||
@@ -400,7 +428,7 @@ function toModelBadgeInfo(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
model: model?.trim() || null,
|
model: toModelBadgeLabel(model),
|
||||||
provider,
|
provider,
|
||||||
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
||||||
needsSetup,
|
needsSetup,
|
||||||
@@ -736,6 +764,9 @@ export function ThreadShell({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
runStartedAt,
|
runStartedAt,
|
||||||
goalState,
|
goalState,
|
||||||
|
recoveryState,
|
||||||
|
continueRecovery,
|
||||||
|
dismissRecovery,
|
||||||
send,
|
send,
|
||||||
transcribeAudio,
|
transcribeAudio,
|
||||||
stop,
|
stop,
|
||||||
@@ -803,9 +834,20 @@ export function ThreadShell({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
const composerContextUsage = useMemo(
|
||||||
|
() => latestComposerContextUsage(displayMessages),
|
||||||
|
[displayMessages],
|
||||||
|
);
|
||||||
const currentGoalState = messagesReady ? goalState : undefined;
|
const currentGoalState = messagesReady ? goalState : undefined;
|
||||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
// Decision states freeze the interrupted turn and hand the next action to
|
||||||
|
// the recovery notice. ``resuming`` remains active; ``recovered`` is only
|
||||||
|
// historical metadata and must not suppress a later normal turn.
|
||||||
|
const recoveryNeedsDecision = recoveryState?.status === "awaiting_user"
|
||||||
|
|| recoveryState?.status === "failed";
|
||||||
|
const currentRunStartedAt = messagesReady && !recoveryNeedsDecision ? runStartedAt : null;
|
||||||
|
const turnActive = messagesReady
|
||||||
|
&& !recoveryNeedsDecision
|
||||||
|
&& (isStreaming || currentRunStartedAt !== null);
|
||||||
const restoredViewportTurnId = useMemo(
|
const restoredViewportTurnId = useMemo(
|
||||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||||
[currentRunStartedAt, displayMessages, turnActive],
|
[currentRunStartedAt, displayMessages, turnActive],
|
||||||
@@ -882,9 +924,17 @@ export function ThreadShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalModelPreset(null);
|
setLocalModelPreset(null);
|
||||||
}, [session?.key, sessionModelPreset]);
|
}, [session?.key, sessionModelPreset]);
|
||||||
|
const configuredPresetNames = useMemo(
|
||||||
|
() => new Set(settings?.model_presets.map((preset) => preset.name) ?? []),
|
||||||
|
[settings],
|
||||||
|
);
|
||||||
const activeModelPreset = (
|
const activeModelPreset = (
|
||||||
localModelPreset
|
(localModelPreset && (!settings || configuredPresetNames.has(localModelPreset))
|
||||||
|| sessionModelPreset
|
? localModelPreset
|
||||||
|
: null)
|
||||||
|
|| (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset))
|
||||||
|
? sessionModelPreset
|
||||||
|
: null)
|
||||||
|| settings?.agent.model_preset
|
|| settings?.agent.model_preset
|
||||||
|| "default"
|
|| "default"
|
||||||
);
|
);
|
||||||
@@ -958,13 +1008,10 @@ export function ThreadShell({
|
|||||||
}
|
}
|
||||||
setFallbackModelName(null);
|
setFallbackModelName(null);
|
||||||
return client.onChat(chatId, (event) => {
|
return client.onChat(chatId, (event) => {
|
||||||
if (event.event !== "turn_model_updated") return;
|
if (event.event !== "turn_model_updated" || event.fallback !== true) return;
|
||||||
const activeModel = event.model_name.trim();
|
setFallbackModelName(event.model_name);
|
||||||
setFallbackModelName(
|
|
||||||
modelBadge.model && activeModel !== modelBadge.model ? activeModel : null,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, [chatId, client, modelBadge.model]);
|
}, [chatId, client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyKey || !chatId || loading) return;
|
if (!historyKey || !chatId || loading) return;
|
||||||
@@ -1436,6 +1483,13 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const composer = (
|
const composer = (
|
||||||
<>
|
<>
|
||||||
|
{recoveryState ? (
|
||||||
|
<RecoveryNotice
|
||||||
|
state={recoveryState}
|
||||||
|
onContinue={continueRecovery}
|
||||||
|
onDismiss={dismissRecovery}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
|
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
|
||||||
<StreamErrorNotice
|
<StreamErrorNotice
|
||||||
error={streamError}
|
error={streamError}
|
||||||
@@ -1454,7 +1508,7 @@ export function ThreadShell({
|
|||||||
: t("thread.composer.placeholderThread")
|
: t("thread.composer.placeholderThread")
|
||||||
}
|
}
|
||||||
modelLabel={modelBadgeLabel}
|
modelLabel={modelBadgeLabel}
|
||||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
modelDetail={modelBadge.model}
|
||||||
modelPreset={activeModelPreset}
|
modelPreset={activeModelPreset}
|
||||||
modelPresets={modelPresetOptions}
|
modelPresets={modelPresetOptions}
|
||||||
onModelPresetChange={handleModelPresetChange}
|
onModelPresetChange={handleModelPresetChange}
|
||||||
@@ -1463,6 +1517,7 @@ export function ThreadShell({
|
|||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
|
contextUsage={composerContextUsage}
|
||||||
variant={composerVariant}
|
variant={composerVariant}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
@@ -1501,7 +1556,7 @@ export function ThreadShell({
|
|||||||
: t("thread.composer.placeholderHero")
|
: t("thread.composer.placeholderHero")
|
||||||
}
|
}
|
||||||
modelLabel={modelBadgeLabel}
|
modelLabel={modelBadgeLabel}
|
||||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
modelDetail={modelBadge.model}
|
||||||
modelPreset={activeModelPreset}
|
modelPreset={activeModelPreset}
|
||||||
modelPresets={modelPresetOptions}
|
modelPresets={modelPresetOptions}
|
||||||
onModelPresetChange={handleModelPresetChange}
|
onModelPresetChange={handleModelPresetChange}
|
||||||
@@ -1510,6 +1565,7 @@ export function ThreadShell({
|
|||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
|
contextUsage={composerContextUsage}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function FileEditRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
||||||
return edit.added > 0 || edit.deleted > 0;
|
return edit.added > 0 || edit.deleted > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { compactActivityPath, redactActivityText } from "./activity-text";
|
|||||||
export type GenericToolStatus = "running" | "done" | "error";
|
export type GenericToolStatus = "running" | "done" | "error";
|
||||||
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
|
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
|
||||||
|
|
||||||
export interface ToolField {
|
interface ToolField {
|
||||||
key:
|
key:
|
||||||
| "query"
|
| "query"
|
||||||
| "pattern"
|
| "pattern"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { displayWebHost, formatCompactWebUrl, parseSafeActivityHttpUrl } from ".
|
|||||||
export type WebSearchStatus = "running" | "done" | "error";
|
export type WebSearchStatus = "running" | "done" | "error";
|
||||||
export type WebSearchTarget = "web" | "x";
|
export type WebSearchTarget = "web" | "x";
|
||||||
|
|
||||||
export interface WebSearchSource {
|
interface WebSearchSource {
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function promptLabel(content: string, index: number): string {
|
function promptLabel(content: string, index: number): string {
|
||||||
const text = content.replace(/\s+/g, " ").trim();
|
const text = content.replace(/\s+/g, " ").trim();
|
||||||
if (!text) return `Prompt ${index + 1}`;
|
if (!text) return `Prompt ${index + 1}`;
|
||||||
return truncatePreview(text, 80);
|
return truncatePreview(text, 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function promptPreview(content: string, index: number): string {
|
function promptPreview(content: string, index: number): string {
|
||||||
const text = compactPreview(content);
|
const text = compactPreview(content);
|
||||||
if (!text) return `Prompt ${index + 1}`;
|
if (!text) return `Prompt ${index + 1}`;
|
||||||
return truncatePreview(text, 320);
|
return truncatePreview(text, 320);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import { formControlFocusClassName } from "@/components/ui/form-control";
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import { formControlFocusClassName } from "@/components/ui/form-control";
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type {
|
|||||||
|
|
||||||
export const MAX_WORKBENCH_PANES = 4;
|
export const MAX_WORKBENCH_PANES = 4;
|
||||||
|
|
||||||
export const WORKBENCH_LAYOUTS = [
|
const WORKBENCH_LAYOUTS = [
|
||||||
"columns",
|
"columns",
|
||||||
"rows",
|
"rows",
|
||||||
"grid",
|
"grid",
|
||||||
|
|||||||
+40
-34
@@ -165,11 +165,19 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-color: rgb(236 141 49);
|
background-color: rgb(236 141 49);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 220ms ease-out;
|
transition: opacity 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-model-badge[data-fallback="true"] {
|
||||||
|
border-color: rgb(236 141 49 / 0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-model-badge[data-fallback="true"]::before {
|
.composer-model-badge[data-fallback="true"]::before {
|
||||||
opacity: 1;
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .composer-model-badge[data-fallback="true"]::before {
|
||||||
|
opacity: 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-model-badge > * {
|
.composer-model-badge > * {
|
||||||
@@ -695,11 +703,38 @@
|
|||||||
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
|
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-composer-model-badge:not([data-switching="true"]):active
|
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
|
||||||
> .composer-model-pill {
|
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes composer-model-pill-viewport-enter {
|
||||||
|
from {
|
||||||
|
transform: scale(0.9074);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-model-pill-viewport {
|
||||||
|
transform-origin: right center;
|
||||||
|
animation: composer-model-pill-viewport-enter 210ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||||
|
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-model-pill-dock {
|
||||||
|
transform-origin: right center;
|
||||||
|
transition-property: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-model-pill-track[data-settling="true"],
|
||||||
|
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
|
||||||
|
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes preset-name-shake {
|
@keyframes preset-name-shake {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@@ -715,37 +750,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes composer-model-pill-viewport-enter {
|
|
||||||
from {
|
|
||||||
transform: scale(0.9074);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-model-pill-viewport {
|
|
||||||
transform-origin: right center;
|
|
||||||
animation: composer-model-pill-viewport-enter 210ms
|
|
||||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
|
||||||
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-model-pill-dock {
|
|
||||||
transform-origin: right center;
|
|
||||||
transition-property: none;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-model-pill-track[data-settling="true"],
|
|
||||||
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
|
|
||||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.thread-composer-model-badge:active > .composer-model-pill {
|
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import type { WebUIIngressLimits } from "@/lib/types";
|
|||||||
* - ``ready`` — ``dataUrl`` available; safe to submit
|
* - ``ready`` — ``dataUrl`` available; safe to submit
|
||||||
* - ``error`` — validation / decode failure; chip shows inline error
|
* - ``error`` — validation / decode failure; chip shows inline error
|
||||||
*/
|
*/
|
||||||
export type AttachmentStatus = "encoding" | "ready" | "error";
|
type AttachmentStatus = "encoding" | "ready" | "error";
|
||||||
export type AttachmentKind = "image" | "file";
|
export type AttachmentKind = "image" | "file";
|
||||||
|
|
||||||
export interface AttachedAttachment {
|
interface AttachedAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
kind: AttachmentKind;
|
kind: AttachmentKind;
|
||||||
file: File;
|
file: File;
|
||||||
@@ -32,7 +32,7 @@ export interface AttachedAttachment {
|
|||||||
|
|
||||||
export type AttachedImage = AttachedAttachment;
|
export type AttachedImage = AttachedAttachment;
|
||||||
|
|
||||||
export interface RestoredReadyAttachment {
|
interface RestoredReadyAttachment {
|
||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
kind?: AttachmentKind;
|
kind?: AttachmentKind;
|
||||||
@@ -55,8 +55,8 @@ export type AttachmentError =
|
|||||||
| "io"; // file read failed at the browser layer
|
| "io"; // file read failed at the browser layer
|
||||||
|
|
||||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
||||||
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
||||||
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
|
const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
|
||||||
|
|
||||||
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
|
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
|
||||||
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
|
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { acceptedAttachmentKind } from "@/hooks/useAttachedImages";
|
|||||||
* - Plain text pasted alongside attachments is *not* consumed by this helper,
|
* - Plain text pasted alongside attachments is *not* consumed by this helper,
|
||||||
* so the caller can still let the textarea receive it naturally.
|
* so the caller can still let the textarea receive it naturally.
|
||||||
*/
|
*/
|
||||||
export function extractImageFilesFromPaste(
|
function extractImageFilesFromPaste(
|
||||||
event: ClipboardEvent | React.ClipboardEvent,
|
event: ClipboardEvent | React.ClipboardEvent,
|
||||||
): File[] {
|
): File[] {
|
||||||
const clipboard = (event as ClipboardEvent).clipboardData
|
const clipboard = (event as ClipboardEvent).clipboardData
|
||||||
@@ -27,7 +27,7 @@ export function extractImageFilesFromPaste(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
|
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
|
||||||
export function extractImageFilesFromDrop(
|
function extractImageFilesFromDrop(
|
||||||
event: DragEvent | React.DragEvent,
|
event: DragEvent | React.DragEvent,
|
||||||
): File[] {
|
): File[] {
|
||||||
const dt = (event as DragEvent).dataTransfer
|
const dt = (event as DragEvent).dataTransfer
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from "@/lib/thread-event-projection";
|
} from "@/lib/thread-event-projection";
|
||||||
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
||||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
|
import { readLocalPreferences } from "@/lib/local-preferences";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
OutboundCliAppMention,
|
OutboundCliAppMention,
|
||||||
@@ -36,6 +38,7 @@ import type {
|
|||||||
SessionMention,
|
SessionMention,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
MessageDeliveryStatus,
|
MessageDeliveryStatus,
|
||||||
|
RecoveryState,
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
@@ -55,7 +58,6 @@ type PendingStreamEvent =
|
|||||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
||||||
|
|
||||||
const STREAM_END_IDLE_DELAY_MS = 1000;
|
|
||||||
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,8 +65,8 @@ const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
|||||||
*
|
*
|
||||||
* Lookup rule: reasoning can only extend the current reasoning placeholder.
|
* Lookup rule: reasoning can only extend the current reasoning placeholder.
|
||||||
* Once ordinary answer text has appeared, the next reasoning chunk starts a
|
* Once ordinary answer text has appeared, the next reasoning chunk starts a
|
||||||
* fresh Thought block so streamed output stays in arrival order:
|
* fresh activity surface so streamed output stays in arrival order while the
|
||||||
* Thought -> answer -> Thought -> answer.
|
* final answer remains the only visible answer bubble.
|
||||||
*/
|
*/
|
||||||
function attachReasoningChunk(
|
function attachReasoningChunk(
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
@@ -183,16 +185,6 @@ export interface SubmittedTurn {
|
|||||||
sideChannel: boolean;
|
sideChannel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
|
||||||
if (
|
|
||||||
ev.event === "delta"
|
|
||||||
|| ev.event === "reasoning_delta"
|
|
||||||
|| ev.event === "file_edit"
|
|
||||||
) return true;
|
|
||||||
return ev.event === "message"
|
|
||||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventTurnId(ev: InboundEvent): string | undefined {
|
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||||
}
|
}
|
||||||
@@ -255,6 +247,9 @@ export function useNanobotStream(
|
|||||||
runStartedAt: number | null;
|
runStartedAt: number | null;
|
||||||
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
||||||
goalState: GoalStateWsPayload | undefined;
|
goalState: GoalStateWsPayload | undefined;
|
||||||
|
recoveryState: RecoveryState | null;
|
||||||
|
continueRecovery: () => Promise<void>;
|
||||||
|
dismissRecovery: () => Promise<void>;
|
||||||
send: (
|
send: (
|
||||||
content: string,
|
content: string,
|
||||||
images?: SendAttachment[],
|
images?: SendAttachment[],
|
||||||
@@ -273,6 +268,7 @@ export function useNanobotStream(
|
|||||||
dismissStreamError: () => void;
|
dismissStreamError: () => void;
|
||||||
} {
|
} {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
||||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||||
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
|
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
|
||||||
@@ -284,6 +280,7 @@ export function useNanobotStream(
|
|||||||
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
||||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
|
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
|
||||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||||
|
const [recoveryState, setRecoveryState] = useState<RecoveryState | null>(null);
|
||||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||||
const buffer = useRef<StreamBuffer | null>(null);
|
const buffer = useRef<StreamBuffer | null>(null);
|
||||||
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
||||||
@@ -296,17 +293,19 @@ export function useNanobotStream(
|
|||||||
const streamTimerRef = useRef<number | null>(null);
|
const streamTimerRef = useRef<number | null>(null);
|
||||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||||
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
|
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
|
||||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
|
||||||
*
|
|
||||||
* When the model finishes a text segment and calls a tool, the server
|
|
||||||
* sends ``stream_end`` but the agent is still "thinking" while the tool
|
|
||||||
* executes. By deferring the flag reset by a short window (1 s) we keep
|
|
||||||
* the loading spinner alive across tool-call boundaries without needing
|
|
||||||
* backend changes. */
|
|
||||||
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||||
|
|
||||||
|
const notifyInBackground = useCallback((body: string) => {
|
||||||
|
if (
|
||||||
|
typeof Notification === "undefined"
|
||||||
|
|| Notification.permission !== "granted"
|
||||||
|
|| document.visibilityState === "visible"
|
||||||
|
|| !readLocalPreferences().browserNotifications
|
||||||
|
) return;
|
||||||
|
new Notification("nanobot", { body });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearPendingStreamWork = useCallback(() => {
|
const clearPendingStreamWork = useCallback(() => {
|
||||||
if (streamFrameRef.current !== null) {
|
if (streamFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(streamFrameRef.current);
|
window.cancelAnimationFrame(streamFrameRef.current);
|
||||||
@@ -319,26 +318,11 @@ export function useNanobotStream(
|
|||||||
pendingStreamEventsRef.current = [];
|
pendingStreamEventsRef.current = [];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelStreamEndTimer = useCallback(() => {
|
|
||||||
if (streamEndTimerRef.current === null) return;
|
|
||||||
clearTimeout(streamEndTimerRef.current);
|
|
||||||
streamEndTimerRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isSideChannelEvent = useCallback((ev: InboundEvent) => {
|
const isSideChannelEvent = useCallback((ev: InboundEvent) => {
|
||||||
const turnId = eventTurnId(ev);
|
const turnId = eventTurnId(ev);
|
||||||
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId);
|
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scheduleStreamEndTimer = useCallback((turn: UIMessageTurnFields = {}) => {
|
|
||||||
cancelStreamEndTimer();
|
|
||||||
streamEndTimerRef.current = setTimeout(() => {
|
|
||||||
streamEndTimerRef.current = null;
|
|
||||||
setIsStreaming(false);
|
|
||||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
|
||||||
}, STREAM_END_IDLE_DELAY_MS);
|
|
||||||
}, [cancelStreamEndTimer]);
|
|
||||||
|
|
||||||
const createActivitySegmentId = useCallback((activate = true) => {
|
const createActivitySegmentId = useCallback((activate = true) => {
|
||||||
activitySegmentCounterRef.current += 1;
|
activitySegmentCounterRef.current += 1;
|
||||||
const id = `activity-${activitySegmentCounterRef.current}`;
|
const id = `activity-${activitySegmentCounterRef.current}`;
|
||||||
@@ -387,7 +371,6 @@ export function useNanobotStream(
|
|||||||
(event) => event.turn.turnId !== rejectedTurnId,
|
(event) => event.turn.turnId !== rejectedTurnId,
|
||||||
);
|
);
|
||||||
sideChannelTurnIdsRef.current.delete(rejectedTurnId);
|
sideChannelTurnIdsRef.current.delete(rejectedTurnId);
|
||||||
cancelStreamEndTimer();
|
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
|
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
|
||||||
if (rejectedRows.length === 0) return prev;
|
if (rejectedRows.length === 0) return prev;
|
||||||
@@ -438,7 +421,7 @@ export function useNanobotStream(
|
|||||||
setRunStartedAt(remainingStartedAt);
|
setRunStartedAt(remainingStartedAt);
|
||||||
setIsStreaming(hasRemainingRun);
|
setIsStreaming(hasRemainingRun);
|
||||||
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
|
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
|
||||||
}, [cancelStreamEndTimer, chatId, client]);
|
}, [chatId, client]);
|
||||||
|
|
||||||
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
|
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
|
||||||
|
|
||||||
@@ -659,15 +642,6 @@ export function useNanobotStream(
|
|||||||
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
||||||
}, [flushPendingStreamEvents]);
|
}, [flushPendingStreamEvents]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return client.onStatus((status) => {
|
|
||||||
if (status !== "reconnecting" && status !== "closed") return;
|
|
||||||
// A transport drop does not prove the backend turn completed. Keep the
|
|
||||||
// semantic running state intact so queued guidance is not flushed early.
|
|
||||||
cancelStreamEndTimer();
|
|
||||||
});
|
|
||||||
}, [cancelStreamEndTimer, client]);
|
|
||||||
|
|
||||||
// Reset local state when switching chats. Do not reset on every
|
// Reset local state when switching chats. Do not reset on every
|
||||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||||
// history response after the optimistic first message has already rendered.
|
// history response after the optimistic first message has already rendered.
|
||||||
@@ -683,6 +657,7 @@ export function useNanobotStream(
|
|||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
setRunStartedAt(restoredRunStartedAt);
|
setRunStartedAt(restoredRunStartedAt);
|
||||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||||
|
setRecoveryState(null);
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
activeAssistantRef.current = null;
|
activeAssistantRef.current = null;
|
||||||
closedAssistantStreamIdsRef.current.clear();
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
@@ -690,9 +665,8 @@ export function useNanobotStream(
|
|||||||
clearPendingStreamWork();
|
clearPendingStreamWork();
|
||||||
sideChannelTurnIdsRef.current.clear();
|
sideChannelTurnIdsRef.current.clear();
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
cancelStreamEndTimer();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPendingToolCalls) setIsStreaming(true);
|
if (hasPendingToolCalls) setIsStreaming(true);
|
||||||
@@ -774,12 +748,6 @@ export function useNanobotStream(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sideChannelEvent = isSideChannelEvent(ev);
|
const sideChannelEvent = isSideChannelEvent(ev);
|
||||||
if (
|
|
||||||
streamEndTimerRef.current !== null
|
|
||||||
&& !sideChannelEvent
|
|
||||||
&& eventExtendsModelActivity(ev)
|
|
||||||
) cancelStreamEndTimer();
|
|
||||||
|
|
||||||
if (ev.event === "delta") {
|
if (ev.event === "delta") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||||
@@ -822,14 +790,13 @@ export function useNanobotStream(
|
|||||||
});
|
});
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
if (ev.resuming) {
|
if (ev.resuming) {
|
||||||
cancelStreamEndTimer();
|
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
if (!mergeNext) {
|
|
||||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleStreamEndTimer(turn);
|
// ``stream_end`` closes the current answer segment, not the turn.
|
||||||
|
// Tools and follow-up model segments may still arrive before the
|
||||||
|
// definitive ``turn_end`` event.
|
||||||
|
setIsStreaming(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,9 +835,8 @@ export function useNanobotStream(
|
|||||||
setGoalState(ev.goal_state);
|
setGoalState(ev.goal_state);
|
||||||
}
|
}
|
||||||
setRunStartedAt(null);
|
setRunStartedAt(null);
|
||||||
// Definitive signal that the turn is fully complete. Cancel any
|
// Definitive signal that the turn is fully complete, so stop the
|
||||||
// pending debounce timer and stop the loading indicator immediately.
|
// loading indicator immediately.
|
||||||
cancelStreamEndTimer();
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
const completedAt = Date.now();
|
const completedAt = Date.now();
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
@@ -884,6 +850,10 @@ export function useNanobotStream(
|
|||||||
finalized,
|
finalized,
|
||||||
{
|
{
|
||||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
...(latencyMs !== undefined ? { latencyMs } : {}),
|
||||||
|
...(ev.usage ? { usage: ev.usage } : {}),
|
||||||
|
...(typeof ev.context_window_tokens === "number"
|
||||||
|
? { contextWindowTokens: ev.context_window_tokens }
|
||||||
|
: {}),
|
||||||
completedAt,
|
completedAt,
|
||||||
},
|
},
|
||||||
ev.turn_id,
|
ev.turn_id,
|
||||||
@@ -895,10 +865,71 @@ export function useNanobotStream(
|
|||||||
return finalized;
|
return finalized;
|
||||||
});
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
|
notifyInBackground(t("recovery.completed", { defaultValue: "Task completed" }));
|
||||||
onTurnEnd?.();
|
onTurnEnd?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ev.event === "recovery_state") {
|
||||||
|
const next: RecoveryState = {
|
||||||
|
status: ev.status,
|
||||||
|
recovery_id: ev.recovery_id,
|
||||||
|
...(ev.reason ? { reason: ev.reason } : {}),
|
||||||
|
...(typeof ev.attempts === "number" ? { attempts: ev.attempts } : {}),
|
||||||
|
...(typeof ev.can_continue === "boolean"
|
||||||
|
? { can_continue: ev.can_continue }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
setRecoveryState(next);
|
||||||
|
if (ev.status === "resuming") {
|
||||||
|
setRunStartedAt((current) => current ?? Date.now() / 1000);
|
||||||
|
setIsStreaming(true);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ev.status === "awaiting_user"
|
||||||
|
|| ev.status === "recovered"
|
||||||
|
|| ev.status === "failed"
|
||||||
|
) {
|
||||||
|
// Recovery is an explicit boundary. The interrupted turn is no
|
||||||
|
// longer running, so do not let the stale start time keep the
|
||||||
|
// activity clock (or composer stop state) alive underneath the
|
||||||
|
// recovery notice.
|
||||||
|
setRunStartedAt(null);
|
||||||
|
setIsStreaming(false);
|
||||||
|
client.finishRunLocally(chatId);
|
||||||
|
clearPendingStreamWork();
|
||||||
|
closeActiveAssistantStream();
|
||||||
|
clearActivitySegment();
|
||||||
|
if (ev.status !== "recovered") {
|
||||||
|
notifyInBackground(
|
||||||
|
ev.status === "failed"
|
||||||
|
? t("recovery.failed", { defaultValue: "Task recovery failed" })
|
||||||
|
: t("recovery.interrupted", { defaultValue: "Task interrupted" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.event === "attached") {
|
||||||
|
setRecoveryState(ev.recovery_state ?? null);
|
||||||
|
if (ev.recovery_state?.status === "resuming") {
|
||||||
|
setRunStartedAt((current) => current ?? Date.now() / 1000);
|
||||||
|
setIsStreaming(true);
|
||||||
|
} else if (
|
||||||
|
ev.recovery_state?.status === "awaiting_user"
|
||||||
|
|| ev.recovery_state?.status === "failed"
|
||||||
|
) {
|
||||||
|
setRunStartedAt(null);
|
||||||
|
setIsStreaming(false);
|
||||||
|
client.finishRunLocally(chatId);
|
||||||
|
clearPendingStreamWork();
|
||||||
|
closeActiveAssistantStream();
|
||||||
|
clearActivitySegment();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ev.event === "message") {
|
if (ev.event === "message") {
|
||||||
if (
|
if (
|
||||||
suppressStreamUntilTurnEndRef.current &&
|
suppressStreamUntilTurnEndRef.current &&
|
||||||
@@ -1013,8 +1044,9 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
// A complete (non-streamed) assistant message. If a stream was in
|
// A complete (non-streamed) assistant message. If a stream was in
|
||||||
// flight, drop the placeholder so we don't render the text twice.
|
// flight, drop the placeholder so we don't render the text twice.
|
||||||
// Streaming state is closed by ``stream_end`` when present, or by
|
// ``turn_end`` is the turn boundary. ``stream_end`` only closes the
|
||||||
// ``turn_end`` for non-streamed and tool-heavy turns.
|
// current text segment so a following tool/reasoning segment remains
|
||||||
|
// part of the same live activity surface.
|
||||||
clearActivitySegment();
|
clearActivitySegment();
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const activeId = buffer.current?.messageId;
|
const activeId = buffer.current?.messageId;
|
||||||
@@ -1088,7 +1120,6 @@ export function useNanobotStream(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ``attached`` frames aren't actionable here.
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsub = client.onChat(chatId, handle);
|
const unsub = client.onChat(chatId, handle);
|
||||||
@@ -1099,12 +1130,11 @@ export function useNanobotStream(
|
|||||||
closedAssistantStreamIdsRef.current.clear();
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
clearActivitySegment();
|
clearActivitySegment();
|
||||||
clearPendingStreamWork();
|
clearPendingStreamWork();
|
||||||
cancelStreamEndTimer();
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
applyStreamError,
|
applyStreamError,
|
||||||
cancelStreamEndTimer,
|
|
||||||
chatId,
|
chatId,
|
||||||
|
closeActiveAssistantStream,
|
||||||
client,
|
client,
|
||||||
clearActivitySegment,
|
clearActivitySegment,
|
||||||
clearPendingStreamWork,
|
clearPendingStreamWork,
|
||||||
@@ -1112,9 +1142,10 @@ export function useNanobotStream(
|
|||||||
ensureActivitySegmentId,
|
ensureActivitySegmentId,
|
||||||
flushPendingStreamEvents,
|
flushPendingStreamEvents,
|
||||||
isSideChannelEvent,
|
isSideChannelEvent,
|
||||||
|
notifyInBackground,
|
||||||
onTurnEnd,
|
onTurnEnd,
|
||||||
schedulePendingStreamFlush,
|
schedulePendingStreamFlush,
|
||||||
scheduleStreamEndTimer,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
@@ -1133,7 +1164,6 @@ export function useNanobotStream(
|
|||||||
: content;
|
: content;
|
||||||
flushPendingStreamEvents();
|
flushPendingStreamEvents();
|
||||||
if (finalizeActiveTurn) {
|
if (finalizeActiveTurn) {
|
||||||
cancelStreamEndTimer();
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
const turnId = crypto.randomUUID();
|
const turnId = crypto.randomUUID();
|
||||||
@@ -1188,7 +1218,7 @@ export function useNanobotStream(
|
|||||||
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
|
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
|
||||||
return { turnId, userMessageId, sideChannel };
|
return { turnId, userMessageId, sideChannel };
|
||||||
},
|
},
|
||||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
@@ -1209,7 +1239,6 @@ export function useNanobotStream(
|
|||||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||||
|
|
||||||
const reconcileTurnComplete = useCallback(() => {
|
const reconcileTurnComplete = useCallback(() => {
|
||||||
cancelStreamEndTimer();
|
|
||||||
clearPendingStreamWork();
|
clearPendingStreamWork();
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
activeAssistantRef.current = null;
|
activeAssistantRef.current = null;
|
||||||
@@ -1218,7 +1247,7 @@ export function useNanobotStream(
|
|||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
setRunStartedAt(null);
|
setRunStartedAt(null);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
}, [clearActivitySegment, clearPendingStreamWork]);
|
||||||
|
|
||||||
const transcribeAudio = useCallback(
|
const transcribeAudio = useCallback(
|
||||||
(dataUrl: string, options?: { durationMs?: number }) =>
|
(dataUrl: string, options?: { durationMs?: number }) =>
|
||||||
@@ -1226,12 +1255,32 @@ export function useNanobotStream(
|
|||||||
[client],
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const recoveryAction = useCallback(async (action: "continue" | "dismiss") => {
|
||||||
|
if (!chatId || !recoveryState) return;
|
||||||
|
await client.requestMutation(`recovery.${action}`, {
|
||||||
|
chat_id: chatId,
|
||||||
|
recovery_id: recoveryState.recovery_id,
|
||||||
|
});
|
||||||
|
}, [chatId, client, recoveryState]);
|
||||||
|
|
||||||
|
const continueRecovery = useCallback(
|
||||||
|
() => recoveryAction("continue"),
|
||||||
|
[recoveryAction],
|
||||||
|
);
|
||||||
|
const dismissRecovery = useCallback(
|
||||||
|
() => recoveryAction("dismiss"),
|
||||||
|
[recoveryAction],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
messagesReady: messageOwnerChatId === chatId,
|
messagesReady: messageOwnerChatId === chatId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
runStartedAt,
|
runStartedAt,
|
||||||
goalState,
|
goalState,
|
||||||
|
recoveryState,
|
||||||
|
continueRecovery,
|
||||||
|
dismissRecovery,
|
||||||
send,
|
send,
|
||||||
transcribeAudio,
|
transcribeAudio,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { normalizeWorkbenchState } from "@/components/workbench/workbench-model"
|
|||||||
import { fetchSidebarState } from "@/lib/api";
|
import { fetchSidebarState } from "@/lib/api";
|
||||||
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
||||||
|
|
||||||
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
pinned_keys: [],
|
pinned_keys: [],
|
||||||
archived_keys: [],
|
archived_keys: [],
|
||||||
@@ -74,7 +74,7 @@ function boolMap(value: unknown): Record<string, boolean> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
|
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function normalizeLocale(
|
|||||||
return baseMatch?.code ?? defaultLocale;
|
return baseMatch?.code ?? defaultLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readStoredLocale(): SupportedLocale | null {
|
function readStoredLocale(): SupportedLocale | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
applyDocumentLocale,
|
applyDocumentLocale,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
fallbackLocale,
|
fallbackLocale,
|
||||||
LOCALE_STORAGE_KEY,
|
|
||||||
normalizeLocale,
|
normalizeLocale,
|
||||||
persistLocale,
|
persistLocale,
|
||||||
resolveInitialLocale,
|
resolveInitialLocale,
|
||||||
@@ -47,7 +46,7 @@ export function currentLocale(): SupportedLocale {
|
|||||||
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
|
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLocaleResources(
|
async function loadLocaleResources(
|
||||||
locale: SupportedLocale,
|
locale: SupportedLocale,
|
||||||
): Promise<LocaleResource> {
|
): Promise<LocaleResource> {
|
||||||
const existing = resourcePromises.get(locale);
|
const existing = resourcePromises.get(locale);
|
||||||
@@ -131,5 +130,4 @@ function syncLocaleSideEffects(language: string) {
|
|||||||
persistLocale(locale);
|
persistLocale(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { LOCALE_STORAGE_KEY };
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -198,6 +198,7 @@
|
|||||||
"fileEditDisplay": "File edit display",
|
"fileEditDisplay": "File edit display",
|
||||||
"codeWrap": "Code wrapping",
|
"codeWrap": "Code wrapping",
|
||||||
"brandLogos": "Brand logos",
|
"brandLogos": "Brand logos",
|
||||||
|
"browserNotifications": "Task notifications",
|
||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
@@ -243,6 +244,7 @@
|
|||||||
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
||||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
"codeWrap": "Keep long code lines readable on smaller screens.",
|
||||||
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||||
|
"browserNotifications": "Notify only when this page is in the background. Off by default.",
|
||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
@@ -1013,7 +1015,8 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
"complete": "Agent finished",
|
"complete": "Agent finished",
|
||||||
"updated": "New activity"
|
"updated": "New activity",
|
||||||
|
"recovery": "This conversation needs your attention"
|
||||||
},
|
},
|
||||||
"pin": "Pin",
|
"pin": "Pin",
|
||||||
"unpin": "Unpin",
|
"unpin": "Unpin",
|
||||||
@@ -1192,6 +1195,11 @@
|
|||||||
"removeQuotedContext": "Remove quoted context",
|
"removeQuotedContext": "Remove quoted context",
|
||||||
"modelNotConfigured": "Model not configured",
|
"modelNotConfigured": "Model not configured",
|
||||||
"configureModel": "Configure model",
|
"configureModel": "Configure model",
|
||||||
|
"switchModel": "Switch model for this chat",
|
||||||
|
"context": {
|
||||||
|
"tooltip": "Context · {{tokens}}{{capacity}}",
|
||||||
|
"meterDescription": "{{context}}. {{percent}}% used."
|
||||||
|
},
|
||||||
"queued": {
|
"queued": {
|
||||||
"label": "Queued guidance",
|
"label": "Queued guidance",
|
||||||
"guide": "Guide",
|
"guide": "Guide",
|
||||||
@@ -1424,7 +1432,24 @@
|
|||||||
"fileEditShowMoreLines": "Show {{count}} more lines",
|
"fileEditShowMoreLines": "Show {{count}} more lines",
|
||||||
"fileEditShowFewerLines": "Show fewer lines",
|
"fileEditShowFewerLines": "Show fewer lines",
|
||||||
"fileEditOpenFile": "Open file",
|
"fileEditOpenFile": "Open file",
|
||||||
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change."
|
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
|
||||||
|
"usage": {
|
||||||
|
"context": "Context now: {{tokens}}{{capacity}}",
|
||||||
|
"requests": "{{count}} calls this turn",
|
||||||
|
"estimated": "Includes estimated usage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recovery": {
|
||||||
|
"actionFailed": "Recovery action failed. Try again.",
|
||||||
|
"interrupted": "Task interrupted",
|
||||||
|
"completed": "Task completed",
|
||||||
|
"failed": "Task recovery failed",
|
||||||
|
"failedHelp": "The saved task could not be restored safely. Review it before continuing.",
|
||||||
|
"resuming": "Restoring interrupted task…",
|
||||||
|
"review": "Review the task before continuing. Tools will not be replayed automatically.",
|
||||||
|
"safeResume": "Continuing from saved conversation context.",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"continue": "Continue"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Image preview",
|
"title": "Image preview",
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"webuiDefaultAccess": "Acceso predeterminado",
|
"webuiDefaultAccess": "Acceso predeterminado",
|
||||||
"currentModel": "Configuración actual",
|
"currentModel": "Configuración actual",
|
||||||
"brandLogos": "Logos de marca",
|
"brandLogos": "Logos de marca",
|
||||||
|
"browserNotifications": "Notificaciones de tareas",
|
||||||
"cliAppsCatalog": "Catálogo",
|
"cliAppsCatalog": "Catálogo",
|
||||||
"cliAppsFilter": "Filtro",
|
"cliAppsFilter": "Filtro",
|
||||||
"engine": "Motor",
|
"engine": "Motor",
|
||||||
@@ -204,6 +205,7 @@
|
|||||||
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||||
"selectedModelValue": "Definido por el modelo seleccionado.",
|
"selectedModelValue": "Definido por el modelo seleccionado.",
|
||||||
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
||||||
|
"browserNotifications": "Notifica solo cuando esta página está en segundo plano. Desactivado de forma predeterminada.",
|
||||||
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
|
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
|
||||||
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
||||||
"logs": "Abre la carpeta de registros del motor nativo.",
|
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||||
@@ -1000,7 +1002,8 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agente en ejecución",
|
"running": "Agente en ejecución",
|
||||||
"complete": "Agente terminado",
|
"complete": "Agente terminado",
|
||||||
"updated": "Nueva actividad"
|
"updated": "Nueva actividad",
|
||||||
|
"recovery": "Esta conversación requiere tu atención"
|
||||||
},
|
},
|
||||||
"pin": "Fijar",
|
"pin": "Fijar",
|
||||||
"unpin": "Desfijar",
|
"unpin": "Desfijar",
|
||||||
@@ -1179,6 +1182,11 @@
|
|||||||
"removeQuotedContext": "Quitar contexto citado",
|
"removeQuotedContext": "Quitar contexto citado",
|
||||||
"modelNotConfigured": "Modelo no configurado",
|
"modelNotConfigured": "Modelo no configurado",
|
||||||
"configureModel": "Configurar modelo",
|
"configureModel": "Configurar modelo",
|
||||||
|
"switchModel": "Cambiar el modelo de este chat",
|
||||||
|
"context": {
|
||||||
|
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||||
|
"meterDescription": "{{context}}. {{percent}} % usado."
|
||||||
|
},
|
||||||
"queued": {
|
"queued": {
|
||||||
"label": "Guía en cola",
|
"label": "Guía en cola",
|
||||||
"guide": "Guiar",
|
"guide": "Guiar",
|
||||||
@@ -1395,6 +1403,11 @@
|
|||||||
"fileEditShowFewerLines": "Mostrar menos líneas",
|
"fileEditShowFewerLines": "Mostrar menos líneas",
|
||||||
"fileEditOpenFile": "Abrir archivo",
|
"fileEditOpenFile": "Abrir archivo",
|
||||||
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
|
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
|
||||||
|
"usage": {
|
||||||
|
"context": "Contexto más reciente: {{tokens}}{{capacity}} tokens",
|
||||||
|
"requests": "{{count}} solicitudes al modelo",
|
||||||
|
"estimated": "Incluye uso estimado"
|
||||||
|
},
|
||||||
"activityThinkingFor": "Pensando durante {{duration}}",
|
"activityThinkingFor": "Pensando durante {{duration}}",
|
||||||
"activityThought": "Pensamiento completado",
|
"activityThought": "Pensamiento completado",
|
||||||
"activityThoughtFor": "Pensó durante {{duration}}",
|
"activityThoughtFor": "Pensó durante {{duration}}",
|
||||||
@@ -1413,6 +1426,18 @@
|
|||||||
"automationSourceFallback": "Automatización",
|
"automationSourceFallback": "Automatización",
|
||||||
"automationTriggered": "Activada automáticamente"
|
"automationTriggered": "Activada automáticamente"
|
||||||
},
|
},
|
||||||
|
"recovery": {
|
||||||
|
"actionFailed": "La recuperación falló. Inténtalo de nuevo.",
|
||||||
|
"interrupted": "Tarea interrumpida",
|
||||||
|
"completed": "Tarea completada",
|
||||||
|
"failed": "La recuperación de la tarea falló",
|
||||||
|
"failedHelp": "La tarea guardada no se pudo restaurar de forma segura. Revísala antes de continuar.",
|
||||||
|
"resuming": "Restaurando la tarea interrumpida…",
|
||||||
|
"review": "Revisa la tarea antes de continuar. Las herramientas no se repetirán automáticamente.",
|
||||||
|
"safeResume": "Continuando desde el contexto guardado de la conversación.",
|
||||||
|
"dismiss": "Descartar",
|
||||||
|
"continue": "Continuar"
|
||||||
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Vista previa de imagen",
|
"title": "Vista previa de imagen",
|
||||||
"open": "Ver imagen",
|
"open": "Ver imagen",
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"webuiDefaultAccess": "Accès par défaut",
|
"webuiDefaultAccess": "Accès par défaut",
|
||||||
"currentModel": "Configuration actuelle",
|
"currentModel": "Configuration actuelle",
|
||||||
"brandLogos": "Logos de marque",
|
"brandLogos": "Logos de marque",
|
||||||
|
"browserNotifications": "Notifications de tâches",
|
||||||
"cliAppsCatalog": "Catalogue",
|
"cliAppsCatalog": "Catalogue",
|
||||||
"cliAppsFilter": "Filtre",
|
"cliAppsFilter": "Filtre",
|
||||||
"engine": "Moteur",
|
"engine": "Moteur",
|
||||||
@@ -204,6 +205,7 @@
|
|||||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||||
"selectedModelValue": "Défini par le modèle sélectionné.",
|
"selectedModelValue": "Défini par le modèle sélectionné.",
|
||||||
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
||||||
|
"browserNotifications": "Notifier uniquement lorsque cette page est en arrière-plan. Désactivé par défaut.",
|
||||||
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’applications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
|
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’applications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
|
||||||
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
|
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
|
||||||
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
||||||
@@ -999,7 +1001,8 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent en cours",
|
"running": "Agent en cours",
|
||||||
"complete": "Agent terminé",
|
"complete": "Agent terminé",
|
||||||
"updated": "Nouvelle activité"
|
"updated": "Nouvelle activité",
|
||||||
|
"recovery": "Cette conversation nécessite votre attention"
|
||||||
},
|
},
|
||||||
"pin": "Épingler",
|
"pin": "Épingler",
|
||||||
"unpin": "Désépingler",
|
"unpin": "Désépingler",
|
||||||
@@ -1178,6 +1181,11 @@
|
|||||||
"removeQuotedContext": "Supprimer le contexte cité",
|
"removeQuotedContext": "Supprimer le contexte cité",
|
||||||
"modelNotConfigured": "Modèle non configuré",
|
"modelNotConfigured": "Modèle non configuré",
|
||||||
"configureModel": "Configurer le modèle",
|
"configureModel": "Configurer le modèle",
|
||||||
|
"switchModel": "Changer le modèle de cette conversation",
|
||||||
|
"context": {
|
||||||
|
"tooltip": "Contexte · {{tokens}}{{capacity}}",
|
||||||
|
"meterDescription": "{{context}}. {{percent}} % utilisé."
|
||||||
|
},
|
||||||
"queued": {
|
"queued": {
|
||||||
"label": "Guidage en attente",
|
"label": "Guidage en attente",
|
||||||
"guide": "Guider",
|
"guide": "Guider",
|
||||||
@@ -1394,6 +1402,11 @@
|
|||||||
"fileEditShowFewerLines": "Afficher moins de lignes",
|
"fileEditShowFewerLines": "Afficher moins de lignes",
|
||||||
"fileEditOpenFile": "Ouvrir le fichier",
|
"fileEditOpenFile": "Ouvrir le fichier",
|
||||||
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
|
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
|
||||||
|
"usage": {
|
||||||
|
"context": "Dernier contexte : {{tokens}}{{capacity}} tokens",
|
||||||
|
"requests": "{{count}} requêtes au modèle",
|
||||||
|
"estimated": "Inclut une estimation de l’utilisation"
|
||||||
|
},
|
||||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||||
"activityThought": "Réflexion terminée",
|
"activityThought": "Réflexion terminée",
|
||||||
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
||||||
@@ -1412,6 +1425,18 @@
|
|||||||
"automationSourceFallback": "Automatisation",
|
"automationSourceFallback": "Automatisation",
|
||||||
"automationTriggered": "Déclenché automatiquement"
|
"automationTriggered": "Déclenché automatiquement"
|
||||||
},
|
},
|
||||||
|
"recovery": {
|
||||||
|
"actionFailed": "La récupération a échoué. Réessayez.",
|
||||||
|
"interrupted": "Tâche interrompue",
|
||||||
|
"completed": "Tâche terminée",
|
||||||
|
"failed": "Échec de la récupération de la tâche",
|
||||||
|
"failedHelp": "La tâche enregistrée n’a pas pu être restaurée en toute sécurité. Vérifiez-la avant de continuer.",
|
||||||
|
"resuming": "Restauration de la tâche interrompue…",
|
||||||
|
"review": "Vérifiez la tâche avant de continuer. Les outils ne seront pas relancés automatiquement.",
|
||||||
|
"safeResume": "Reprise depuis le contexte de conversation enregistré.",
|
||||||
|
"dismiss": "Ignorer",
|
||||||
|
"continue": "Continuer"
|
||||||
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Aperçu de l’image",
|
"title": "Aperçu de l’image",
|
||||||
"open": "Voir l’image",
|
"open": "Voir l’image",
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"webuiDefaultAccess": "Akses bawaan",
|
"webuiDefaultAccess": "Akses bawaan",
|
||||||
"currentModel": "Konfigurasi saat ini",
|
"currentModel": "Konfigurasi saat ini",
|
||||||
"brandLogos": "Logo merek",
|
"brandLogos": "Logo merek",
|
||||||
|
"browserNotifications": "Notifikasi tugas",
|
||||||
"cliAppsCatalog": "Katalog",
|
"cliAppsCatalog": "Katalog",
|
||||||
"cliAppsFilter": "Saring",
|
"cliAppsFilter": "Saring",
|
||||||
"engine": "Mesin",
|
"engine": "Mesin",
|
||||||
@@ -204,6 +205,7 @@
|
|||||||
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
||||||
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
||||||
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
||||||
|
"browserNotifications": "Beri tahu hanya saat halaman ini di latar belakang. Nonaktif secara bawaan.",
|
||||||
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
||||||
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
||||||
"logs": "Buka folder log mesin asli.",
|
"logs": "Buka folder log mesin asli.",
|
||||||
@@ -999,7 +1001,8 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agen sedang berjalan",
|
"running": "Agen sedang berjalan",
|
||||||
"complete": "Agen selesai",
|
"complete": "Agen selesai",
|
||||||
"updated": "Aktivitas baru"
|
"updated": "Aktivitas baru",
|
||||||
|
"recovery": "Percakapan ini memerlukan perhatian Anda"
|
||||||
},
|
},
|
||||||
"pin": "Sematkan",
|
"pin": "Sematkan",
|
||||||
"unpin": "Lepas sematan",
|
"unpin": "Lepas sematan",
|
||||||
@@ -1178,6 +1181,11 @@
|
|||||||
"removeQuotedContext": "Hapus konteks kutipan",
|
"removeQuotedContext": "Hapus konteks kutipan",
|
||||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||||
"configureModel": "Konfigurasi model",
|
"configureModel": "Konfigurasi model",
|
||||||
|
"switchModel": "Ganti model untuk percakapan ini",
|
||||||
|
"context": {
|
||||||
|
"tooltip": "Konteks · {{tokens}}{{capacity}}",
|
||||||
|
"meterDescription": "{{context}}. {{percent}}% digunakan."
|
||||||
|
},
|
||||||
"queued": {
|
"queued": {
|
||||||
"label": "Panduan antrean",
|
"label": "Panduan antrean",
|
||||||
"guide": "Pandu",
|
"guide": "Pandu",
|
||||||
@@ -1394,6 +1402,11 @@
|
|||||||
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
|
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
|
||||||
"fileEditOpenFile": "Buka file",
|
"fileEditOpenFile": "Buka file",
|
||||||
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
|
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
|
||||||
|
"usage": {
|
||||||
|
"context": "Konteks terbaru: {{tokens}}{{capacity}} token",
|
||||||
|
"requests": "{{count}} permintaan model",
|
||||||
|
"estimated": "Termasuk penggunaan perkiraan"
|
||||||
|
},
|
||||||
"activityThinkingFor": "Berpikir selama {{duration}}",
|
"activityThinkingFor": "Berpikir selama {{duration}}",
|
||||||
"activityThought": "Selesai berpikir",
|
"activityThought": "Selesai berpikir",
|
||||||
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
||||||
@@ -1412,6 +1425,18 @@
|
|||||||
"automationSourceFallback": "Otomatisasi",
|
"automationSourceFallback": "Otomatisasi",
|
||||||
"automationTriggered": "Dipicu otomatis"
|
"automationTriggered": "Dipicu otomatis"
|
||||||
},
|
},
|
||||||
|
"recovery": {
|
||||||
|
"actionFailed": "Pemulihan gagal. Coba lagi.",
|
||||||
|
"interrupted": "Tugas terputus",
|
||||||
|
"completed": "Tugas selesai",
|
||||||
|
"failed": "Pemulihan tugas gagal",
|
||||||
|
"failedHelp": "Tugas tersimpan tidak dapat dipulihkan dengan aman. Tinjau sebelum melanjutkan.",
|
||||||
|
"resuming": "Memulihkan tugas yang terputus…",
|
||||||
|
"review": "Tinjau tugas sebelum melanjutkan. Alat tidak akan dijalankan ulang secara otomatis.",
|
||||||
|
"safeResume": "Melanjutkan dari konteks percakapan yang tersimpan.",
|
||||||
|
"dismiss": "Abaikan",
|
||||||
|
"continue": "Lanjutkan"
|
||||||
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Pratinjau gambar",
|
"title": "Pratinjau gambar",
|
||||||
"open": "Lihat gambar",
|
"open": "Lihat gambar",
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"webuiDefaultAccess": "既定の権限",
|
"webuiDefaultAccess": "既定の権限",
|
||||||
"currentModel": "現在の設定",
|
"currentModel": "現在の設定",
|
||||||
"brandLogos": "ブランドロゴ",
|
"brandLogos": "ブランドロゴ",
|
||||||
|
"browserNotifications": "タスク通知",
|
||||||
"cliAppsCatalog": "カタログ",
|
"cliAppsCatalog": "カタログ",
|
||||||
"cliAppsFilter": "フィルター",
|
"cliAppsFilter": "フィルター",
|
||||||
"engine": "エンジン",
|
"engine": "エンジン",
|
||||||
@@ -204,6 +205,7 @@
|
|||||||
"selectedModelProvider": "選択したモデルによって設定されます。",
|
"selectedModelProvider": "選択したモデルによって設定されます。",
|
||||||
"selectedModelValue": "選択したモデルによって設定されます。",
|
"selectedModelValue": "選択したモデルによって設定されます。",
|
||||||
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
|
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
|
||||||
|
"browserNotifications": "このページがバックグラウンドにある場合のみ通知します。既定ではオフです。",
|
||||||
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
||||||
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
||||||
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
||||||
@@ -999,7 +1001,8 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "エージェント実行中",
|
"running": "エージェント実行中",
|
||||||
"complete": "エージェント完了",
|
"complete": "エージェント完了",
|
||||||
"updated": "新しいアクティビティ"
|
"updated": "新しいアクティビティ",
|
||||||
|
"recovery": "この会話には対応が必要です"
|
||||||
},
|
},
|
||||||
"pin": "ピン留め",
|
"pin": "ピン留め",
|
||||||
"unpin": "ピン留めを解除",
|
"unpin": "ピン留めを解除",
|
||||||
@@ -1178,6 +1181,11 @@
|
|||||||
"removeQuotedContext": "引用したコンテキストを削除",
|
"removeQuotedContext": "引用したコンテキストを削除",
|
||||||
"modelNotConfigured": "モデルが未設定です",
|
"modelNotConfigured": "モデルが未設定です",
|
||||||
"configureModel": "モデルを設定",
|
"configureModel": "モデルを設定",
|
||||||
|
"switchModel": "この会話で使うモデルを切り替える",
|
||||||
|
"context": {
|
||||||
|
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
|
||||||
|
"meterDescription": "{{context}}。{{percent}}% 使用中"
|
||||||
|
},
|
||||||
"queued": {
|
"queued": {
|
||||||
"label": "保留中のガイド",
|
"label": "保留中のガイド",
|
||||||
"guide": "ガイド",
|
"guide": "ガイド",
|
||||||
@@ -1394,6 +1402,11 @@
|
|||||||
"fileEditShowFewerLines": "表示行数を減らす",
|
"fileEditShowFewerLines": "表示行数を減らす",
|
||||||
"fileEditOpenFile": "ファイルを開く",
|
"fileEditOpenFile": "ファイルを開く",
|
||||||
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
|
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
|
||||||
|
"usage": {
|
||||||
|
"context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン",
|
||||||
|
"requests": "モデルリクエスト {{count}} 回",
|
||||||
|
"estimated": "推定使用量を含みます"
|
||||||
|
},
|
||||||
"activityThinkingFor": "{{duration}}考えています",
|
"activityThinkingFor": "{{duration}}考えています",
|
||||||
"activityThought": "思考しました",
|
"activityThought": "思考しました",
|
||||||
"activityThoughtFor": "{{duration}}考えました",
|
"activityThoughtFor": "{{duration}}考えました",
|
||||||
@@ -1412,6 +1425,18 @@
|
|||||||
"automationSourceFallback": "自動化",
|
"automationSourceFallback": "自動化",
|
||||||
"automationTriggered": "自動実行"
|
"automationTriggered": "自動実行"
|
||||||
},
|
},
|
||||||
|
"recovery": {
|
||||||
|
"actionFailed": "復元操作に失敗しました。もう一度お試しください。",
|
||||||
|
"interrupted": "タスクが中断されました",
|
||||||
|
"completed": "タスクが完了しました",
|
||||||
|
"failed": "タスクの復元に失敗しました",
|
||||||
|
"failedHelp": "保存されたタスクを安全に復元できませんでした。続行前に確認してください。",
|
||||||
|
"resuming": "中断されたタスクを復元しています…",
|
||||||
|
"review": "続行する前にタスクを確認してください。ツールは自動的に再実行されません。",
|
||||||
|
"safeResume": "保存された会話コンテキストから続行しています。",
|
||||||
|
"dismiss": "閉じる",
|
||||||
|
"continue": "続行"
|
||||||
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "画像プレビュー",
|
"title": "画像プレビュー",
|
||||||
"open": "画像を表示",
|
"open": "画像を表示",
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"webuiDefaultAccess": "기본 권한",
|
"webuiDefaultAccess": "기본 권한",
|
||||||
"currentModel": "현재 구성",
|
"currentModel": "현재 구성",
|
||||||
"brandLogos": "브랜드 로고",
|
"brandLogos": "브랜드 로고",
|
||||||
|
"browserNotifications": "작업 알림",
|
||||||
"cliAppsCatalog": "카탈로그",
|
"cliAppsCatalog": "카탈로그",
|
||||||
"cliAppsFilter": "필터",
|
"cliAppsFilter": "필터",
|
||||||
"engine": "엔진",
|
"engine": "엔진",
|
||||||
@@ -204,6 +205,7 @@
|
|||||||
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
||||||
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
||||||
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
|
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
|
||||||
|
"browserNotifications": "이 페이지가 백그라운드에 있을 때만 알립니다. 기본값은 꺼짐입니다.",
|
||||||
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
||||||
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
||||||
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
||||||
@@ -999,7 +1001,8 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "에이전트 실행 중",
|
"running": "에이전트 실행 중",
|
||||||
"complete": "에이전트 완료",
|
"complete": "에이전트 완료",
|
||||||
"updated": "새 활동"
|
"updated": "새 활동",
|
||||||
|
"recovery": "이 대화에는 확인이 필요합니다"
|
||||||
},
|
},
|
||||||
"pin": "고정",
|
"pin": "고정",
|
||||||
"unpin": "고정 해제",
|
"unpin": "고정 해제",
|
||||||
@@ -1178,6 +1181,11 @@
|
|||||||
"removeQuotedContext": "인용한 문맥 제거",
|
"removeQuotedContext": "인용한 문맥 제거",
|
||||||
"modelNotConfigured": "모델이 설정되지 않음",
|
"modelNotConfigured": "모델이 설정되지 않음",
|
||||||
"configureModel": "모델 설정",
|
"configureModel": "모델 설정",
|
||||||
|
"switchModel": "이 대화에서 사용할 모델 전환",
|
||||||
|
"context": {
|
||||||
|
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
|
||||||
|
"meterDescription": "{{context}}. {{percent}}% 사용 중."
|
||||||
|
},
|
||||||
"queued": {
|
"queued": {
|
||||||
"label": "대기 중인 안내",
|
"label": "대기 중인 안내",
|
||||||
"guide": "안내",
|
"guide": "안내",
|
||||||
@@ -1394,6 +1402,11 @@
|
|||||||
"fileEditShowFewerLines": "줄 줄이기",
|
"fileEditShowFewerLines": "줄 줄이기",
|
||||||
"fileEditOpenFile": "파일 열기",
|
"fileEditOpenFile": "파일 열기",
|
||||||
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
||||||
|
"usage": {
|
||||||
|
"context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰",
|
||||||
|
"requests": "모델 요청 {{count}}회",
|
||||||
|
"estimated": "예상 사용량 포함"
|
||||||
|
},
|
||||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||||
"activityThought": "생각함",
|
"activityThought": "생각함",
|
||||||
"activityThoughtFor": "{{duration}} 동안 생각함",
|
"activityThoughtFor": "{{duration}} 동안 생각함",
|
||||||
@@ -1412,6 +1425,18 @@
|
|||||||
"automationSourceFallback": "자동화",
|
"automationSourceFallback": "자동화",
|
||||||
"automationTriggered": "자동 실행됨"
|
"automationTriggered": "자동 실행됨"
|
||||||
},
|
},
|
||||||
|
"recovery": {
|
||||||
|
"actionFailed": "복구 작업에 실패했습니다. 다시 시도하세요.",
|
||||||
|
"interrupted": "작업이 중단됨",
|
||||||
|
"completed": "작업 완료",
|
||||||
|
"failed": "작업 복구 실패",
|
||||||
|
"failedHelp": "저장된 작업을 안전하게 복구할 수 없습니다. 계속하기 전에 검토하세요.",
|
||||||
|
"resuming": "중단된 작업을 복구하는 중…",
|
||||||
|
"review": "계속하기 전에 작업을 검토하세요. 도구는 자동으로 다시 실행되지 않습니다.",
|
||||||
|
"safeResume": "저장된 대화 컨텍스트에서 계속합니다.",
|
||||||
|
"dismiss": "닫기",
|
||||||
|
"continue": "계속"
|
||||||
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "이미지 미리보기",
|
"title": "이미지 미리보기",
|
||||||
"open": "이미지 보기",
|
"open": "이미지 보기",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user