Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc575e518 | |||
| b551f669d6 | |||
| ddc5f6ee60 | |||
| 473941e322 | |||
| 1f575ba8de | |||
| 8f63f3271b | |||
| 9df23f7aeb | |||
| c6d1f7d5e0 | |||
| 05eaa253d1 | |||
| 699809de6e | |||
| 4f05fbd0ac | |||
| 537620909c | |||
| ed9369474f | |||
| 1fceeeb754 | |||
| 71a3437168 | |||
| 86726e0ab5 |
@@ -0,0 +1,55 @@
|
||||
name: Kustomize validate
|
||||
|
||||
# Renders every deploy/kubernetes overlay with `kustomize build` so manifest
|
||||
# drift (duplicate bases, missing patches, invalid YAML) is caught in CI
|
||||
# rather than at deploy time. Only runs when overlay or base files change.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'deploy/kubernetes/**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'release/v[0-9]*'
|
||||
paths:
|
||||
- 'deploy/kubernetes/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: kustomize-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Kustomize build (overlays)
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install kustomize
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
sudo mv kustomize /usr/local/bin/
|
||||
|
||||
- name: Render all overlays
|
||||
run: |
|
||||
failed=0
|
||||
for overlay in deploy/kubernetes/overlays/*/; do
|
||||
name="$(basename "$overlay")"
|
||||
echo "::group::$name"
|
||||
if kustomize build "$overlay"; then
|
||||
echo "::endgroup::"
|
||||
else
|
||||
echo "::endgroup::"
|
||||
echo "::error::kustomize build failed for overlay '$name'"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
+177
@@ -5,6 +5,183 @@ generated at release time from each PR's `## Changelog` section, tagged by the
|
||||
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
|
||||
website under `/releases`.
|
||||
|
||||
## [v0.10.0] — 2026-08-19
|
||||
|
||||
- [Bug fix / Test/CI] Host daemons now honor standard proxy environment variables without forwarding (#1029)
|
||||
- [UI / Feature] Route host- and session-scoped server requests to the replica holding the host's tunnel, and signal `wrong_replica` (HTTP 400 / WS 4400) so clients re-address on a miss. (#2037)
|
||||
- [Bug fix] Keep `serve-mcp` responsive to pings and additional requests during slow tool calls. (#2813)
|
||||
- [UI / Feature / Docs / Test/CI] White-label the web UI from server config with custom names, headings, safe logo assets, favicon, and optional Omnigent attribution. (#2857)
|
||||
- [Bug fix] A transient server hiccup no longer pins a session to the wrong working directory for the rest of the conversation. (#3017)
|
||||
- [Bug fix] Claude SDK agents now discover large MCP tool definitions on demand instead of loading every schema up front. (#3134)
|
||||
- [Bug fix / Docs] Sub-agents no longer inherit their parent's bundle directory (skills, local tools); resolvable children use their own, while unresolvable children never fall back to the parent's (#3567)
|
||||
- [UI / Bug fix] Native-harness sessions no longer leave a stale duplicate of your message (or of the assistant's reply) pinned to the bottom of the web transcript. (#3595)
|
||||
- [Feature] GitHub policy blocks tag pushes (`--tags` / `--follow-tags` / `refs/tags/` refspecs) by default; opt out with `deny_tag_push: false`. (#3620)
|
||||
- [Bug fix] Server URLs and log paths in `omni host status` are now proper clickable links instead of text the terminal has to guess at (#3862)
|
||||
- [UI / Bug fix / Feature] agy sessions now mirror tool calls and sub-agents into the web UI, and no longer duplicate or truncate replies (#3890)
|
||||
- [Bug fix] `force_sandbox` (and any declared `os_env.sandbox`) now actually applies to a Claude Code native session's file/shell tools, not just the terminal process (#3910)
|
||||
- [Bug fix] Native sessions no longer fail with "terminal failed to start" when the host daemon was launched from a directory that has since been deleted (#3974)
|
||||
- [UI / Feature] The server can offer several sandbox providers at once (`sandbox.providers`), and the new-session picker lists one option per provider (#4006)
|
||||
- [Bug fix / Feature / Chore] Switching between conversations is instant — background conversations stay connected, so returning to one shows messages that arrived while you were away (#4113)
|
||||
- [Bug fix] `omnigent pi` now uses each model's real context window and output limit, instead of compacting early and truncating long replies on high-context models. (#4178)
|
||||
- [Feature] Route a host's tunnel, its runners, and its session traffic to one replica so multi-replica deployments keep host-scoped requests sticky. (#4185)
|
||||
- [Bug fix] A custom agent that declares its own provider auth now launches on codex-native instead of stalling on the Codex sign-in screen. (#4208)
|
||||
- [Bug fix] `omnigent server --host 0.0.0.0` with `OMNIGENT_LOCAL_SINGLE_USER=1` no longer 401s every request and 403s the host tunnel; the single-user marker is honored on network-exposed binds, with a warning that the server serves unauthenticated requests (#4224)
|
||||
- [UI / Bug fix] New chats in a project with a default base branch now fork a fresh branch off that default instead of reopening your last-used worktree (#4229)
|
||||
- [UI] New-chat landing header uses tighter Otto sizing and responsive headline scale (#4233)
|
||||
- [Bug fix] Changing effort or model mid-conversation on a Claude Code session no longer risks wedging the terminal or silently keeping the old setting. (#4250)
|
||||
- [Bug fix] `omnigent chat <remote-url>` can now start a new conversation with an agent registered on the remote server (#4260)
|
||||
- [UI / Bug fix] Collapsed "Worked for …" rows in a chat transcript now sit at an even spacing and draw their full-width divider (#4284)
|
||||
- [UI / Bug fix] Voice dictation now inserts text at your cursor instead of appending it to the end of the composer (#4290)
|
||||
- [UI / Feature] The file panel can now browse anywhere the session can reach, not just the folder it started in (#4306)
|
||||
- [UI / Bug fix] Navigating to another session while a new one is still being created no longer yanks you into the new session when it finishes (#4307)
|
||||
- [UI / Bug fix] New polly and debby sessions now show the same "Starting up…" spinner as claude-code, instead of a "Connecting…" row under the composer (#4312)
|
||||
- [Bug fix] Duplicate-detection comments no longer ask you to close your issue when the matching issue is already closed — an already-fixed match now asks whether you're on a build with the fix, and treats a still-reproducing report as a regression. (#4313)
|
||||
- [Bug fix] Kimi and Hermes sessions launch again — their version checks compared against the wrong version series and rejected every current CLI. (#4314)
|
||||
- [UI / Feature] Organizations can preconfigure Omnigent server URLs through Android managed configuration, and they show up ready to tap in the app's server list. (#4315)
|
||||
- [Feature] `omnigent host --background` starts the local server and registers this machine as a host without tying up a terminal. (#4317)
|
||||
- [UI / Breaking] Reverts the shared-session approval-authority and message-attribution features (#2150 stack); session approvals are again available to any shared editor. (#4318)
|
||||
- [UI] Administrators can preset the iOS app's server URLs with a managed app configuration, so managed users pick their organization's server instead of typing it (#4319)
|
||||
- [Bug fix] Fixed a bug where an archived session (or an archived sub-agent) could be gated as if it had spent nothing, letting a tool call proceed over its actual budget. (#4320)
|
||||
- [Feature] `omnigent start` starts the local server and registers this machine as a host — the on switch to go with `omnigent stop`. (#4321)
|
||||
- [Bug fix / Test/CI] Fix `omnidev` restart loop on Linux caused by non-mutating file access events (#4330)
|
||||
- [Feature / Docs] Issue triage now explains its impact assessment and priority in one bot comment instead of adding severity labels. (#4334)
|
||||
- [UI / Bug fix] Opening the left sidebar no longer squeezes the chat below its minimum width — the browser/workspace panel yields instead and restores its width when the sidebar collapses. (#4337)
|
||||
- [Bug fix] Pi sessions on a Databricks workspace no longer hang when the workspace model list is unavailable — non-Claude models are routed by family, and a model that truly can't be served now says so instead of never replying (#4339)
|
||||
- [Chore] Files in the viewer load faster — workspace-file reads are now gzipped, cutting a 1 MB text file from ~2.3 s to ~1.3 s (#4341)
|
||||
- [Bug fix / Chore] A claude-native session no longer shows "Working…" forever when Claude exits (#4344)
|
||||
- [UI] The sidebar "Needs response" badge now uses the brand accent color (pink by default), matching the unread indicator. (#4346)
|
||||
- [UI] Refreshed modal styling — larger rounded corners, softer drop shadow, and roomier padding (#4347)
|
||||
- [Bug fix] Pi sessions on a proxy that exposes both Anthropic and OpenAI surfaces now send each model to the surface its family speaks, instead of routing everything through Anthropic and hanging (#4348)
|
||||
- [Bug fix] Session snapshots no longer fail when a session contains a malformed legacy agent id. (#4350)
|
||||
- [Bug fix] A session whose message the runner refuses now reports the failure and its reason instead of appearing to finish successfully. (#4354)
|
||||
- [UI / Feature] Hover the collapsed sidebar toggle to peek the conversation list without pinning it open. (#4355)
|
||||
- [Bug fix] Generic-ACP / Goose / Qwen turns that fail now report the exception type instead of a blank "inner executor error: " with no detail. (#4362)
|
||||
- [UI / Bug fix] Messages sent from the chat UI no longer stop reaching the agent after a dropped network request (#4366)
|
||||
- [UI / Chore] The Files panel is now split into separate **Files** (folder tree) and **Changes** (changed files) tabs (#4367)
|
||||
- [UI / Feature] Chat messages now show their timestamp beside the Copy/Fork actions. (#4372)
|
||||
- [Bug fix] A conversation link copied from your browser now works wherever a server URL is expected, instead of failing later with an opaque "Method Not Allowed" crash (#4374)
|
||||
- [UI / Bug fix / Test/CI] Clicking a sidebar session that needs a response no longer runs its title under the "Needs response" tag, and the Inbox count badge now matches the sidebar's pink accent (#4375)
|
||||
- [UI / Bug fix] Maximized workspace panel no longer shows chat content through a transparent background in dark mode. (#4376)
|
||||
- [Bug fix] Agents now inherit your ssh-agent, so git-over-SSH and SSH-cert-authenticated tooling work in agent shells and terminals (#4377)
|
||||
- [Bug fix] The sidebar remembers your session filter across reloads instead of resetting to "All sessions" (#4381)
|
||||
- [Feature / Docs / Test/CI] Blaxel is now available as a sandbox provider for CLI and managed-host deployments. (#4383)
|
||||
- [UI] The Chat/Terminal switcher is now a segmented toggle — both views are visible at a glance and switching takes one click (#4385)
|
||||
- [Bug fix / Feature] `omnigent run --server local` runs against a local server, overriding any configured server default — and the no-AGENT `omnigent run --server ""` no longer fails with `Agent path not found: https:` (#4387)
|
||||
- [UI / Bug fix] Terminal-first sessions return to chat automatically when the runner stops or disconnects, instead of stranding on an empty "No terminals available" terminal view (#4388)
|
||||
- [Bug fix] The `build-omnigent` skill is available again in native `omnigent claude` and `omnigent codex` sessions (#4391)
|
||||
- [Bug fix] A custom ACP agent can declare the environment variables it authenticates with via `env_passthrough`, and a stalled ACP handshake now reports which call timed out instead of failing with an empty message. (#4392)
|
||||
- [Bug fix / Feature] The Copilot harness now authenticates with your existing `gh auth login` session, and a GitHub Enterprise host can be set via `omnigent setup` (#4396)
|
||||
- [Bug fix] MCP server configs can now use `${VAR}` placeholders in the `url` field, not just in `headers` — so a config can be committed to version control without hardcoding the endpoint. (#4398)
|
||||
- [Bug fix] `kimi-native` sub-agents honor `executor.config.yolo: true` (launching `kimi --yolo`) and `antigravity-native` sub-agents honor `permission_mode: bypassPermissions`, so server-spawned workers no longer stall on interactive approval prompts. (#4401)
|
||||
- [Bug fix] Resuming a claude-native session no longer drops a message sent right after the session starts. (#4403)
|
||||
- [Bug fix] A sub-agent session no longer logs a spurious "did not resolve in the parent spec" warning on every turn. (#4435)
|
||||
- [UI / Bug fix] Bulk-select sessions and move them to a project in one action via the new folder icon in the selection bar. (#4452)
|
||||
- [UI] Codex's bypass-approvals option now matches Claude's clean permission UX — no more red warning banners (#4467)
|
||||
- [Bug fix] Compaction snapshots no longer store raw image data, which cuts the size of newly written compacted conversation rows substantially. (#4470)
|
||||
- [UI / Bug fix] The new-session workspace picker now navigates to `~/…` paths and shows a clear error when a typed path doesn't exist (#4480)
|
||||
- [UI / Feature] Harness launch failures now show a clear title, cause, and suggested fix instead of a raw error code and truncated log tail. (#4485)
|
||||
- [UI / Feature] Sub-agents are now auto-assigned readable structured names (e.g. `researcher-1`) and a task-derived display label in the Agents panel (#4489)
|
||||
- [UI] Restored the down chevrons on the new-session composer's chips and made every dropdown trigger show a pointer cursor (#4493)
|
||||
- [UI / Bug fix] Modal dialogs and the workspace "Open new" menu no longer render behind the embedded browser pane (#4500)
|
||||
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4501)
|
||||
- [Bug fix] Session search no longer hangs on "Searching…" — content search is now backed by a trigram index and bounded by a timeout. (#4502)
|
||||
- [Bug fix / Test/CI] Fixes an HTTP client resource leak when OpenAI Agents SDK executors shut down. (#4508)
|
||||
- [Bug fix / Test/CI] Honor OmnigentClient timeout for ordinary Python SDK HTTP requests. (#4509)
|
||||
- [Bug fix] Sessions ride out brief runner-tunnel drops (laptop sleep-wake, ingress recycles) without flashing Failed or truncating the streaming reply. (#4516)
|
||||
- [Bug fix] `omnigent` no longer crashes on startup when your shell sets a SOCKS proxy (e.g. `ALL_PROXY=socks5://…`) and the `httpx[socks]` extra isn't installed (#4517)
|
||||
- [UI / Bug fix] Attaching an unsupported file in a new chat now tells you why up front and keeps your message instead of losing it (#4519)
|
||||
- [Bug fix] `omni` now works on machines with an HTTP proxy configured, and reports an unreachable server as a clear error instead of a crash. (#4520)
|
||||
- [Bug fix] Sessions that outlive their 60-minute runner bearer no longer permanently lose runner→server auth when the token re-mint is rejected — the runner now falls back to the machine's SDK/OIDC credential. (#4521)
|
||||
- [Bug fix] Harness logs now go to a file under `~/.omnigent/logs/harness/` (or the runner's log), and a failing ACP turn quotes the agent's own error output instead of dropping it. (#4523)
|
||||
- [Bug fix] An `openai-agents` agent with no pinned model no longer fails with a confusing "install databricks-sdk" error when only OpenAI credentials are missing (#4526)
|
||||
- [Bug fix] Claude native sessions now report per-turn token usage (`gen_ai.usage.input_tokens` / `output_tokens`) to MLflow and other OpenTelemetry backends. (#4530)
|
||||
- [UI / Bug fix / Feature] Move a running session to another machine from the host badge in the composer. (#4531)
|
||||
- [Bug fix] Upgrading omnigent in place no longer breaks harness launches on already-running runners (#4539)
|
||||
- [Chore] The session-search index migration builds its Postgres indexes concurrently, so upgrades no longer block writes while the indexes build. (#4541)
|
||||
- [Feature] The Android app now opens Databricks workspaces on their `/omnigent` app instead of the workspace landing page. (#4543)
|
||||
- [Bug fix] `omnigent host` pointed at a local server that has exited now stops after ~5 minutes with a clear error instead of reconnecting forever. (#4544)
|
||||
- [Bug fix] Runners no longer crash-loop at session start when signal-handler registration fails; they log one warning and keep working. (#4545)
|
||||
- [Bug fix] Session search now returns results instead of timing out on large workspaces. (#4546)
|
||||
- [UI / Bug fix] Modal buttons like Stop session and Clone now show a spinner while the action is running, instead of just greying out. (#4548)
|
||||
- [UI / Bug fix] The desktop server picker moved from the window title bar to the bottom of the sidebar, fixing an overlap with the chat header on narrow windows — and Windows and Linux desktop now have it too (#4551)
|
||||
- [UI] The embedded terminal connects in the background and stays connected across Chat/Terminal flips and recent-session switches, so opening it is near-instant instead of reconnecting every time. (#4552)
|
||||
- [Bug fix] The Android app no longer shows the Databricks workspace navigation bar around Omnigent when connecting to a workspace-hosted server. (#4555)
|
||||
- [UI / Feature] On the macOS desktop app, the sidebar header now shares the title-bar row with the window controls — the empty strip above the sidebar and the redundant wordmark row are gone, and the Collapse/Search/Settings buttons sit beside the traffic lights. (#4557)
|
||||
- [Bug fix] Codex sessions on a ChatGPT-account or API-key login launch again, instead of failing with "model is not supported when using Codex with a ChatGPT account" (#4558)
|
||||
- [UI] Connecting the iOS app to a Databricks workspace now opens Omnigent directly and hides the workspace navigation bar (#4559)
|
||||
- [Bug fix] kiro sessions no longer fail the first message with a connection error when the kiro TUI is slow to start (#4562)
|
||||
- [Bug fix] `omnigent host` now warns once and backs off when a server accepts connections but never responds, waits out (up to 120s) a slow-booting local server instead of stranding it — stopping it if it truly fails — recovers fast runner starts after a zygote crash, and no longer leaves empty log files behind. (#4563)
|
||||
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4564)
|
||||
- [UI / Bug fix] Deleting a session removes it from the sidebar immediately instead of waiting for the server to finish tearing it down (#4566)
|
||||
- [Bug fix] Session search no longer times out on large workspaces. (#4567)
|
||||
- [UI / Bug fix] Fixed iOS controls rendering under the status bar on Databricks workspace-hosted servers (#4568)
|
||||
- [UI / Feature] You can now leave a session someone shared with you — pick "Leave session" from its sidebar row menu to clear it from your sidebar without asking the owner (#4571)
|
||||
- [UI / Bug fix] The workspace Files panel now shows hidden files by default, and its eye icon shows whether they are visible rather than what clicking will do. (#4575)
|
||||
- [Bug fix] Switching a session's agent now updates the tools its native harness can call, instead of leaving the previous agent's tools in place (#4576)
|
||||
- [Bug fix] The native pane reaper no longer kills a terminal that is actively producing output when the harness status pipeline stalls; it now checks tmux's own activity clock before reaping. (#4577)
|
||||
- [Bug fix] A silently stalled claude-native transcript forwarder now self-recovers within five minutes and logs exactly where it stalled, instead of freezing mirroring and session status indefinitely. (#4578)
|
||||
- [Bug fix] A claude-native transcript forwarder that stops — cancelled, crashed, or returned — now always logs an attributed exit line instead of dying silently. (#4579)
|
||||
- [Bug fix] A stray hook event from another Claude session can no longer silently redirect a claude-native session's transcript mirroring; session identity now changes only via SessionStart announcements. (#4580)
|
||||
- [Bug fix] `omni claude` streaming, statusline, and typing during tool-running turns now respond at native speed: hook subprocesses skip the framework's eager import graph, and the blocking hook path runs as shell + a loopback `curl` relayed by the long-lived runner instead of spawning a Python interpreter per event. (#4582)
|
||||
- [UI / Bug fix / Feature] Fork a sub-agent to promote it into a top-level session of its own. (#4584)
|
||||
- [Bug fix] Upgrading omnigent in place no longer breaks runner launches on already-running hosts (#4587)
|
||||
- [UI / Bug fix] Native-harness sessions no longer briefly drop your in-flight message bubble when an interrupt marker is reconciled at the same time. (#4591)
|
||||
- [UI / Bug fix / Chore] Native assistant text now reconciles cleanly with committed transcript messages without duplicate streaming output. (#4593)
|
||||
- [Bug fix] The embedded browser pane no longer lingers over the welcome screen after switching or disconnecting from a server (#4595)
|
||||
- [Chore] Removed the "A new version of Omnigent is available" prompt and browser PWA install support; the desktop and mobile apps remain the installable clients. (#4617)
|
||||
- [Chore] OpenTelemetry exporters and automatic instrumentors are now installed through `omnigent[tracing]` instead of the default package. (#4621)
|
||||
- [Bug fix] Development builds no longer show an update reminder for the matching final release. (#4628)
|
||||
- [Bug fix] `omni host` no longer prints a zygote traceback — and keeps copy-on-write runner forking — when started from a directory that contains an `omnigent` checkout (#4631)
|
||||
- [UI / Bug fix] Clicking a file an agent mentions in its reply now opens it, including `path:line` citations and markdown links (#4644)
|
||||
- [UI / Bug fix] Fixed long unbroken text or inline code in chat messages overflowing or getting cut off at narrow window widths. (#4651)
|
||||
- [Bug fix] Fixed a duplicate assistant message that could appear after reconnecting to a Claude Code (native) session (#4656)
|
||||
- [Bug fix / Chore] Resuming or forking a Claude Code session containing screenshots no longer duplicates image payloads into metadata and inflates the request past the context limit. (#4659)
|
||||
- [UI / Bug fix] Archiving the current session now redirects to the home page instead of leaving you on the archived session (#4671)
|
||||
- [UI / Feature] Usage page shows session costs, daily spend timeline, and breakdowns by harness and model (#4673)
|
||||
- [Bug fix] Sessions whose workspace is an omnigent checkout no longer run a different omnigent than the one you installed (#4688)
|
||||
- [Bug fix] `omnigent run --harness acp:<agent>` now launches the ACP agent you asked for instead of the first one configured (#4689)
|
||||
- [UI / Bug fix] The desktop app now auto-selects this machine after "Run on this machine" (#4691)
|
||||
- [UI / Bug fix] The managed sandbox host option (and other capability-gated UI) now appears on its own after a slow `/v1/info` probe, instead of staying hidden until a page reload. (#4694)
|
||||
- [Chore] Runner-backed resource APIs now avoid redundant session reads, improving responsiveness under load. (#4695)
|
||||
- [Bug fix] ACP agents now report cached-read tokens, so token usage reflects what was actually billed (#4699)
|
||||
- [Bug fix] Built-in ACP agents like Grok Build now appear in `omni setup` instead of being invisible (#4700)
|
||||
- [Bug fix] `omnigent run --harness acp:<slug>` now works with remote servers by resolving the slug client-side and embedding the full agent config in the spec. ACP agent settings (session_id_mode, send_model, omnigent_mcp, env_passthrough) are now preserved through embedding. (#4702)
|
||||
- [Bug fix / Feature] `/model` now switches an ACP agent's model mid-conversation instead of being ignored, and keeps the chat history (#4703)
|
||||
- [Bug fix] A host name set in `config.yaml` is now kept when no `host_id` is present — the id is generated instead of overwriting your chosen name. (#4708)
|
||||
- [Bug fix] `omnigent resume` now lists only your own sessions, not ones shared with you (#4709)
|
||||
- [UI / Bug fix] The Inbox count badge now uses the same text and background colors as the selected session item in the sidebar (#4714)
|
||||
- [UI / Feature] Multi-session delete now shows a table of worktree branches you can pick to clean up (with a tri-state select-all header), instead of blocking branch cleanup behind single-session delete (#4715)
|
||||
- [Bug fix] OpenCode 1.18.x installs are now accepted; the version gate no longer rejects users who installed OpenCode via its official upstream route. (#4725)
|
||||
- [UI / Bug fix / Chore / Test/CI] Approval prompts now name the assistant that asked (Claude Code, Codex, Cursor, Antigravity, Kiro, Goose, Qwen Code, Hermes) instead of an internal policy id (#4735)
|
||||
- [UI / Bug fix] Opening the workspace folder browser no longer flashes an "Up one level" tooltip over the listing (#4742)
|
||||
- [Feature] Kubernetes sandbox runners now use Jobs with automatic restart on crash (up to 3 retries) and a liveness probe, replacing bare Pods that required manual intervention after a failure. (#4744)
|
||||
- [UI / Bug fix] The chat transcript now detects a silently dead live connection and reconnects on its own — worst case 45 s, instantly on tab refocus — instead of freezing until the page is reloaded. (#4750)
|
||||
- [Bug fix] Chat messages sent while the terminal's Claude composer is covered by the ctrl+r history search or a hand-opened `/model` picker now dismiss the overlay and deliver, instead of silently vanishing (#4751)
|
||||
- [Bug fix] Creating a session from the web UI is faster end-to-end: the chat page opens immediately and the terminal is ready sooner — including the first session after a host restart, which no longer pays a multi-second warmup. (#4752)
|
||||
- [UI / Bug fix] Answered question and plan cards stay outside the "Worked for" fold, read as settled, and survive a page reload (#4760)
|
||||
- [UI / Feature] Web terminals now attach directly over loopback when the runner is on the same machine as the browser, cutting keystroke echo from ~250 ms to under 10 ms against a remote server. (#4763)
|
||||
- [UI / Bug fix / Test/CI] The Sessions filter is now always visible, so session filtering is discoverable without hovering the sidebar header. (#4764)
|
||||
- [Feature] New `OMNIGENT_REQUIRE_WRAPPER` env var lets operators require the CLI be run through a wrapper (e.g. `isaac omni`) and refuse direct `omni` calls (#4766)
|
||||
- [UI / Bug fix / Chore / Test/CI] Chat output stays visible above a growing composer; bottom-following readers remain pinned while readers who scroll up—even just 50px—keep the same visible content. (#4767)
|
||||
- [Bug fix] `omnidev --vite-port` once again starts the frontend on the requested port. (#4770)
|
||||
- [Feature / Test/CI] macOS desktop app now ships an Intel (x64) build alongside Apple Silicon. (#4772)
|
||||
- [UI / Feature / Docs] Usage and web-driven harness setup can now be enabled per deployment with `OMNIGENT_FEATURES`. (#4775)
|
||||
- [UI / Bug fix / Feature] In-chat errors now provide expandable diagnostics and recovery-aware Retry, copy, and dismiss actions without replaying failed input, and cancelled retry requests no longer leave stale recovery results cached. (#4787)
|
||||
- [Feature / Docs] ArgoCD quick-start overlay for deploying Omnigent with the kubernetes sandbox provider (#4788)
|
||||
- [Bug fix] `omnigent[antigravity]` no longer crashes with a protobuf gencode/runtime version mismatch on startup. (#4795)
|
||||
- [UI / Bug fix] Server URLs in copyable connection and reconnect commands are now safely quoted. (#4817)
|
||||
- [Bug fix] Resumed Codex conversations now keep the authentication provider selected in Codex configuration. (#4818)
|
||||
- [Bug fix] `omnidev omnigent` commands now keep runtime state and configuration inside their development pod. (#4822)
|
||||
- [Bug fix] Native Windows: harness CLIs (codex, pi, claude-sdk, antigravity) no longer hang on spawn due to missing `SYSTEMROOT`/`COMSPEC`; Windows drive-letter workspace paths (`C:\…`) are now accepted; pre-release harness CLI versions (e.g. `0.146.0-alpha.9.2`) no longer fail the version gate. (#4886)
|
||||
- [UI / Feature] Background tasks that keep running after a turn ends now show as a pill above the composer instead of a "Working…" spinner (#4893)
|
||||
- [UI / Bug fix] Maximizing the workspace panel in the desktop app no longer tucks its tab icons under the macOS window controls. (#4897)
|
||||
- [Feature] Configured ACP agents (e.g. Devin) and installed ACP CLI harnesses (e.g. Grok Build) now appear in the web New Chat picker, like the native harnesses (#4909)
|
||||
- [Bug fix] Managed codex, claude-sdk (Polly/Debby), and pi harnesses resolve their launch model from the workspace's Unity Catalog model services, so they no longer fail against a Databricks AI Gateway that has retired the legacy `databricks-*` model namespace (#4915)
|
||||
- [UI / Feature] Devin is now a built-in harness — set it up in `omni setup`, launch it with `--harness devin` or from the New Chat picker, no `acp:` config needed (#4920)
|
||||
- [Bug fix] `omni setup` and the New Chat picker no longer show a duplicate — or silently ignore — a built-in ACP harness you configured yourself under the same name; your own command wins. (#4927)
|
||||
- [UI] Chat error banners are now a compact centered pill with inline Retry, matching the design prototype (#4931)
|
||||
- [UI / Feature] The chat header now shows the conversation's name, its project folder, and the sub-agent path, and title bars are a consistent 48px. (#4940)
|
||||
|
||||
## [v0.9.0] — 2026-08-11
|
||||
|
||||
- [UI / Bug fix] Recent servers remain one-click connectable and now include a separate copy action. (#2555)
|
||||
|
||||
@@ -304,6 +304,11 @@ def _build_routing(
|
||||
return _build_local_llm_routing_client(server_llm), settings
|
||||
|
||||
|
||||
def _resolve_execution_timeout(cfg: dict[str, Any]) -> int:
|
||||
"""Return the configured execution limit or the RuntimeCaps default."""
|
||||
return int(cfg.get("execution_timeout") or 7200)
|
||||
|
||||
|
||||
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
"""Resolve config if needed, wire the stores, and build the app.
|
||||
|
||||
@@ -374,6 +379,7 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
routing_client, routing_settings = _build_routing(cfg, server_llm)
|
||||
|
||||
caps = RuntimeCaps(
|
||||
execution_timeout=_resolve_execution_timeout(cfg),
|
||||
default_policies=parse_default_policies(cfg.get("policies")),
|
||||
llm=server_llm,
|
||||
routing_client=routing_client,
|
||||
|
||||
@@ -45,6 +45,9 @@ spec:
|
||||
# them out of band (kubectl edit, sealed-secrets, external-secrets), and
|
||||
# selfHeal must not revert those edits. Without this, selfHeal
|
||||
# continuously overwrites live credentials with the placeholder.
|
||||
#
|
||||
# The pointer targets /data, not /stringData, because ArgoCD normalizes
|
||||
# stringData into base64 /data on the live object before diffing.
|
||||
- group: ""
|
||||
kind: Secret
|
||||
name: omnigent-secrets
|
||||
|
||||
@@ -32,10 +32,12 @@ patches:
|
||||
|
||||
# The Ingress depends on an ingress controller (nginx by default) and
|
||||
# cert-manager. ArgoCD scores an Ingress without status.loadBalancer as
|
||||
# Progressing, so on a cluster with no controller the sync stalls forever.
|
||||
# Putting it in wave 1 (everything else is implicit wave 0) means nothing
|
||||
# gates on its health. ArgoCD's kind ordering already applies it last within
|
||||
# a wave, so this only affects the health gate.
|
||||
# Progressing. Wave 1 (everything else is implicit wave 0) means no later
|
||||
# sync wave gates on its health, so the *sync* completes. The Application
|
||||
# itself may still report Progressing indefinitely on a cluster without a
|
||||
# controller — with automated + selfHeal, that keeps the Application in a
|
||||
# perpetual reconcile loop (harmless but noisy). Install the controller or
|
||||
# add a custom health check that treats the Ingress as healthy.
|
||||
- target:
|
||||
kind: Ingress
|
||||
patch: |
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
# Kubernetes sandbox runners (on-demand host Pods)
|
||||
|
||||
This Kustomize overlay turns on the **`kubernetes`** managed-sandbox provider: a
|
||||
`host_type: managed` session spawns one **runner Pod** that runs `omnigent host`
|
||||
as its container entrypoint and dials back to the server over the existing
|
||||
launch-token tunnel. It layers the RBAC + config the provider needs onto the
|
||||
base server deployment.
|
||||
`host_type: managed` session spawns a **batch/v1 Job** whose child Pod runs
|
||||
`omnigent host` as its container entrypoint and dials back to the server over the
|
||||
existing launch-token tunnel. It layers the RBAC + config the provider needs onto
|
||||
the base server deployment.
|
||||
|
||||
## Launch model: entrypoint-as-host
|
||||
|
||||
The runner Pod's container command **is** the host. An **init container**
|
||||
prepares the workspace (`mkdir` + optional `git clone`); the **main container**
|
||||
then runs `omnigent host` under a tiny PID-1 reaper. The host re-parents runner
|
||||
processes to PID 1, which the reaper reaps; SIGTERM is forwarded for graceful
|
||||
shutdown.
|
||||
The runner is launched as a **batch/v1 Job** (one Pod, `backoffLimit: 6`). The
|
||||
Job's child Pod runs `omnigent host` as its container command. An **init
|
||||
container** prepares the workspace (`mkdir` + optional `git clone`); the **main
|
||||
container** then runs `omnigent host` under a tiny PID-1 reaper. The host
|
||||
re-parents runner processes to PID 1, which the reaper reaps; SIGTERM is
|
||||
forwarded for graceful shutdown.
|
||||
|
||||
The launch token is delivered through a **per-Pod Kubernetes Secret** referenced
|
||||
The launch token is delivered through a **per-Job Kubernetes Secret** referenced
|
||||
by the Pod's `secretKeyRef` — it never enters the Pod spec, a command line, or
|
||||
an audit log. The launcher creates that Secret at provision and deletes it
|
||||
alongside the Pod at terminate.
|
||||
alongside the Job at terminate.
|
||||
|
||||
Because the host is **never started by `exec`-ing into an already-running
|
||||
container**, this provider needs **no `pods/exec` grant** — and avoids the
|
||||
exec-into-running-container class of runtime issues entirely. The server SA's
|
||||
rights are the minimum the launcher calls: create/get/delete Pods, get
|
||||
`pods/log` (start-failure diagnostics only), create/delete Secrets (the per-Pod
|
||||
token), and list events.
|
||||
rights are the minimum the launcher calls: create/get/delete Jobs,
|
||||
list/get Pods (to poll the Job's child), get `pods/log` (start-failure
|
||||
diagnostics only), create/delete Secrets (the per-Job token), and list events.
|
||||
|
||||
## Two-namespace, least-blast-radius design
|
||||
|
||||
| Namespace | Holds |
|
||||
|---|---|
|
||||
| `omnigent` | the server, its DB/PVC, its Secrets, the `omnigent-server` SA |
|
||||
| `omnigent-sandboxes` | runner Pods, the per-Pod token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
|
||||
| `omnigent-sandboxes` | runner Jobs (and their child Pods), the per-Job token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
|
||||
|
||||
The server SA's Pod/Secret rights are a **namespaced Role** bound (cross-namespace)
|
||||
to `omnigent-sandboxes` only — so a compromised server can manage runner Pods but
|
||||
**cannot** delete the server/DB Pods, read the server's Secrets, or execute
|
||||
commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
|
||||
The server SA's Job/Pod/Secret rights are a **namespaced Role** bound
|
||||
(cross-namespace) to `omnigent-sandboxes` only — so a compromised server can
|
||||
manage runner Jobs but **cannot** delete the server/DB Pods, read the server's
|
||||
Secrets, or execute commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
|
||||
the generated runner Pod is already restricted-compliant (non-root uid 1000, drop
|
||||
`ALL` caps, `seccompProfile: RuntimeDefault`, no privilege escalation).
|
||||
|
||||
@@ -83,7 +84,7 @@ runner Pod unexpectedly carries no credential:
|
||||
(`omnigent/server/managed_hosts.py`), e.g. "agent … is not a genuine built-in;
|
||||
omitting agent label".
|
||||
- A name that is not a valid label value logs a `WARNING` from
|
||||
`build_pod_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
|
||||
`build_job_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
|
||||
"agent … is not a valid omnigent.ai/agent value; runner Pod … stays
|
||||
unclassified". Note the gate upstream will already have logged this agent as
|
||||
classified, so this is the line that explains the missing label.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnigent-slack"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
description = "Slack Socket Mode bot that drives Omnigent sessions."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -133,7 +133,7 @@ from omnigent.native_terminal import (
|
||||
terminal_attach_url as _attach_url,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
|
||||
+25
-13
@@ -2971,11 +2971,13 @@ def _claim_foreground_daemon_record(
|
||||
"""
|
||||
conflict = _live_daemon_conflict(record)
|
||||
if conflict is not None:
|
||||
# server_url is None in local mode; "" makes the hint say --server "".
|
||||
stop_command = _host_stop_command(conflict.server_url or "")
|
||||
raise click.ClickException(
|
||||
"A host daemon is already running for this server "
|
||||
f"(pid={conflict.pid}, target={conflict.target}). "
|
||||
"Run `omnigent host status` to inspect it or "
|
||||
"`omnigent host stop --server ...` to stop it first."
|
||||
f"Run `omnigent host status` to inspect it or `{stop_command}` "
|
||||
"to stop it first."
|
||||
)
|
||||
previous = _find_daemon_record(record.target)
|
||||
if previous is not None and not _pid_alive(previous.pid):
|
||||
@@ -3159,9 +3161,10 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
today. A non-200 answer that carries the Databricks edge signature
|
||||
(302 to the workspace OAuth page, or a DatabricksRealm 401) means
|
||||
the run would otherwise die much later with an opaque "non-JSON
|
||||
response (status=302)" traceback from the session-create call. On a
|
||||
TTY we run the same flow ``omnigent login`` would and continue;
|
||||
headless invocations get the exact command to run instead.
|
||||
response (status=302)" traceback from the session-create call. First,
|
||||
it asks the SDK for a fresh workspace token; only then does a TTY run
|
||||
the same flow ``omnigent login`` would, while headless invocations get
|
||||
the exact command to run instead.
|
||||
|
||||
Non-Databricks postures are deliberately left alone: local accounts
|
||||
servers auto-authenticate downstream (magic-link redeem), and
|
||||
@@ -3181,6 +3184,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
import httpx as _httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.cli_auth import load_databricks_org_id, store_databricks_auth
|
||||
|
||||
try:
|
||||
probe = _httpx.get(
|
||||
@@ -3197,6 +3201,13 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
workspace_host = _databricks_workspace_login_target(server, probe)
|
||||
if workspace_host is None:
|
||||
return
|
||||
org_id = load_databricks_org_id(server)
|
||||
token = _databricks_workspace_token(workspace_host)
|
||||
if token is not None:
|
||||
refreshed_probe = _verify_databricks_server_token(server, token, org_id)
|
||||
if refreshed_probe.status_code == 200:
|
||||
store_databricks_auth(server, workspace_host, org_id=org_id)
|
||||
return
|
||||
login_cmd = f"omnigent login {server}"
|
||||
if non_interactive or not sys.stdin.isatty():
|
||||
raise click.ClickException(
|
||||
@@ -3204,11 +3215,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
f"HTTP {probe.status_code}). Run `{login_cmd}` and retry."
|
||||
)
|
||||
click.echo(f"Not signed in to {server} — running `{login_cmd}` first.")
|
||||
# Recover the ``?o=`` selector from a prior login record so a re-login
|
||||
# still targets the right workspace.
|
||||
from omnigent.cli_auth import load_databricks_org_id
|
||||
|
||||
_databricks_login(server, workspace_host, org_id=load_databricks_org_id(server))
|
||||
_databricks_login(server, workspace_host, org_id=org_id)
|
||||
|
||||
|
||||
def _ensure_backend(server: str | None) -> str:
|
||||
@@ -5735,6 +5742,7 @@ def import_session_command(
|
||||
import httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.conversation_browser import conversation_url
|
||||
from omnigent.session_import import (
|
||||
ImportSource,
|
||||
SessionImportNotFoundError,
|
||||
@@ -5840,13 +5848,17 @@ def import_session_command(
|
||||
)
|
||||
continue
|
||||
imported_count += 1
|
||||
# Surface the browser URL, not the bare id, so the user can open the
|
||||
# imported session straight into the web (where it offers the resume
|
||||
# picker). Maps a Databricks API base to its workspace SPA link.
|
||||
session_link = conversation_url(base_url, session_id)
|
||||
if is_batch:
|
||||
click.echo(
|
||||
f"Imported {item_count} item(s) from {current_source_session_id} "
|
||||
f"into {session_id}."
|
||||
f"into {session_link}"
|
||||
)
|
||||
else:
|
||||
click.echo(f"Imported {item_count} item(s) into {session_id}.")
|
||||
click.echo(f"Imported {item_count} item(s) into {session_link}")
|
||||
|
||||
if is_batch:
|
||||
click.echo(f"\nImported: {imported_count}")
|
||||
@@ -10960,7 +10972,7 @@ def _databricks_workspace_token(workspace_host: str) -> str | None:
|
||||
try:
|
||||
auth, _host = _resolve_databricks_auth(host=workspace_host)
|
||||
return auth.current_token()
|
||||
except (DatabricksAuthError, ValueError):
|
||||
except (DatabricksAuthError, ImportError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+23
-21
@@ -328,6 +328,9 @@ def _connection_refused(exc: BaseException) -> bool:
|
||||
_RECONNECT_BASE_S = 0.5
|
||||
_RECONNECT_CAP_S = 10.0
|
||||
_RECONNECT_JITTER = 0.5
|
||||
# Fresh hosts get a short auth-retry window for Databricks OAuth refreshes.
|
||||
# Established hosts retry auth failures indefinitely to preserve sessions.
|
||||
_MAX_CONSECUTIVE_AUTH_ERRORS = 3
|
||||
# Consecutive connection-refused failures against a loopback server before the
|
||||
# host exits (~5 minutes at the backoff cap). Refused on loopback means no
|
||||
# process listens on the port — the local server is gone, not unreachable.
|
||||
@@ -834,9 +837,7 @@ class HostProcess:
|
||||
self._capabilities_initialized = False
|
||||
# Consecutive login-page redirects; reset by a successful upgrade.
|
||||
self._login_redirect_streak = 0
|
||||
# Consecutive 401/403 upgrade rejections on an already-connected host;
|
||||
# reset by a successful upgrade. Gates the once-per-episode terminal
|
||||
# notice so a VPN outage doesn't spam stderr on every retry.
|
||||
# Reset by a successful upgrade or non-auth error; bounds fresh-host refresh retries.
|
||||
self._auth_retry_streak = 0
|
||||
# Consecutive connection-refused connect failures; reset by an accepted
|
||||
# upgrade or any non-refused error. Fatal past a bounded streak only
|
||||
@@ -1235,21 +1236,16 @@ class HostProcess:
|
||||
"""
|
||||
if status in _RETRYABLE_UPGRADE_STATUSES or not (400 <= status < 500):
|
||||
return None
|
||||
if status in (401, 403) and self._ever_connected:
|
||||
# A host that already completed an upgrade proved its credentials
|
||||
# and authorization are valid, so a later 401/403 is almost always
|
||||
# a network-path artifact — a dropped VPN whose corporate proxy
|
||||
# answers the upgrade with 401/403 before it reaches the server.
|
||||
# Retry forever (mirrors the login-redirect path) so a live host
|
||||
# with running sessions survives the outage and resumes when the
|
||||
# path recovers, instead of exiting and forcing a manual restart.
|
||||
if status in (401, 403):
|
||||
# Fresh hosts can race OAuth refresh; connected hosts preserve active sessions.
|
||||
self._auth_retry_streak += 1
|
||||
cause = (
|
||||
f"Connection refused (HTTP {status}): the host tunnel was "
|
||||
"rejected after it had already connected."
|
||||
cause = f"Connection refused (HTTP {status}): the host tunnel was rejected."
|
||||
should_retry = self._ever_connected or (
|
||||
self._auth_retry_streak < _MAX_CONSECUTIVE_AUTH_ERRORS
|
||||
)
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if self._auth_retry_streak == 1:
|
||||
if should_retry:
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if should_retry and self._auth_retry_streak == 1:
|
||||
# The warning above lands only in the CLI log file; print once
|
||||
# per outage so a foreground `omnigent host` isn't silent.
|
||||
print(
|
||||
@@ -1259,19 +1255,20 @@ class HostProcess:
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
if should_retry:
|
||||
return None
|
||||
if status == 401:
|
||||
return HostConnectError(
|
||||
"Authentication failed (HTTP 401): the server rejected the "
|
||||
"supplied credentials. "
|
||||
"Authentication failed (HTTP 401): the server rejected the supplied "
|
||||
f"credentials across {_MAX_CONSECUTIVE_AUTH_ERRORS} consecutive attempts. "
|
||||
+ self._credentials_fix_hint()
|
||||
+ " "
|
||||
+ self._login_fix_hint()
|
||||
)
|
||||
if status == 403:
|
||||
return HostConnectError(
|
||||
"Connection refused (HTTP 403): the credentials authenticated, "
|
||||
"but the server did not accept the host tunnel. Either your "
|
||||
"Connection refused (HTTP 403): the server repeatedly rejected the host "
|
||||
"tunnel. Either your "
|
||||
"identity is not authorized to register a host on this server, "
|
||||
"or the server is running a build that predates the host API "
|
||||
"(the /v1/hosts tunnel route). Confirm you have access and that "
|
||||
@@ -2668,6 +2665,11 @@ class HostProcess:
|
||||
# riding out a messy restart isn't killed by
|
||||
# redirects accumulated across unrelated errors.
|
||||
self._login_redirect_streak = 0
|
||||
if not (
|
||||
isinstance(exc, InvalidStatus) and exc.response.status_code in (401, 403)
|
||||
):
|
||||
# Keep the refresh window limited to consecutive auth rejections.
|
||||
self._auth_retry_streak = 0
|
||||
# Refused on loopback is decisive: nothing listens on the
|
||||
# port and no network path can heal it, so bound the
|
||||
# retries. Remote refusals retry forever (outages recover).
|
||||
|
||||
@@ -332,9 +332,11 @@ class AcpExecutor(Executor):
|
||||
self._image_supported: bool = False
|
||||
self._system_prompt_sent: bool = False
|
||||
|
||||
# ACP toolCallId → tool name, so a later tool_call_update can close the
|
||||
# right tool card with the name from the originating tool_call.
|
||||
# ACP toolCallId → tool name / rawInput from the originating tool_call, so
|
||||
# a later tool_call_update can close the right tool card, and a permission
|
||||
# request that names only the id can still say what is about to run.
|
||||
self._tool_names: dict[str, str] = {}
|
||||
self._tool_inputs: dict[str, _AcpJsonObject] = {}
|
||||
|
||||
# Context-window size (tokens) reported via ``usage_update``; surfaced by
|
||||
# :meth:`max_context_tokens` so the UI context meter fills.
|
||||
@@ -798,21 +800,35 @@ class AcpExecutor(Executor):
|
||||
# Permission (session/request_permission) → policy + elicitation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_tool_call(params: _AcpJsonObject) -> tuple[str, _AcpJsonObject]:
|
||||
def _extract_tool_call(self, params: _AcpJsonObject) -> tuple[str, _AcpJsonObject]:
|
||||
"""Pull ``(tool_name, tool_input)`` from a ``session/request_permission``.
|
||||
|
||||
ACP's ``toolCall`` carries a human ``title`` (e.g. ``"shell"``), a
|
||||
``kind`` (e.g. ``"execute"``), and a ``rawInput`` dict. We prefer the
|
||||
title, else the kind. (Vendor-specific ``_meta`` tool names — e.g.
|
||||
Goose's ``_meta.goose.toolCall.toolName`` — are not read here; ``title``
|
||||
is the portable name every ACP agent supplies.)
|
||||
``kind`` (e.g. ``"execute"``), and a ``rawInput`` dict; prefer the title,
|
||||
else the kind. An agent may instead send the request bare, carrying only
|
||||
``toolCallId``, so fall back to what the originating ``tool_call`` update
|
||||
reported for that id — it always arrives first. Without that fallback a
|
||||
bare request degrades to ``"tool"`` with no arguments, so the approval
|
||||
card cannot say what is about to run and no TOOL_CALL policy rule can
|
||||
match it (rules gate on the tool name, then read its arguments).
|
||||
|
||||
(Vendor-specific ``_meta`` tool names — e.g. Goose's
|
||||
``_meta.goose.toolCall.toolName`` — are not read here; ``title`` is the
|
||||
portable name and ``toolCallId`` the portable correlation.)
|
||||
"""
|
||||
tool_call = params.get("toolCall") or {}
|
||||
name = tool_call.get("title") or tool_call.get("kind") or "tool"
|
||||
call_id = tool_call.get("toolCallId")
|
||||
call_id = call_id if isinstance(call_id, str) else None
|
||||
name = (
|
||||
tool_call.get("title")
|
||||
or tool_call.get("kind")
|
||||
or (self._tool_names.get(call_id) if call_id else None)
|
||||
or "tool"
|
||||
)
|
||||
args = tool_call.get("rawInput")
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
cached = self._tool_inputs.get(call_id) if call_id else None
|
||||
args = cached if isinstance(cached, dict) else {}
|
||||
return str(name), args
|
||||
|
||||
async def _decide_permission(self, params: _AcpJsonObject) -> bool:
|
||||
@@ -1069,6 +1085,7 @@ class AcpExecutor(Executor):
|
||||
args = raw_input if isinstance(raw_input, dict) else {}
|
||||
if isinstance(call_id, str) and call_id:
|
||||
self._tool_names[call_id] = str(name)
|
||||
self._tool_inputs[call_id] = args
|
||||
events.append(
|
||||
ToolCallRequest(name=str(name), args=args, metadata={"call_id": call_id})
|
||||
)
|
||||
@@ -1080,6 +1097,7 @@ class AcpExecutor(Executor):
|
||||
_TOOL_STATUS_FAILED,
|
||||
):
|
||||
name = self._tool_names.pop(call_id, "tool")
|
||||
self._tool_inputs.pop(call_id, None)
|
||||
events.append(
|
||||
ToolCallComplete(
|
||||
name=name,
|
||||
|
||||
@@ -131,7 +131,12 @@ def _normalize_responses_items_for_chat(
|
||||
for item in items:
|
||||
if item.get("type") == "message":
|
||||
raw_content = item.get("content")
|
||||
if isinstance(raw_content, list):
|
||||
if item.get("role") == "assistant" and isinstance(raw_content, str):
|
||||
# The chat converter iterates assistant content expecting
|
||||
# blocks, so a plain string is walked character by character and
|
||||
# each one indexed. User strings take a different branch there.
|
||||
item = {**item, "content": [{"type": "output_text", "text": raw_content}]}
|
||||
elif isinstance(raw_content, list):
|
||||
normalized_content = _normalize_content_blocks_for_chat(raw_content)
|
||||
if normalized_content is not raw_content:
|
||||
item = {**item, "content": normalized_content}
|
||||
|
||||
@@ -712,7 +712,7 @@ class SandboxExecTransport(SandboxLifecycle):
|
||||
return self.run(
|
||||
sandbox_id,
|
||||
f"setsid nohup sh -c {shlex.quote(supervise_host_command(command))} "
|
||||
f"> {log_path} 2>&1 < /dev/null & echo launched",
|
||||
f">> {log_path} 2>&1 < /dev/null & echo launched",
|
||||
)
|
||||
|
||||
def put(self, sandbox_id: str, local_path: Path, remote_path: str) -> None:
|
||||
|
||||
@@ -1704,6 +1704,9 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
|
||||
_request_timeout=_POD_READY_REQUEST_TIMEOUT_S,
|
||||
),
|
||||
),
|
||||
# TODO(v0.29): remove this entry once all runners have rolled
|
||||
# past v0.28 — bare Pods are no longer created. Keep in sync
|
||||
# with the pods:create/delete TODO in role.yaml.
|
||||
# Fall back to deleting a bare Pod left by the pre-Job
|
||||
# launcher. Child Pods are named <job>-<rand5> so this only
|
||||
# targets pre-migration bare Pods whose name IS sandbox_id.
|
||||
|
||||
@@ -429,7 +429,7 @@ class OpenShellSandboxLauncher(SandboxLauncher):
|
||||
supervisor along with the host, which no in-sandbox loop can survive.
|
||||
"""
|
||||
script = supervise_host_command(command)
|
||||
bg_command = f"{script} > {log_path} 2>&1 < /dev/null"
|
||||
bg_command = f"{script} >> {log_path} 2>&1 < /dev/null"
|
||||
self._openshell().exec_background(
|
||||
sandbox_id, ["bash", "-lc", bg_command], timeout=_FOREGROUND_TIMEOUT_S
|
||||
)
|
||||
|
||||
@@ -12,11 +12,12 @@ from omnigent.onboarding.databricks_config import normalize_workspace_url
|
||||
from omnigent.onboarding.ucode_state import read_ucode_state
|
||||
|
||||
_UCODE_AGENT_NAMES: tuple[str, ...] = ("claude", "codex", "pi")
|
||||
# Pin to the ``main`` branch (not a SHA) so setup always tracks the latest
|
||||
# ucode. A branch ref is mutable, so uvx would otherwise serve a stale cached
|
||||
# build of an older ``main`` commit; ``--refresh-package ucode`` defeats that
|
||||
# cache and forces re-resolution of the branch HEAD on every run.
|
||||
_UCODE_GIT_REF = "main"
|
||||
# Pin ucode to a fixed commit so setup is reproducible, rather than tracking
|
||||
# ucode's ``main`` HEAD (a mutable ref that can move under us between runs and
|
||||
# break setup unexpectedly). A full SHA is immutable, so uvx caches the built
|
||||
# wheel by ref and reuses it across runs — no ``--refresh-package`` needed to
|
||||
# defeat a mutable branch's stale cache.
|
||||
_UCODE_GIT_REF = "94271a78c7139220b7333bcae91e522f95ef3af3"
|
||||
_UCODE_UVX_SOURCE = f"git+https://github.com/databricks/ucode@{_UCODE_GIT_REF}"
|
||||
|
||||
|
||||
@@ -50,8 +51,8 @@ def build_ucode_configure_command(
|
||||
"""Build the ``ucode configure`` command Omnigent runs.
|
||||
|
||||
:param ucode_command: Command prefix that invokes ucode, e.g.
|
||||
``("/usr/bin/ucode",)`` or ``("uvx", "--refresh-package", "ucode",
|
||||
"--from", "git+https://github.com/databricks/ucode@main", "ucode")``.
|
||||
``("/usr/bin/ucode",)`` or ``("uvx", "--from",
|
||||
"git+https://github.com/databricks/ucode@<sha>", "ucode")``.
|
||||
:param workspace_urls: Workspace URLs to configure. Must be non-empty.
|
||||
:param agents: ucode agent names to configure non-interactively,
|
||||
e.g. ``("claude", "codex", "pi")``.
|
||||
@@ -126,22 +127,22 @@ def ucode_workspace_exists(workspace_url: str) -> bool:
|
||||
def find_ucode_command() -> list[str]:
|
||||
"""Return a command prefix that invokes ``ucode``.
|
||||
|
||||
Prefers an ephemeral ``uvx`` run pinned to ucode's ``main`` branch so
|
||||
setup always uses the latest ucode rather than whatever (possibly stale)
|
||||
``ucode`` the user installed long ago. A locally-installed binary is used
|
||||
only as a last resort when ``uvx`` is unavailable. This ordering matters:
|
||||
an old persistently-installed ``ucode`` predates options like
|
||||
Prefers an ephemeral ``uvx`` run pinned to a fixed ucode commit, so setup
|
||||
uses a known-good ucode rather than whatever (possibly stale) ``ucode`` the
|
||||
user installed long ago. A locally-installed binary is used only as a last
|
||||
resort when ``uvx`` is unavailable. This ordering matters: an old
|
||||
persistently-installed ``ucode`` predates options like
|
||||
``configure --workspaces`` and would otherwise win and break setup.
|
||||
|
||||
:returns: Command prefix, e.g.
|
||||
``["uvx", "--refresh-package", "ucode", "--from",
|
||||
"git+https://github.com/databricks/ucode@main", "ucode"]`` or, when
|
||||
``["uvx", "--from",
|
||||
"git+https://github.com/databricks/ucode@<sha>", "ucode"]`` or, when
|
||||
``uvx`` is absent, ``["/usr/bin/ucode"]``.
|
||||
:raises click.ClickException: If neither ``uvx`` nor ``ucode`` is on PATH.
|
||||
"""
|
||||
uvx = shutil.which("uvx")
|
||||
if uvx is not None:
|
||||
return [uvx, "--refresh-package", "ucode", "--from", _UCODE_UVX_SOURCE, "ucode"]
|
||||
return [uvx, "--from", _UCODE_UVX_SOURCE, "ucode"]
|
||||
|
||||
ucode = shutil.which("ucode")
|
||||
if ucode is None:
|
||||
|
||||
@@ -158,9 +158,9 @@ from omnigent.server.schemas import (
|
||||
)
|
||||
from omnigent.spec.skill_sources import SkillSourceContext, resolve_harness_skills
|
||||
from omnigent.spec.types import AgentSpec, LocalToolInfo, SkillSpec
|
||||
from omnigent.terminals.close_codes import WS_CLOSE_TERMINAL_NOT_FOUND
|
||||
from omnigent.terminals.control_bridge import bridge_tmux_control_to_websocket
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
bridge_tmux_pty_to_websocket,
|
||||
)
|
||||
from omnigent.tools.builtins.load_skill import (
|
||||
|
||||
@@ -1785,12 +1785,13 @@ def create_app(
|
||||
host_version = host_versions.get(conn.host_id)
|
||||
if conn.runner_id is None:
|
||||
# No runner binding: an in-process executor (or a session
|
||||
# not yet dispatched) is reachable — EXCEPT an unbound fork
|
||||
# of a session that had a working directory, which must
|
||||
# rebind a host + directory first. Reporting it offline
|
||||
# routes the first message into the directory picker instead
|
||||
# of dropping it against a runner that can't start.
|
||||
runner_online = not conn.needs_workspace
|
||||
# not yet dispatched) is reachable — EXCEPT sessions that have
|
||||
# no executor to reach and must launch a runner on a host
|
||||
# first: an unbound fork (needs a workspace) or an imported
|
||||
# transcript (no live executor anywhere). Reporting those
|
||||
# offline routes the first message into the resume picker
|
||||
# instead of dropping it against a runner that can't start.
|
||||
runner_online = not (conn.needs_workspace or conn.imported)
|
||||
else:
|
||||
# Strict: reachable only if the runner tunnel is up. No
|
||||
# host-relaunch optimism — host state lives in host_online.
|
||||
|
||||
@@ -3743,13 +3743,18 @@ def _publish_status(
|
||||
# an explicit ``0`` clears it so a finished background shell drops the
|
||||
# indicator on the next turn end. ``None`` means "no information" (the
|
||||
# trailing PTY-activity ``idle`` carries none) and must NOT wipe the
|
||||
# count the Stop hook just published. A new turn or a failure clears it.
|
||||
# count the Stop hook just published. A new ``running`` turn does NOT clear
|
||||
# it either — background shells outlive turn boundaries, so the tally
|
||||
# persists across the turn (the composer pill stays lit alongside the
|
||||
# working shimmer) until the next authoritative count. Only a failure
|
||||
# clears it: a dead session may never post another count to drop a stale
|
||||
# tally.
|
||||
if background_task_count is not None:
|
||||
if background_task_count > 0:
|
||||
_session_background_task_count_cache[session_id] = background_task_count
|
||||
else:
|
||||
_session_background_task_count_cache.pop(session_id, None)
|
||||
elif status in ("running", "failed"):
|
||||
elif status == "failed":
|
||||
_session_background_task_count_cache.pop(session_id, None)
|
||||
event = SessionStatusEvent(
|
||||
type="session.status",
|
||||
|
||||
@@ -89,11 +89,13 @@ from omnigent.server.auth import LEVEL_OWNER, LEVEL_READ, AuthProvider
|
||||
from omnigent.server.routes._auth_helpers import require_access
|
||||
from omnigent.stores import ConversationStore
|
||||
from omnigent.stores.permission_store import PermissionStore
|
||||
from omnigent.terminals.control_bridge import bridge_tmux_control_to_websocket
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_INTERNAL_ERROR,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
WS_CLOSE_WRONG_REPLICA,
|
||||
)
|
||||
from omnigent.terminals.control_bridge import bridge_tmux_control_to_websocket
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
bridge_tmux_pty_to_websocket,
|
||||
)
|
||||
|
||||
|
||||
@@ -210,6 +210,12 @@ class SessionConnectivity:
|
||||
dot off while ``runner_id``/``host_id`` are still ``None`` so
|
||||
the UI prompts for a host + directory before the clone can run,
|
||||
rather than treating it as an in-process session.
|
||||
:param imported: ``True`` when this session was imported from a local
|
||||
harness transcript (the ``omnigent.import.source`` label is set).
|
||||
Like ``needs_workspace``, forces the online dot off while unbound:
|
||||
an imported transcript has no live executor anywhere, so it must
|
||||
launch a runner on a host before it can run — reporting it offline
|
||||
routes the first message into the resume picker.
|
||||
:param runner_last_seen: Epoch seconds the bound runner's tunnel was
|
||||
last observed alive, written by the replica holding the tunnel.
|
||||
``None`` when never observed (or cleared on graceful disconnect).
|
||||
@@ -221,6 +227,7 @@ class SessionConnectivity:
|
||||
runner_id: str | None
|
||||
host_id: str | None
|
||||
needs_workspace: bool
|
||||
imported: bool = False
|
||||
runner_last_seen: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1136,10 +1136,10 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
SqlConversationMetadata.id.in_(unique_ids),
|
||||
)
|
||||
).all()
|
||||
# Fork-source label is in the AP DB.
|
||||
# Connectivity markers are in the AP DB. Both signal on presence:
|
||||
# the fork-source label forces an unbound clone to pick a workspace;
|
||||
# the import-source label marks a transcript with no live executor.
|
||||
with self._conv_session("get_session_connectivity") as ap_sess:
|
||||
# One pass over the fork-source connectivity marker, which
|
||||
# signals on presence (its value is the source id).
|
||||
label_rows = ap_sess.execute(
|
||||
select(
|
||||
SqlConversationLabel.conversation_id,
|
||||
@@ -1148,17 +1148,21 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
).where(
|
||||
SqlConversationLabel.workspace_id == current_workspace_id(),
|
||||
SqlConversationLabel.conversation_id.in_(unique_ids),
|
||||
SqlConversationLabel.key.in_([FORK_SOURCE_LABEL_KEY]),
|
||||
SqlConversationLabel.key.in_([FORK_SOURCE_LABEL_KEY, IMPORT_SOURCE_LABEL_KEY]),
|
||||
)
|
||||
).all()
|
||||
needs_workspace_ids = {
|
||||
row.conversation_id for row in label_rows if row.key == FORK_SOURCE_LABEL_KEY
|
||||
}
|
||||
imported_ids = {
|
||||
row.conversation_id for row in label_rows if row.key == IMPORT_SOURCE_LABEL_KEY
|
||||
}
|
||||
return {
|
||||
row.id: SessionConnectivity(
|
||||
runner_id=row.runner_id,
|
||||
host_id=row.host_id,
|
||||
needs_workspace=row.id in needs_workspace_ids,
|
||||
imported=row.id in imported_ids,
|
||||
runner_last_seen=row.runner_last_seen,
|
||||
)
|
||||
for row in meta_rows
|
||||
|
||||
@@ -9,7 +9,43 @@ See ``designs/OMNIGENT_TERMINAL_BRIDGE.md`` for the design and the
|
||||
:mod:`omnigent.inner.terminal` for the underlying tmux machinery.
|
||||
"""
|
||||
|
||||
from omnigent.terminals.registry import TerminalListEntry, TerminalRegistry
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from omnigent.terminals.registry import TerminalListEntry, TerminalRegistry
|
||||
|
||||
# Resolved on first access (PEP 562) so importing a leaf module such as
|
||||
# ``omnigent.terminals.close_codes`` does not build the tmux registry —
|
||||
# ``registry`` reaches into ``omnigent.inner.terminal``, ~100ms of import
|
||||
# a CLI client reading a WebSocket close code has no use for.
|
||||
_REGISTRY_EXPORTS: frozenset[str] = frozenset({"TerminalListEntry", "TerminalRegistry"})
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
"""
|
||||
Import :mod:`omnigent.terminals.registry` on first attribute access.
|
||||
|
||||
:param name: A public attribute, e.g. ``"TerminalRegistry"``.
|
||||
:returns: The requested object.
|
||||
:raises AttributeError: If *name* is not exported.
|
||||
"""
|
||||
if name not in _REGISTRY_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
from omnigent.terminals import registry
|
||||
|
||||
value = getattr(registry, name)
|
||||
globals()[name] = value # cache so later reads skip __getattr__
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""
|
||||
List the package's public names without importing the registry.
|
||||
|
||||
:returns: Sorted :data:`__all__`.
|
||||
"""
|
||||
return sorted(__all__)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TerminalListEntry",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Application-level WebSocket close codes for the terminal bridges.
|
||||
|
||||
A leaf module on purpose: these codes are the wire contract between the
|
||||
server's ``/attach`` route, the runner, the native CLI client, and the
|
||||
browser, so all four need them without importing the bridge that serves
|
||||
the socket. :mod:`omnigent.terminals.ws_bridge` pulls in FastAPI and the
|
||||
tmux machinery, which a CLI client reading a close code has no use for.
|
||||
|
||||
Keep this module dependency-free. ``web/src/components/blocks/
|
||||
TerminalSession.ts`` mirrors these values on the browser side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
# RFC 6455 reserves the 4xxx band for application use. The 44xx band
|
||||
# mirrors HTTP 4xx, as 4500 mirrors 5xx.
|
||||
|
||||
# 4404 tells the client's reconnect loop to stop — sent on a
|
||||
# pre-attach lookup miss and on PTY EOF when the tmux session is
|
||||
# genuinely gone (Claude exited / the session was killed).
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND: Final[int] = 4404
|
||||
|
||||
# 4405 means the user *detached* from tmux: the ``tmux attach`` child
|
||||
# exited (PTY EOF) but the session is still alive. The client must NOT
|
||||
# treat this as a terminal-gone exit: a detach misread as 4404 would
|
||||
# tear the whole session (and runner) down.
|
||||
WS_CLOSE_TERMINAL_DETACHED: Final[int] = 4405
|
||||
|
||||
# 4400 is the WS analogue of the HTTP 400 ``wrong_replica``: the runner
|
||||
# tunnel is bound but not on this replica (the ``?omnigent_slice_key=``
|
||||
# reached a replica that doesn't hold the tunnel — the key doesn't match
|
||||
# where it lives). Unlike 4500 (a genuine failure), the request is valid
|
||||
# and just misrouted: the client re-dials keyless and reaches the replica
|
||||
# the tunnel actually lives on. Mirrors the fetch path's keyless
|
||||
# re-address on a ``wrong_replica`` 400.
|
||||
WS_CLOSE_WRONG_REPLICA: Final[int] = 4400
|
||||
|
||||
WS_CLOSE_INTERNAL_ERROR: Final[int] = 4500
|
||||
|
||||
__all__ = [
|
||||
"WS_CLOSE_INTERNAL_ERROR",
|
||||
"WS_CLOSE_TERMINAL_DETACHED",
|
||||
"WS_CLOSE_TERMINAL_NOT_FOUND",
|
||||
"WS_CLOSE_WRONG_REPLICA",
|
||||
]
|
||||
@@ -68,10 +68,12 @@ from fastapi import WebSocket, WebSocketDisconnect
|
||||
# backlog forms, and the forwarder collapses thousands of tiny per-line frames
|
||||
# into a few large ones. ``_coalesce_limit_after_input`` keeps the frame right
|
||||
# after a keystroke small so the echo stays on xterm's synchronous paint path.
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_INTERNAL_ERROR,
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
_coalesce_limit_after_input,
|
||||
_forward_pty_to_ws,
|
||||
_monotonic,
|
||||
|
||||
@@ -54,6 +54,12 @@ if sys.platform != "win32":
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_INTERNAL_ERROR,
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# 4 KiB matches tmux's own copy/redraw paths and is a good fit for
|
||||
@@ -75,26 +81,6 @@ _PANE_LIVENESS_CHECK_CACHE_S: Final[float] = 0.1 # 100ms cache to avoid per-key
|
||||
_TMUX_ATTACH_WAIT_GRACE_S: Final[float] = 0.5
|
||||
_TMUX_ATTACH_WAIT_POLL_S: Final[float] = 0.02
|
||||
|
||||
# Application-level WebSocket close codes (RFC 6455 reserves 4xxx).
|
||||
# 4404 tells the client's reconnect loop to stop — sent on a
|
||||
# pre-attach lookup miss and on PTY EOF when the tmux session is
|
||||
# genuinely gone (Claude exited / the session was killed).
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND: Final[int] = 4404
|
||||
# 4405 means the user *detached* from tmux: the ``tmux attach`` child
|
||||
# exited (PTY EOF) but the session is still alive. The client must NOT
|
||||
# treat this as a terminal-gone exit: a detach misread as 4404 would
|
||||
# tear the whole session (and runner) down.
|
||||
WS_CLOSE_TERMINAL_DETACHED: Final[int] = 4405
|
||||
# 4400 is the WS analogue of the HTTP 400 ``wrong_replica`` (the 44xx band
|
||||
# mirrors HTTP 4xx, as 4500 mirrors 5xx): the runner tunnel is bound but not on
|
||||
# this replica (the ``?omnigent_slice_key=`` reached a replica that doesn't hold
|
||||
# the tunnel — the key doesn't match where it lives). Unlike 4500 (a genuine
|
||||
# failure), the request is valid and just misrouted: the client re-dials keyless
|
||||
# and reaches the replica the tunnel actually lives on. Mirrors the fetch path's
|
||||
# keyless re-address on a ``wrong_replica`` 400.
|
||||
WS_CLOSE_WRONG_REPLICA: Final[int] = 4400
|
||||
WS_CLOSE_INTERNAL_ERROR: Final[int] = 4500
|
||||
|
||||
# A ``tmux has-session`` liveness probe is local and near-instant; cap
|
||||
# it so a wedged tmux server can't stall the bridge's teardown.
|
||||
_TMUX_HAS_SESSION_TIMEOUT_S: Final[float] = 2.0
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ the two in sync, so releases are cut by bumping pyproject alone (via
|
||||
``scripts/update_versions.py``).
|
||||
"""
|
||||
|
||||
VERSION = "0.10.0.dev0"
|
||||
VERSION = "0.11.0.dev0"
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "omnigent"
|
||||
# Keep in sync with omnigent/version.py's VERSION constant.
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
description = "Omnigent: declarative agent authoring and runtime framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -26,8 +26,8 @@ dependencies = [
|
||||
# one version, so a published `omnigent==X` must resolve the SDK wheels
|
||||
# built alongside it (release-omnigent.yml verifies these pins match the
|
||||
# release tag). Local/editable installs still resolve via tool.uv.sources.
|
||||
"omnigent-client==0.10.0.dev0",
|
||||
"omnigent-ui-sdk==0.10.0.dev0",
|
||||
"omnigent-client==0.11.0.dev0",
|
||||
"omnigent-ui-sdk==0.11.0.dev0",
|
||||
# Merged from omnigent + omnigent.
|
||||
"pyyaml>=6.0,<7",
|
||||
# OpenClaw stores its wrapped acpx registry as JSON5.
|
||||
@@ -269,7 +269,7 @@ nimble = ["nimble-python>=1.2.0,<2"]
|
||||
# (`omnigent-client`, `omnigent-ui-sdk`) and the sub-package's own
|
||||
# `[project].version` in `integrations/slack/pyproject.toml`.
|
||||
slack = [
|
||||
"omnigent-slack==0.10.0.dev0",
|
||||
"omnigent-slack==0.11.0.dev0",
|
||||
]
|
||||
databricks = [
|
||||
# Floor matches what core code was tested against when the SDK was a
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnigent-client"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
description = "Python client SDK for the omnigent server API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -16,7 +16,7 @@ dependencies = [
|
||||
# editable alongside the root ``omnigent`` package. Version-locked
|
||||
# (==) so a published SDK always pairs with the server release it
|
||||
# shipped with (release-omnigent.yml verifies the pin).
|
||||
"omnigent==0.10.0.dev0",
|
||||
"omnigent==0.11.0.dev0",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.0,<3",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnigent-ui-sdk"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
description = "Terminal UI components (Rich + prompt_toolkit) for building omnigent frontends"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -13,7 +13,7 @@ dependencies = [
|
||||
# at one version (release-omnigent.yml verifies the pin). A `>=`
|
||||
# floor would also reject pre-releases (e.g. 0.1.0rc1 < 0.1.0 in
|
||||
# PEP 440).
|
||||
"omnigent-client==0.10.0.dev0",
|
||||
"omnigent-client==0.11.0.dev0",
|
||||
"rich>=13",
|
||||
"prompt_toolkit>=3",
|
||||
"pyyaml>=6.0,<7",
|
||||
|
||||
@@ -1826,6 +1826,46 @@ def test_ensure_backend_databricks_preflight_skips_when_authenticated(
|
||||
assert result == "https://myapp-1234.aws.databricksapps.com"
|
||||
|
||||
|
||||
def test_databricks_preflight_silent_sdk_refresh_skips_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A fresh SDK token recovers an expired Databricks Apps session silently."""
|
||||
import httpx
|
||||
|
||||
responses = iter([_databricks_probe_response(302), _databricks_probe_response(200)])
|
||||
requests: list[dict[str, object]] = []
|
||||
stored: list[tuple[str, str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.chat._remote_headers",
|
||||
lambda server_url=None, *, host_id=None: {"Authorization": "Bearer expired"},
|
||||
)
|
||||
|
||||
def _get(url: str, **kwargs: object) -> object:
|
||||
requests.append(kwargs)
|
||||
return next(responses)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
monkeypatch.setattr(cli, "_databricks_workspace_token", lambda workspace: "fresh-token")
|
||||
monkeypatch.setattr(cli, "_databricks_login", lambda *args, **kwargs: pytest.fail("login"))
|
||||
monkeypatch.setattr("omnigent.cli_auth.load_databricks_org_id", lambda server: "123")
|
||||
|
||||
def _store(
|
||||
server: str,
|
||||
workspace: str,
|
||||
user_id: str | None = None,
|
||||
org_id: str | None = None,
|
||||
) -> None:
|
||||
stored.append((server, workspace, org_id))
|
||||
|
||||
monkeypatch.setattr("omnigent.cli_auth.store_databricks_auth", _store)
|
||||
|
||||
cli._ensure_databricks_server_auth(_HOST_DATABRICKS_SERVER, non_interactive=True)
|
||||
|
||||
assert requests[1]["headers"] == {"Authorization": "Bearer fresh-token"}
|
||||
assert requests[1]["params"] == {"o": "123"}
|
||||
assert stored == [(_HOST_DATABRICKS_SERVER, "https://example.databricks.com", "123")]
|
||||
|
||||
|
||||
# ── Foreground ``host`` auth pre-flight ─────────────────────────────
|
||||
#
|
||||
# ``host`` runs the same Databricks sign-in pre-flight ``run`` does before
|
||||
|
||||
@@ -66,7 +66,9 @@ def test_import_command_loads_local_session_and_posts_normalized_items(tmp_path:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "import" in _CLICK_SUBCOMMANDS
|
||||
assert "conv_imported" in result.output
|
||||
# The output surfaces the session's browser URL, not the bare id, so the
|
||||
# user can open the import straight into the web (resume picker).
|
||||
assert f"{_BASE}/c/conv_imported" in result.output
|
||||
request = route.calls.last.request
|
||||
payload = json.loads(request.content)
|
||||
assert payload == {
|
||||
|
||||
@@ -185,3 +185,16 @@ def test_build_routing_defaults_without_a_routing_block() -> None:
|
||||
client, settings = _build_routing({}, None)
|
||||
assert client is None
|
||||
assert settings == RoutingSettings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cfg", "expected_timeout"),
|
||||
[
|
||||
({"execution_timeout": 86_400}, 86_400),
|
||||
({}, 7_200),
|
||||
],
|
||||
)
|
||||
def test_resolve_execution_timeout(cfg: dict[str, int], expected_timeout: int) -> None:
|
||||
from deploy.docker.entrypoint import _resolve_execution_timeout
|
||||
|
||||
assert _resolve_execution_timeout(cfg) == expected_timeout
|
||||
|
||||
@@ -302,7 +302,8 @@ def test_cli_imports_claude_chat_into_live_server(live_server: str, tmp_path: Pa
|
||||
env=env,
|
||||
)
|
||||
|
||||
match = re.search(r"Imported \d+ item\(s\) into (\S+)\.", result.stdout)
|
||||
# Output is the session's browser URL (…/c/<id>); pull the id back out.
|
||||
match = re.search(r"Imported \d+ item\(s\) into \S+/c/(\S+)", result.stdout)
|
||||
assert match is not None, result.stdout
|
||||
session_id = match.group(1)
|
||||
session = httpx.get(
|
||||
@@ -374,7 +375,7 @@ def test_cli_imports_recent_claude_chats_as_batch(live_server: str, tmp_path: Pa
|
||||
)
|
||||
|
||||
imported = re.findall(
|
||||
r"Imported \d+ item\(s\) from (\S+) into (\S+)\.",
|
||||
r"Imported \d+ item\(s\) from (\S+) into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert [source_id for source_id, _ in imported] == list(source_session_ids[1:])
|
||||
@@ -464,7 +465,7 @@ def test_cli_imports_recent_codex_chats_as_batch(live_server: str, tmp_path: Pat
|
||||
)
|
||||
|
||||
imported = re.findall(
|
||||
r"Imported \d+ item\(s\) from (\S+) into (\S+)\.",
|
||||
r"Imported \d+ item\(s\) from (\S+) into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert [source_id for source_id, _ in imported] == list(source_session_ids[1:])
|
||||
@@ -516,7 +517,7 @@ def test_cli_force_replaces_imported_chat(
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
first_match = re.search(r"into (\S+)\.", first.stdout)
|
||||
first_match = re.search(r"into \S+/c/(\S+)", first.stdout)
|
||||
assert first_match is not None, first.stdout
|
||||
|
||||
_write_force_import_fixture(tmp_path, harness, "new prompt")
|
||||
@@ -528,7 +529,7 @@ def test_cli_force_replaces_imported_chat(
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
replaced_match = re.search(r"into (\S+)\.", replaced.stdout)
|
||||
replaced_match = re.search(r"into \S+/c/(\S+)", replaced.stdout)
|
||||
assert replaced_match is not None, replaced.stdout
|
||||
assert replaced_match.group(1) == first_match.group(1)
|
||||
|
||||
@@ -586,7 +587,7 @@ def test_cli_imports_jsonl_harness_chat_end_to_end(
|
||||
)
|
||||
|
||||
match = re.search(
|
||||
rf"Imported 2 item\(s\) from {re.escape(source_session_id)} into (\S+)\.",
|
||||
rf"Imported 2 item\(s\) from {re.escape(source_session_id)} into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert match is not None, result.stdout
|
||||
@@ -653,7 +654,7 @@ def test_cli_imports_opencode_export_end_to_end(
|
||||
)
|
||||
|
||||
match = re.search(
|
||||
rf"Imported 4 item\(s\) from {source_session_id} into (\S+)\.",
|
||||
rf"Imported 4 item\(s\) from {source_session_id} into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert match is not None, result.stdout
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""E2E: the in-chat Plan tracker renders, expands, tracks live updates, clears.
|
||||
|
||||
The task/plan tracker (``ChatPlanAccordion``) is pinned above the conversation
|
||||
and fed by the harness-agnostic ``session.todos`` contract — the claude-native
|
||||
forwarder posts it from ``TodoWrite``, the codex-native forwarder from plan
|
||||
updates. These tests drive that list straight through the Sessions events route
|
||||
(the same path the forwarders post to), so they're deterministic: no live agent
|
||||
turn is needed to produce a plan.
|
||||
|
||||
Mirrors ``chat/test_mcp_startup_indicator.py`` — seed before load to prove the
|
||||
snapshot path, then re-publish the idempotent full-state list until the live SSE
|
||||
subscription catches it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
_TRACKER = '[data-testid="plan-tracker"]'
|
||||
|
||||
_PLAN = [
|
||||
{"content": "Read the code", "status": "completed", "activeForm": "Reading the code"},
|
||||
{"content": "Write the tracker", "status": "in_progress", "activeForm": "Writing the tracker"},
|
||||
{"content": "Ship it", "status": "pending", "activeForm": "Shipping it"},
|
||||
]
|
||||
|
||||
|
||||
def _publish_todos(
|
||||
base_url: str,
|
||||
session_id: str,
|
||||
todos: list[dict[str, str]],
|
||||
) -> None:
|
||||
"""Publish the full todo list through the events route.
|
||||
|
||||
:param base_url: Base URL of the local e2e server.
|
||||
:param session_id: Session/conversation id.
|
||||
:param todos: Full list; each item is ``{"content", "status", "activeForm"}``.
|
||||
An empty list clears the tracker.
|
||||
:returns: None.
|
||||
"""
|
||||
resp = httpx.post(
|
||||
f"{base_url}/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_session_todos", "data": {"todos": todos}},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _publish_until(
|
||||
base_url: str,
|
||||
session_id: str,
|
||||
todos: list[dict[str, str]],
|
||||
expectation: Callable[[], None],
|
||||
) -> None:
|
||||
"""Re-publish the idempotent full list until the tracker reflects it.
|
||||
|
||||
The session stream is snapshot-plus-live-tail with no replay buffer, so a
|
||||
list published in the window between the page's snapshot load and its live
|
||||
SSE subscription is dropped. The list is full-state and idempotent, so
|
||||
re-publishing until the assertion passes closes the connect race without
|
||||
weakening the check.
|
||||
|
||||
:param base_url: Base URL of the local e2e server.
|
||||
:param session_id: Session/conversation id.
|
||||
:param todos: Full list to publish each attempt.
|
||||
:param expectation: Playwright ``expect`` assertion for the state the
|
||||
published list should drive; polled between re-publishes.
|
||||
:returns: None.
|
||||
"""
|
||||
deadline = time.monotonic() + 30.0
|
||||
while True:
|
||||
_publish_todos(base_url, session_id, todos)
|
||||
try:
|
||||
expectation()
|
||||
return
|
||||
except AssertionError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
|
||||
|
||||
def test_plan_tracker_renders_expands_updates_and_clears(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The Plan card seeds from the snapshot, expands on click, tracks a live
|
||||
completion, and disappears when the list is cleared.
|
||||
|
||||
:param page: Playwright page fixture.
|
||||
:param seeded_session: ``(base_url, session_id)`` from the local server.
|
||||
:returns: None.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
tracker = page.locator(_TRACKER)
|
||||
|
||||
# 1. The plan exists BEFORE the page opens: the snapshot cache must seed the
|
||||
# card on load (the refresh / reconnect channel).
|
||||
_publish_todos(base_url, session_id, _PLAN)
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
expect(tracker).to_be_visible(timeout=15_000)
|
||||
expect(tracker).to_contain_text("Plan")
|
||||
# 1 completed of 3 total.
|
||||
expect(tracker).to_contain_text("(1/3)")
|
||||
|
||||
# 2. Collapsed by default: the task list is hidden until the user expands.
|
||||
in_progress = tracker.get_by_text("Write the tracker")
|
||||
expect(in_progress).to_be_hidden()
|
||||
|
||||
# 3. Clicking the summary opens the disclosure and reveals the tasks.
|
||||
tracker.locator("summary").click()
|
||||
expect(in_progress).to_be_visible()
|
||||
expect(tracker).to_contain_text("Read the code")
|
||||
expect(tracker).to_contain_text("Ship it")
|
||||
|
||||
# 4. Live update: the agent completes the in-progress task and the count
|
||||
# advances without a reload. First live-tail-dependent step, so
|
||||
# re-publish the idempotent list until the SSE subscription receives it.
|
||||
completed_plan = [
|
||||
{"content": "Read the code", "status": "completed", "activeForm": "Reading the code"},
|
||||
{
|
||||
"content": "Write the tracker",
|
||||
"status": "completed",
|
||||
"activeForm": "Writing the tracker",
|
||||
},
|
||||
{"content": "Ship it", "status": "pending", "activeForm": "Shipping it"},
|
||||
]
|
||||
_publish_until(
|
||||
base_url,
|
||||
session_id,
|
||||
completed_plan,
|
||||
lambda: expect(tracker).to_contain_text("(2/3)", timeout=3_000),
|
||||
)
|
||||
|
||||
# 5. An empty list clears the tracker entirely — a session with no plan
|
||||
# shows no card.
|
||||
_publish_until(
|
||||
base_url,
|
||||
session_id,
|
||||
[],
|
||||
lambda: expect(tracker).to_have_count(0, timeout=3_000),
|
||||
)
|
||||
@@ -1,12 +1,19 @@
|
||||
"""Working indicator behavior when background shells outlive a turn.
|
||||
"""Working indicator behavior when background tasks run alongside a turn.
|
||||
|
||||
A claude-native turn can settle to ``idle`` while background shells keep
|
||||
running. The forwarder reports that as ``external_session_status: idle``
|
||||
carrying a positive ``background_task_count``. The turn has ended, so the
|
||||
composer's ``background-task-pill`` names the shells instead of the
|
||||
"Working…" shimmer (which would misread as the agent still thinking).
|
||||
The "Working…" shimmer and the ``background-task-pill`` are independent
|
||||
surfaces, and the background-shell tally is sticky across turn boundaries
|
||||
(shells outlive the turn that launched them):
|
||||
|
||||
Both tests drive the real status edges through the Sessions events route
|
||||
* Turn actively ``running`` with a lingering shell → BOTH show: the shimmer
|
||||
reports the live turn and the pill counts the shell.
|
||||
* Turn genuinely ended (``idle``) with a shell still running → only the pill
|
||||
shows; a "Working…" shimmer there would misread as the agent still thinking.
|
||||
|
||||
(A claude-native turn-end ``waiting`` is normalized to ``idle`` server-side —
|
||||
see ``_background_task_delivery_status`` — so the client's "working + shell"
|
||||
state is the ``running`` case above, not ``waiting``.)
|
||||
|
||||
All tests drive the real status edges through the Sessions events route
|
||||
(the same path the claude-native forwarder posts to), so they are
|
||||
deterministic — no live LLM turn, whose timing and the openai-agents
|
||||
executor's handling of a blocked mock would make the assertions flaky.
|
||||
@@ -93,18 +100,54 @@ def test_background_task_indicator_label_lifecycle(
|
||||
expect(working).to_have_count(0)
|
||||
|
||||
# 2. A new turn starts (the `running` edge a composer send produces): the
|
||||
# fresh turn supersedes the tally, so the pill gives way to the rotating
|
||||
# working shimmer (e.g. "Working…", "Cooking…"). Accept any label in the
|
||||
# pool — which one shows depends on the wall-clock bucket the turn lands
|
||||
# on.
|
||||
# background shell outlives the turn boundary, so the pill STAYS lit and
|
||||
# the rotating "Working…" shimmer lights up beside it — both surfaces at
|
||||
# once. Accept any label in the pool — which one shows depends on the
|
||||
# wall-clock bucket the turn lands on.
|
||||
_publish_status(base_url, session_id, "running")
|
||||
expect(working).to_contain_text(_WORKING_LABEL_RE, timeout=15_000)
|
||||
expect(pill).to_have_count(0)
|
||||
expect(pill).to_contain_text("2 background tasks")
|
||||
|
||||
# 3. The turn ends with the background shell finished: an authoritative
|
||||
# Stop-hook `0` clears the tally, so the indicator goes out.
|
||||
# Stop-hook `0` clears the tally, so both surfaces go out.
|
||||
_publish_status(base_url, session_id, "idle", background_task_count=0)
|
||||
expect(working).to_have_count(0, timeout=15_000)
|
||||
expect(pill).to_have_count(0)
|
||||
|
||||
|
||||
def test_working_shimmer_and_pill_coexist_while_running(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""A ``running`` turn with a lingering shell shows the shimmer AND the pill.
|
||||
|
||||
Background shells outlive turn boundaries, so when a new turn goes
|
||||
``running`` the tally persists: the "Working…" shimmer lights for the live
|
||||
turn and the pill keeps counting the shell. The two are independent
|
||||
surfaces and must show side by side — the regression this guards is the
|
||||
pill blinking off the moment the working shimmer appears.
|
||||
|
||||
:param page: Playwright page fixture.
|
||||
:param seeded_session: ``(base_url, session_id)`` from the local server
|
||||
fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
working = page.locator(_WORKING)
|
||||
pill = page.locator(_PILL)
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
# A prior turn ended with a background shell: idle + a positive count lights
|
||||
# the pill (turn over, no shimmer yet).
|
||||
_publish_status(base_url, session_id, "idle", background_task_count=2)
|
||||
expect(pill).to_contain_text("2 background tasks", timeout=15_000)
|
||||
expect(working).to_have_count(0)
|
||||
|
||||
# A new turn starts: the shell outlives the boundary, so both light up —
|
||||
# the shimmer for the live turn, the pill for the still-running shell.
|
||||
_publish_status(base_url, session_id, "running")
|
||||
expect(working).to_contain_text(_WORKING_LABEL_RE, timeout=15_000)
|
||||
expect(pill).to_contain_text("2 background tasks")
|
||||
|
||||
|
||||
def test_sidebar_spinner_ignores_background_tasks(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""E2E: prose dollar signs render literally instead of turning into math.
|
||||
|
||||
Regression guard for the "vertical letter-by-letter math soup" bug. The
|
||||
renderer used to accept a single ``$`` as a LaTeX delimiter, so an assistant
|
||||
message that mentioned money, rates, or shell variables had any two of its
|
||||
dollar signs paired up and everything between them handed to KaTeX — a
|
||||
sentence like "about $5/PR versus $2/session" rendered its middle as a stack
|
||||
of italic math variables. ``web/src/components/ai-elements/
|
||||
streamdown-security.ts`` now requires ``$$`` to open math.
|
||||
|
||||
Two deterministic assistant messages (seeded via ``external_assistant_message``
|
||||
— no LLM run) cover both halves of the contract:
|
||||
|
||||
- The **prose** paragraph keeps every dollar sign as text and renders **no**
|
||||
KaTeX at all, with the sentence intact.
|
||||
- The **math** message still renders KaTeX, so turning single-dollar math off
|
||||
did not cost us real formulas — one inline span from the ``\\(...\\)`` form
|
||||
that agents actually emit, plus one ``$$``-fenced display block.
|
||||
|
||||
Asserting on the absence of ``.katex`` (rather than on the text alone) is what
|
||||
ties this to the bug: KaTeX rewrites the characters into positioned spans, so a
|
||||
message that "contains the right text" can still be unreadable soup. The prose
|
||||
assertion is scoped to its own paragraph because the transcript groups adjacent
|
||||
assistant messages into a single bubble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
_AGENT_NAME = "hello_world"
|
||||
_ASSISTANT = '[data-testid="message-bubble"][data-role="assistant"]'
|
||||
|
||||
# The reported sentence shape. `$/PR` and `$/session` are the load-bearing
|
||||
# tokens: a `$` followed by a slash is neither currency-with-a-digit nor a
|
||||
# SCREAMING_CASE variable, so the escaping heuristics this fix replaced never
|
||||
# caught them and they paired up into one math span.
|
||||
_PROSE_TEXT = (
|
||||
"Cost check: $/PR is down but $/session is up, a 60% saving overall, "
|
||||
"and it still reads $LLM_API_KEY from the environment."
|
||||
)
|
||||
|
||||
# Real math must survive: an explicit inline TeX span (what agents emit for
|
||||
# inline math) and a `$$`-fenced display block.
|
||||
_MATH_TEXT = "\n".join(
|
||||
[
|
||||
r"The quadratic roots are \(x = 1\), from:",
|
||||
"",
|
||||
r"$$",
|
||||
r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}",
|
||||
r"$$",
|
||||
]
|
||||
)
|
||||
|
||||
_PROSE_MARKER = "Cost check:"
|
||||
_MATH_MARKER = "The quadratic roots are"
|
||||
|
||||
|
||||
def _seed_message(base_url: str, session_id: str, text: str) -> None:
|
||||
"""Append a committed assistant message to the session transcript.
|
||||
|
||||
:param base_url: The live server's base URL.
|
||||
:param session_id: The session to append to.
|
||||
:param text: The assistant message body.
|
||||
"""
|
||||
resp = httpx.post(
|
||||
f"{base_url}/v1/sessions/{session_id}/events",
|
||||
json={
|
||||
"type": "external_assistant_message",
|
||||
"data": {"agent": _AGENT_NAME, "text": text},
|
||||
},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dollar_session(seeded_session: tuple[str, str]) -> Iterator[tuple[str, str]]:
|
||||
"""Seed a session with one prose message and one real-math message.
|
||||
|
||||
:param seeded_session: ``(base_url, session_id)`` for a runner-bound session.
|
||||
:returns: the same ``(base_url, session_id)`` after both replies are seeded.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_seed_message(base_url, session_id, _PROSE_TEXT)
|
||||
_seed_message(base_url, session_id, _MATH_TEXT)
|
||||
yield (base_url, session_id)
|
||||
|
||||
|
||||
def test_prose_dollar_signs_are_not_rendered_as_math(
|
||||
page: Page,
|
||||
dollar_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Dollars in prose stay literal text; genuine math still renders."""
|
||||
base_url, session_id = dollar_session
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
bubble = page.locator(_ASSISTANT, has_text=_MATH_MARKER).first
|
||||
expect(bubble).to_be_visible(timeout=30_000)
|
||||
|
||||
prose = page.locator(f"{_ASSISTANT} p", has_text=_PROSE_MARKER).first
|
||||
expect(prose).to_be_visible(timeout=30_000)
|
||||
|
||||
# The bug: the span between two dollar signs became KaTeX math.
|
||||
assert prose.locator(".katex").count() == 0, "prose dollar signs were rendered as math"
|
||||
|
||||
# And the sentence survives intact, dollar signs and all.
|
||||
expect(prose).to_have_text(_PROSE_TEXT)
|
||||
|
||||
# The counterpart: real math must still render, or the fix went too far.
|
||||
# Exactly one display block, and the inline \(...\) span stays inline
|
||||
# rather than being promoted to a second centered block.
|
||||
expect(bubble.locator(".katex").first).to_be_visible(timeout=30_000)
|
||||
assert bubble.locator(".katex").count() == 2, (
|
||||
"expected one inline TeX span and one display block to render"
|
||||
)
|
||||
assert bubble.locator(".katex-display").count() == 1, (
|
||||
"expected the $$-fenced block to be the only display-mode formula"
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""E2E: resume a real imported / host-less session onto a chosen online host.
|
||||
|
||||
An imported session has no host binding (``host_id`` null) and no runner, so it
|
||||
used to read as a normal reachable session (or, with no executor, a terminal
|
||||
reconnect dead-end). Now the server reports an unbound imported transcript as
|
||||
``runner_online: false`` (it has no live executor anywhere), and the open view
|
||||
routes to the in-app "Resume on a machine" picker — the same bind + launch path
|
||||
(``POST /v1/hosts/{id}/runners``) the fork-resume dialog uses.
|
||||
|
||||
This drives the REAL liveness path: it imports a genuine transcript through the
|
||||
public ``POST /v1/imports`` API and never stubs ``/health`` or the session
|
||||
snapshot. The only stubs are the host-side wire the harness can't provide (it
|
||||
spawns no ``omnigent host`` daemon): the hosts list reports one online host, the
|
||||
filesystem pre-flight lists empty, and the runner launch records its body.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
|
||||
_HOST_ID = "host_imported_e2e"
|
||||
_WS_DIR = "/home/e2e/imported-repo"
|
||||
|
||||
|
||||
@pytest.mark.flaky(reruns=2, reruns_delay=5)
|
||||
def test_imported_session_resumes_onto_chosen_local_host(
|
||||
page: Page,
|
||||
live_server: str,
|
||||
) -> None:
|
||||
"""A real imported session offers the resume picker and launches a runner.
|
||||
|
||||
Failure modes this guards:
|
||||
|
||||
- The server stops reporting an unbound import as ``runner_online: false``
|
||||
(the ``imported`` connectivity marker), so it reads as reachable and the
|
||||
picker never appears.
|
||||
- The client re-applies the cold-boot startup grace to imports, so the
|
||||
picker is masked behind "Connecting…" instead of showing at once.
|
||||
- The offline routing regresses to the terminal reconnect dead-end instead
|
||||
of the picker.
|
||||
- The bind wire shape drifts (wrong session_id / workspace on the launch).
|
||||
|
||||
:param page: Playwright page fixture (fresh context per test).
|
||||
:param live_server: Base URL of the spawned server.
|
||||
"""
|
||||
base_url = live_server
|
||||
|
||||
runner_bodies: list[dict[str, Any]] = []
|
||||
|
||||
def handle_hosts(route: Route) -> None:
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"hosts": [
|
||||
{
|
||||
"host_id": _HOST_ID,
|
||||
"name": "e2e-laptop",
|
||||
"owner": "e2e",
|
||||
"status": "online",
|
||||
"sandbox_provider": None,
|
||||
"configured_harnesses": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def handle_filesystem(route: Route) -> None:
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"object": "list", "data": [], "has_more": False}),
|
||||
)
|
||||
|
||||
def handle_runners(route: Route) -> None:
|
||||
runner_bodies.append(route.request.post_data_json)
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"runner_id": "runner_imported_e2e", "status": "launching"}),
|
||||
)
|
||||
|
||||
page.route("**/v1/hosts", handle_hosts)
|
||||
page.route(f"**/v1/hosts/{_HOST_ID}/filesystem/**", handle_filesystem)
|
||||
page.route(f"**/v1/hosts/{_HOST_ID}/runners", handle_runners)
|
||||
|
||||
# Import a genuine transcript through the public API. The route stamps the
|
||||
# ``omnigent.import.source`` label and leaves the session host-less — the
|
||||
# exact state a real ``omnigent import`` produces.
|
||||
resp = httpx.post(
|
||||
f"{base_url}/v1/imports",
|
||||
json={
|
||||
"source": "claude",
|
||||
"external_session_id": f"e2e-import-{uuid4().hex}",
|
||||
"workspace": _WS_DIR,
|
||||
"items": [
|
||||
{
|
||||
"type": "message",
|
||||
"response_id": "resp_1",
|
||||
"data": {
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello from an import"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
session_id = resp.json()["session_id"]
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
# Real liveness resolves the import to local_stranded (server reports
|
||||
# runner_online=false; the client skips the cold-boot grace for imports),
|
||||
# so the disconnected banner shows. Clicking it must open the resume
|
||||
# picker, NOT the terminal reconnect dialog.
|
||||
indicator = page.get_by_test_id("disconnected-indicator")
|
||||
expect(indicator).to_be_visible(timeout=30_000)
|
||||
indicator.click()
|
||||
|
||||
dialog = page.get_by_test_id("resume-dir-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.get_by_test_id("reconnect-session-dialog")).not_to_be_visible()
|
||||
|
||||
# Prefill: the session's own workspace, host defaulted to the sole online
|
||||
# machine → the bind button is enabled without any further input.
|
||||
bind = page.get_by_test_id("resume-dir-bind-button")
|
||||
expect(bind).to_be_enabled(timeout=10_000)
|
||||
bind.click()
|
||||
|
||||
deadline = time.monotonic() + 30.0
|
||||
while not runner_bodies and time.monotonic() < deadline:
|
||||
time.sleep(0.2)
|
||||
assert len(runner_bodies) == 1, "expected exactly one runner launch"
|
||||
launch = runner_bodies[0]
|
||||
assert launch["session_id"] == session_id, launch
|
||||
assert launch["workspace"] == _WS_DIR, launch
|
||||
+14
-17
@@ -3773,43 +3773,41 @@ async def test_connected_host_auth_rejection_prints_notice_once(
|
||||
assert err.count(f"HTTP {status}") == 1
|
||||
|
||||
|
||||
async def test_fresh_host_still_fails_loud_on_auth_rejection(
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
async def test_fresh_host_retries_auth_rejection_before_failing_loud(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status: int,
|
||||
) -> None:
|
||||
"""A never-connected host still fails loud on 401/403.
|
||||
"""A never-connected host retries auth errors before failing loud.
|
||||
|
||||
The connected-host retry keys on a prior successful upgrade. A host
|
||||
that has NEVER connected and is rejected with 401/403 is genuinely
|
||||
unauthenticated / unauthorized — it must still raise HostConnectError
|
||||
on the first attempt (→ exit 1 with the fix printed), not loop.
|
||||
Databricks OAuth can refresh after daemon start but before a later
|
||||
tunnel upgrade. The host gets a few attempts, then still raises a
|
||||
clear credential failure instead of looping forever.
|
||||
"""
|
||||
spy = _ConnectSpy([_invalid_status(403)])
|
||||
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
|
||||
spy = _ConnectSpy([_invalid_status(status)])
|
||||
_patch_connect(monkeypatch, spy)
|
||||
host = _host()
|
||||
|
||||
with pytest.raises(HostConnectError):
|
||||
await host.run()
|
||||
|
||||
# Exactly one attempt → no silent retry for the fresh-host case.
|
||||
assert spy.call_count == 1
|
||||
assert spy.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,expected",
|
||||
[
|
||||
(401, "HTTP 401"),
|
||||
(403, "HTTP 403"),
|
||||
(404, "permanent"),
|
||||
],
|
||||
)
|
||||
async def test_run_fails_loud_on_permanent_4xx(
|
||||
monkeypatch: pytest.MonkeyPatch, status: int, expected: str
|
||||
) -> None:
|
||||
"""A permanent 4xx upgrade rejection fails loud on the first attempt.
|
||||
"""A non-auth permanent 4xx upgrade rejection fails loud immediately.
|
||||
|
||||
401/403/other-4xx mean unauthenticated / unauthorized / wrong-or-old
|
||||
server — reconnecting can never succeed, so run() must raise
|
||||
HostConnectError immediately rather than backing off.
|
||||
A wrong or old server cannot recover through reconnecting, so run()
|
||||
must raise HostConnectError rather than backing off.
|
||||
"""
|
||||
spy = _ConnectSpy([_invalid_status(status)])
|
||||
_patch_connect(monkeypatch, spy)
|
||||
@@ -3820,8 +3818,6 @@ async def test_run_fails_loud_on_permanent_4xx(
|
||||
|
||||
# Message identifies the specific permanent failure.
|
||||
assert expected in str(excinfo.value)
|
||||
# Exactly one attempt → no silent reconnect/backoff. If >1, the 4xx
|
||||
# was misclassified as transient and the loop kept retrying.
|
||||
assert spy.call_count == 1
|
||||
|
||||
|
||||
@@ -3838,6 +3834,7 @@ async def test_auth_rejection_suggests_omnigent_login(
|
||||
server URL, so the user can copy-paste it.
|
||||
"""
|
||||
server_url = "https://app.example.databricks.com"
|
||||
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
|
||||
spy = _ConnectSpy([_invalid_status(status)])
|
||||
_patch_connect(monkeypatch, spy)
|
||||
host = _host(server_url=server_url)
|
||||
|
||||
@@ -127,7 +127,8 @@ async def test_session_new_client_mode_generates_and_sends_id() -> None:
|
||||
|
||||
|
||||
def test_extract_tool_call_prefers_title() -> None:
|
||||
name, args = AcpExecutor._extract_tool_call(
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
name, args = ex._extract_tool_call(
|
||||
{"toolCall": {"title": "shell", "kind": "execute", "rawInput": {"command": "ls"}}}
|
||||
)
|
||||
assert name == "shell"
|
||||
@@ -135,11 +136,60 @@ def test_extract_tool_call_prefers_title() -> None:
|
||||
|
||||
|
||||
def test_extract_tool_call_falls_back_to_kind() -> None:
|
||||
name, args = AcpExecutor._extract_tool_call({"toolCall": {"kind": "read"}})
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
name, args = ex._extract_tool_call({"toolCall": {"kind": "read"}})
|
||||
assert name == "read"
|
||||
assert args == {}
|
||||
|
||||
|
||||
def test_extract_tool_call_recovers_a_bare_permission_request() -> None:
|
||||
"""A request naming only ``toolCallId`` resolves via the originating tool_call.
|
||||
|
||||
An agent may ask permission without repeating the tool: Devin sends no
|
||||
``title`` / ``kind`` / ``rawInput``, only the id it already announced. The
|
||||
``tool_call`` update always arrives first, so its name and arguments are
|
||||
still on hand.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update(
|
||||
{
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "toolu_01",
|
||||
"title": "Ran command",
|
||||
"kind": "execute",
|
||||
"rawInput": {"command": "rm -rf build"},
|
||||
}
|
||||
)
|
||||
name, args = ex._extract_tool_call(
|
||||
{
|
||||
"toolCall": {
|
||||
"toolCallId": "toolu_01",
|
||||
"_meta": {"vendor/editableCommand": "rm -rf build"},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert name == "Ran command"
|
||||
assert args == {"command": "rm -rf build"}
|
||||
|
||||
|
||||
def test_extract_tool_call_prefers_the_request_over_the_cache() -> None:
|
||||
"""A request that carries its own title/rawInput wins; the cache is a fallback."""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update(
|
||||
{"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "stale", "rawInput": {"a": 1}}
|
||||
)
|
||||
name, args = ex._extract_tool_call(
|
||||
{"toolCall": {"toolCallId": "c1", "title": "shell", "rawInput": {"command": "ls"}}}
|
||||
)
|
||||
assert (name, args) == ("shell", {"command": "ls"})
|
||||
|
||||
|
||||
def test_extract_tool_call_unknown_id_degrades_to_tool() -> None:
|
||||
"""An id we never saw announced still yields the safe generic fallback."""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
assert ex._extract_tool_call({"toolCall": {"toolCallId": "never-announced"}}) == ("tool", {})
|
||||
|
||||
|
||||
def test_permission_outcome_allow_prefers_once() -> None:
|
||||
params = {
|
||||
"options": [
|
||||
@@ -211,6 +261,31 @@ def test_tool_call_and_update_emit_cards() -> None:
|
||||
assert "c1" not in ex._tool_names # popped
|
||||
|
||||
|
||||
def test_tool_call_caches_release_on_completion() -> None:
|
||||
"""Both id-keyed caches drop the entry when the call closes.
|
||||
|
||||
They exist only to bridge a tool_call to its permission request and closing
|
||||
update, so a long session must not accumulate one entry per tool call.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update(
|
||||
{
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "c1",
|
||||
"title": "shell",
|
||||
"rawInput": {"command": "ls"},
|
||||
}
|
||||
)
|
||||
assert ex._tool_names == {"c1": "shell"}
|
||||
assert ex._tool_inputs == {"c1": {"command": "ls"}}
|
||||
|
||||
ex._handle_session_update(
|
||||
{"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed"}
|
||||
)
|
||||
assert ex._tool_names == {}
|
||||
assert ex._tool_inputs == {}
|
||||
|
||||
|
||||
def test_tool_call_update_failed_maps_to_error() -> None:
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update({"sessionUpdate": "tool_call", "toolCallId": "c2", "title": "t"})
|
||||
@@ -324,6 +399,63 @@ async def test_decide_permission_ask_without_handler_fails_closed() -> None:
|
||||
assert await ex._decide_permission({"toolCall": {"title": "shell"}}) is False
|
||||
|
||||
|
||||
def _seed_tool_call(ex: AcpExecutor) -> dict[str, object]:
|
||||
"""Announce a ``tool_call``, then return the bare permission params for it.
|
||||
|
||||
Mirrors the real frame order for an agent that asks permission without
|
||||
repeating the tool: ``tool_call`` (with the name + command) → then
|
||||
``session/request_permission`` carrying only the id.
|
||||
"""
|
||||
ex._handle_session_update(
|
||||
{
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "toolu_01",
|
||||
"title": "Ran command",
|
||||
"kind": "execute",
|
||||
"rawInput": {"command": "rm -rf build"},
|
||||
}
|
||||
)
|
||||
return {"toolCall": {"toolCallId": "toolu_01"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_permission_policy_sees_the_real_tool_call() -> None:
|
||||
"""The TOOL_CALL policy is evaluated against the resolved name + arguments.
|
||||
|
||||
Rules gate on the tool name and then read its arguments (the destructive-shell
|
||||
builtin reads ``arguments["command"]``), so a bare request evaluated as
|
||||
``{"name": "tool", "arguments": {}}`` matches nothing — a "deny ``rm -rf``"
|
||||
policy would sit silent while the command ran.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
params = _seed_tool_call(ex)
|
||||
|
||||
class _V:
|
||||
action = "POLICY_ACTION_DENY"
|
||||
|
||||
ex._policy_evaluator = AsyncMock(return_value=_V())
|
||||
assert await ex._decide_permission(params) is False
|
||||
ex._policy_evaluator.assert_awaited_once_with(
|
||||
"PHASE_TOOL_CALL",
|
||||
{"name": "Ran command", "arguments": {"command": "rm -rf build"}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_permission_card_names_the_tool() -> None:
|
||||
"""The approval card describes the call instead of an unnamed "tool".
|
||||
|
||||
The elicitation handler renders ``<tool_name>(<args>)``, so these two values
|
||||
are literally what the user reads before approving.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
params = _seed_tool_call(ex)
|
||||
ex._elicitation_handler = AsyncMock(return_value=True)
|
||||
|
||||
assert await ex._decide_permission(params) is True
|
||||
ex._elicitation_handler.assert_awaited_once_with("Ran command", {"command": "rm -rf build"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# warm model switch (session/set_config_option)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2074,6 +2074,43 @@ def test_normalize_content_blocks_strips_filename_from_input_image() -> None:
|
||||
assert result is not blocks
|
||||
|
||||
|
||||
def test_normalize_responses_items_wraps_string_assistant_content() -> None:
|
||||
"""String content must become blocks before the chat converter sees it.
|
||||
|
||||
A plain string is legal Responses-API content, but ``items_to_messages``
|
||||
iterates content expecting blocks — so it walks the string character by
|
||||
character and indexes each one, raising ``string indices must be integers,
|
||||
not 'str'``. Since history is replayed, one such item breaks every later
|
||||
turn in the conversation.
|
||||
"""
|
||||
items = [
|
||||
{"type": "message", "role": "assistant", "content": "I'll explore the repo."},
|
||||
{"type": "message", "role": "user", "content": "go ahead"},
|
||||
]
|
||||
|
||||
result = _normalize_responses_items_for_chat(items)
|
||||
|
||||
assert result[0]["content"] == [{"type": "output_text", "text": "I'll explore the repo."}]
|
||||
# User strings reach a different converter branch that accepts them, and
|
||||
# callers rely on them staying strings.
|
||||
assert result[1]["content"] == "go ahead"
|
||||
|
||||
|
||||
def test_normalize_responses_items_leaves_block_content_alone() -> None:
|
||||
"""Content that is already a block list is untouched."""
|
||||
items = [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "hi"}],
|
||||
}
|
||||
]
|
||||
|
||||
result = _normalize_responses_items_for_chat(items)
|
||||
|
||||
assert result[0]["content"] == [{"type": "output_text", "text": "hi"}]
|
||||
|
||||
|
||||
def test_normalize_content_blocks_preserves_input_image_detail_for_http_url() -> None:
|
||||
"""Supported ``input_image.detail`` survives metadata sanitization for URLs."""
|
||||
blocks = [
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_run_background_wraps_command_in_sh_c() -> None:
|
||||
[cmd] = launcher.commands
|
||||
assert cmd == (
|
||||
f"setsid nohup sh -c {shlex.quote(supervise_host_command(_HOST_LAUNCH))} "
|
||||
"> /tmp/omnigent-host.log 2>&1 < /dev/null & echo launched"
|
||||
">> /tmp/omnigent-host.log 2>&1 < /dev/null & echo launched"
|
||||
)
|
||||
# The env prefix stays attached to the launch inside the quoted script, so
|
||||
# the inner shell re-applies it on every supervised restart attempt.
|
||||
|
||||
@@ -232,7 +232,7 @@ def test_run_background_uses_exec_background(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert name == "sb-1"
|
||||
assert command[:2] == ["bash", "-lc"]
|
||||
assert "omnigent host --server https://s" in command[2]
|
||||
assert "> /tmp/host.log 2>&1 < /dev/null" in command[2]
|
||||
assert ">> /tmp/host.log 2>&1 < /dev/null" in command[2]
|
||||
assert fake.exec_calls == []
|
||||
|
||||
|
||||
|
||||
@@ -20,21 +20,20 @@ from omnigent.onboarding.ucode_setup import (
|
||||
)
|
||||
|
||||
|
||||
def test_find_ucode_command_prefers_uvx_from_main() -> None:
|
||||
"""Prefers an ephemeral uvx run pinned to main, even if ucode is installed.
|
||||
def test_find_ucode_command_prefers_uvx_pinned_commit() -> None:
|
||||
"""Prefers an ephemeral uvx run pinned to a fixed commit, even if ucode is
|
||||
installed.
|
||||
|
||||
The locally-installed binary may be stale (predating
|
||||
``configure --workspaces``), so uvx-from-main must win when both are
|
||||
present. ``--refresh-package ucode`` forces re-resolution of the mutable
|
||||
``main`` ref so the cache can't serve an old commit.
|
||||
``configure --workspaces``), so uvx-from-pinned-commit must win when both
|
||||
are present. The ref is a full SHA so setup resolves a reproducible ucode
|
||||
rather than a moving ``main`` HEAD.
|
||||
"""
|
||||
with patch("shutil.which", return_value="/usr/bin/uvx"):
|
||||
assert find_ucode_command() == [
|
||||
"/usr/bin/uvx",
|
||||
"--refresh-package",
|
||||
"ucode",
|
||||
"--from",
|
||||
"git+https://github.com/databricks/ucode@main",
|
||||
"git+https://github.com/databricks/ucode@94271a78c7139220b7333bcae91e522f95ef3af3",
|
||||
"ucode",
|
||||
]
|
||||
|
||||
@@ -88,10 +87,8 @@ def test_build_ucode_configure_command_supports_uvx_prefix() -> None:
|
||||
command = build_ucode_configure_command(
|
||||
(
|
||||
"/usr/bin/uvx",
|
||||
"--refresh-package",
|
||||
"ucode",
|
||||
"--from",
|
||||
"git+https://github.com/databricks/ucode@main",
|
||||
"git+https://github.com/databricks/ucode@94271a78c7139220b7333bcae91e522f95ef3af3",
|
||||
"ucode",
|
||||
),
|
||||
workspace_urls=("https://one.example.databricks.com",),
|
||||
@@ -99,10 +96,8 @@ def test_build_ucode_configure_command_supports_uvx_prefix() -> None:
|
||||
|
||||
assert command == [
|
||||
"/usr/bin/uvx",
|
||||
"--refresh-package",
|
||||
"ucode",
|
||||
"--from",
|
||||
"git+https://github.com/databricks/ucode@main",
|
||||
"git+https://github.com/databricks/ucode@94271a78c7139220b7333bcae91e522f95ef3af3",
|
||||
"ucode",
|
||||
"configure",
|
||||
"--workspaces",
|
||||
|
||||
@@ -6752,6 +6752,59 @@ async def test_post_external_session_todos_rejects_non_list_todos(
|
||||
assert "external_session_todos" in resp.text
|
||||
|
||||
|
||||
async def test_post_external_session_todos_filters_malformed_items(
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Individual malformed todo items are dropped before caching / broadcast.
|
||||
|
||||
The top-level payload is a valid list (so this is not the 400-rejection
|
||||
path), but ``_handle_external_session_todos`` keeps only items with a
|
||||
string ``content``, a ``status`` in {pending, in_progress, completed},
|
||||
and a string ``activeForm``. This mirrors the same filter ``sse.ts``
|
||||
applies on the live path, so a buggy forwarder version can't poison the
|
||||
snapshot or the in-chat Plan tracker with half-formed entries.
|
||||
"""
|
||||
from omnigent.server.routes import sessions as sessions_module
|
||||
|
||||
published: list[tuple[str, dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.server.routes.sessions.session_stream.publish",
|
||||
lambda sid, ev: published.append((sid, ev)),
|
||||
)
|
||||
agent = await create_test_agent(client)
|
||||
session = await _create_session(client, agent["id"])
|
||||
sessions_module._session_todos_cache.pop(session["id"], None)
|
||||
|
||||
good = {"content": "Real task", "status": "in_progress", "activeForm": "Doing it"}
|
||||
todos = [
|
||||
good,
|
||||
{"content": "Bad status", "status": "not-a-status", "activeForm": "x"},
|
||||
{"content": 123, "status": "pending", "activeForm": "x"}, # non-str content
|
||||
{"content": "No active form", "status": "completed", "activeForm": None}, # non-str
|
||||
"not-a-dict",
|
||||
{"status": "pending", "activeForm": "x"}, # missing content
|
||||
]
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session['id']}/events",
|
||||
json={"type": "external_session_todos", "data": {"todos": todos}},
|
||||
)
|
||||
assert resp.status_code in (200, 202), resp.text
|
||||
|
||||
# Only the well-formed item survives — on both the live SSE channel
|
||||
# and the cached snapshot the tracker reads on bind.
|
||||
todo_events = [ev for _sid, ev in published if ev.get("type") == "session.todos"]
|
||||
assert len(todo_events) == 1
|
||||
assert todo_events[0]["todos"] == [good]
|
||||
|
||||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||||
assert snapshot["todos"] == [good]
|
||||
finally:
|
||||
sessions_module._session_todos_cache.pop(session["id"], None)
|
||||
|
||||
|
||||
async def test_post_external_mcp_startup_publishes_session_mcp_startup(
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -6,8 +6,10 @@ in-chat "N background tasks still running" indicator survives the trailing
|
||||
PTY-activity ``idle`` (which carries no count) and a reload.
|
||||
|
||||
The tally must clear on an authoritative ``Stop``-hook ``0`` (the shell
|
||||
finished), on a new turn (``running``), and on a failure — but NOT on the
|
||||
countless trailing ``idle`` edges that carry no count. It must never make
|
||||
finished) and on a failure — but NOT on the countless trailing ``idle`` edges
|
||||
that carry no count, and NOT on a new ``running`` turn (background shells
|
||||
outlive turn boundaries, so the composer pill stays lit alongside the working
|
||||
shimmer until the next authoritative count). It must never make
|
||||
``_session_status_with_child_rollup`` report ``running``: the turn is over
|
||||
and the session takes a new message immediately.
|
||||
"""
|
||||
@@ -85,10 +87,14 @@ def test_authoritative_zero_clears_tally_and_list_drops_to_idle() -> None:
|
||||
assert _session_status_with_child_rollup(_SID, []) == "idle"
|
||||
|
||||
|
||||
def test_new_turn_running_clears_tally() -> None:
|
||||
def test_new_turn_running_keeps_tally() -> None:
|
||||
# A new ``running`` turn does NOT clear the tally: background shells outlive
|
||||
# turn boundaries, so the composer pill stays lit alongside the working
|
||||
# shimmer until the next authoritative count (the trailing ``running`` edge
|
||||
# carries none).
|
||||
_publish_status(_SID, "idle", background_task_count=2)
|
||||
_publish_status(_SID, "running")
|
||||
assert _SID not in _sessions_mod._session_background_task_count_cache
|
||||
assert _sessions_mod._session_background_task_count_cache.get(_SID) == 2
|
||||
|
||||
|
||||
def test_failure_clears_tally_and_wins_over_count() -> None:
|
||||
|
||||
@@ -803,6 +803,61 @@ async def test_health_unbound_fork_of_coding_session_reads_offline(
|
||||
assert sessions[chat_fork.id]["runner_online"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_unbound_imported_session_reads_offline(
|
||||
db_uri: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
An unbound imported transcript reads offline; a plain session online.
|
||||
|
||||
Both are unbound (no runner, no host). The ``omnigent.import.source``
|
||||
label is the difference: an import has no live executor anywhere, so it
|
||||
must launch a runner on a host first and reads ``runner_online: false``
|
||||
(routing the open view to the resume picker). A plain unbound session
|
||||
resumes in-process and stays online. Regression guard for the
|
||||
``imported`` branch of ``_bulk_session_liveness``.
|
||||
"""
|
||||
from omnigent.server.app import create_app
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||||
SqlAlchemyConversationStore,
|
||||
)
|
||||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||||
from omnigent.stores.host_store import HostStore
|
||||
|
||||
conversation_store = SqlAlchemyConversationStore(db_uri)
|
||||
host_store = HostStore(db_uri)
|
||||
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
||||
|
||||
imported = conversation_store.create_conversation()
|
||||
conversation_store.set_labels(imported.id, {"omnigent.import.source": "claude"})
|
||||
plain = conversation_store.create_conversation()
|
||||
|
||||
app = create_app(
|
||||
agent_store=SqlAlchemyAgentStore(db_uri),
|
||||
file_store=SqlAlchemyFileStore(db_uri),
|
||||
conversation_store=conversation_store,
|
||||
artifact_store=artifact_store,
|
||||
host_store=host_store,
|
||||
agent_cache=AgentCache(
|
||||
artifact_store=artifact_store,
|
||||
cache_dir=tmp_path / "cache",
|
||||
),
|
||||
)
|
||||
ids = f"{imported.id},{plain.id}"
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
resp = await c.get(f"/health?session_ids={ids}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
sessions = resp.json()["sessions"]
|
||||
# Imported → offline (needs a host). A True here means the unbound branch
|
||||
# ignored the import marker (the pre-fix behavior).
|
||||
assert sessions[imported.id]["runner_online"] is False
|
||||
# Plain unbound session → reachable in-process.
|
||||
assert sessions[plain.id]["runner_online"] is True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SeedStores:
|
||||
"""
|
||||
|
||||
@@ -4475,6 +4475,28 @@ def test_get_session_connectivity_reports_needs_workspace_for_fork(
|
||||
assert result[plain.id].needs_workspace is False
|
||||
|
||||
|
||||
def test_get_session_connectivity_reports_imported(
|
||||
conversation_store: SqlAlchemyConversationStore,
|
||||
) -> None:
|
||||
"""
|
||||
The import-source label surfaces as ``imported=True``.
|
||||
|
||||
An imported transcript carries the ``omnigent.import.source`` label and
|
||||
has no live executor, so ``get_session_connectivity`` must report
|
||||
``imported=True`` — the flag that makes ``_bulk_session_liveness`` mark
|
||||
the unbound import offline so the open view offers the resume picker
|
||||
instead of treating it as a reachable in-process session.
|
||||
"""
|
||||
imported = conversation_store.create_conversation()
|
||||
conversation_store.set_labels(imported.id, {"omnigent.import.source": "claude"})
|
||||
plain = conversation_store.create_conversation()
|
||||
|
||||
result = conversation_store.get_session_connectivity([imported.id, plain.id])
|
||||
|
||||
assert result[imported.id].imported is True
|
||||
assert result[plain.id].imported is False
|
||||
|
||||
|
||||
def test_get_session_connectivity_empty_input_skips_query(
|
||||
conversation_store: SqlAlchemyConversationStore,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Guard: the terminal close codes stay reachable without FastAPI.
|
||||
|
||||
The 4xxx close codes are the wire contract between the server's
|
||||
``/attach`` route, the runner, the native CLI client, and the browser.
|
||||
They used to live in :mod:`omnigent.terminals.ws_bridge`, so the CLI's
|
||||
``omnigent claude`` launch imported FastAPI (~120ms) and the tmux
|
||||
registry (~100ms) just to compare an integer.
|
||||
|
||||
These tests pin the codes to their published values — a change here is a
|
||||
protocol break that also needs the browser mirror updated — and pin the
|
||||
import boundary that keeps them cheap to read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from omnigent.terminals import close_codes
|
||||
|
||||
# Mirrored in ``web/src/components/blocks/TerminalSession.ts``.
|
||||
_PUBLISHED_CODES = {
|
||||
"WS_CLOSE_WRONG_REPLICA": 4400,
|
||||
"WS_CLOSE_TERMINAL_NOT_FOUND": 4404,
|
||||
"WS_CLOSE_TERMINAL_DETACHED": 4405,
|
||||
"WS_CLOSE_INTERNAL_ERROR": 4500,
|
||||
}
|
||||
|
||||
_MUST_NOT_LOAD = (
|
||||
"fastapi",
|
||||
"omnigent.inner.terminal",
|
||||
"omnigent.terminals.registry",
|
||||
"omnigent.terminals.ws_bridge",
|
||||
"starlette",
|
||||
)
|
||||
|
||||
|
||||
def test_published_close_codes_are_stable() -> None:
|
||||
"""These integers are on the wire — they cannot drift silently."""
|
||||
actual = {name: getattr(close_codes, name) for name in _PUBLISHED_CODES}
|
||||
assert actual == _PUBLISHED_CODES
|
||||
|
||||
|
||||
def test_all_lists_every_code() -> None:
|
||||
"""``__all__`` must not fall behind the module's contents."""
|
||||
assert sorted(close_codes.__all__) == sorted(_PUBLISHED_CODES)
|
||||
|
||||
|
||||
def test_reading_a_close_code_does_not_import_the_bridge() -> None:
|
||||
"""A CLI client must not pay for FastAPI to compare an integer."""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from omnigent.terminals.close_codes import WS_CLOSE_TERMINAL_NOT_FOUND\n"
|
||||
"import sys\n"
|
||||
f"print(sorted(m for m in {_MUST_NOT_LOAD!r} if m in sys.modules))",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert proc.stdout.strip() == "[]", (
|
||||
f"close_codes pulled in heavy modules: {proc.stdout.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def test_native_client_does_not_import_fastapi() -> None:
|
||||
"""``omnigent claude``'s module graph must stay FastAPI-free."""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import omnigent.claude_native\nimport sys\n"
|
||||
"print(sorted(m for m in ('fastapi', 'starlette') if m in sys.modules))",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert proc.stdout.strip() == "[]", (
|
||||
f"claude_native imports a server framework: {proc.stdout.strip()}"
|
||||
)
|
||||
@@ -29,8 +29,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import omnigent.terminals.ws_bridge as ws_bridge
|
||||
from omnigent.terminals.close_codes import WS_CLOSE_TERMINAL_DETACHED
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
_check_pane_dead_definitive,
|
||||
_forward_pty_to_ws,
|
||||
_reap_tmux_attach_child,
|
||||
|
||||
@@ -35,7 +35,7 @@ from omnigent.databricks_model_discovery import DatabricksClaudeCatalog
|
||||
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN
|
||||
from omnigent.runtime import tool_result_replay as trc
|
||||
from omnigent.spec import load_omnigent_yaml
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.13' and sys_platform != 'win32'",
|
||||
]
|
||||
@@ -1008,9 +1008,9 @@ name = "databricks-sdk"
|
||||
version = "0.67.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.13' and sys_platform != 'win32'",
|
||||
]
|
||||
@@ -1028,16 +1028,16 @@ name = "databricks-sdk"
|
||||
version = "0.115.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.13' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "requests" },
|
||||
{ name = "google-auth", marker = "extra == 'extra-8-omnigent-cwsandbox' or extra == 'extra-8-omnigent-databricks' or extra == 'extra-8-omnigent-modal' or extra == 'group-8-omnigent-dev' or extra == 'group-8-omnigent-lint' or extra != 'extra-8-omnigent-antigravity'" },
|
||||
{ name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-omnigent-cwsandbox' or extra == 'extra-8-omnigent-databricks' or extra == 'extra-8-omnigent-modal' or extra == 'group-8-omnigent-dev' or extra == 'group-8-omnigent-lint' or extra != 'extra-8-omnigent-antigravity'" },
|
||||
{ name = "requests", marker = "extra == 'extra-8-omnigent-cwsandbox' or extra == 'extra-8-omnigent-databricks' or extra == 'extra-8-omnigent-modal' or extra == 'group-8-omnigent-dev' or extra == 'group-8-omnigent-lint' or extra != 'extra-8-omnigent-antigravity'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/49/1fd8121d4517849ea67fddbd827e535871b94037fab985235c011fdc60d1/databricks_sdk-0.115.0.tar.gz", hash = "sha256:a91f219313ea1afcde9575ea083825cbcb3dde2d00cb4c858c49d9dfd61b3129", upload-time = "2026-06-08T09:43:01.138Z" }
|
||||
wheels = [
|
||||
@@ -3110,7 +3110,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "omnigent"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -3444,7 +3444,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "omnigent-client"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
source = { editable = "sdks/python-client" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -3489,7 +3489,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "omnigent-slack"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
source = { editable = "integrations/slack" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -3514,7 +3514,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "omnigent-ui-sdk"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
source = { editable = "sdks/ui" }
|
||||
dependencies = [
|
||||
{ name = "omnigent-client" },
|
||||
@@ -3525,7 +3525,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "omnigent-client", specifier = "==0.10.0.dev0" },
|
||||
{ name = "omnigent-client", specifier = "==0.11.0.dev0" },
|
||||
{ name = "prompt-toolkit", specifier = ">=3" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||
{ name = "rich", specifier = ">=13" },
|
||||
@@ -3989,7 +3989,7 @@ name = "pexpect"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ptyprocess" },
|
||||
{ name = "ptyprocess", marker = "python_full_version == '3.13.*' or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-cwsandbox') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-databricks') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-modal') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-dev') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-lint') or sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", upload-time = "2023-11-25T09:07:26.339Z" }
|
||||
wheels = [
|
||||
@@ -4252,9 +4252,9 @@ name = "protobuf"
|
||||
version = "6.33.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.13' and sys_platform != 'win32'",
|
||||
]
|
||||
@@ -4274,9 +4274,9 @@ name = "protobuf"
|
||||
version = "7.35.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.13' and sys_platform != 'win32'",
|
||||
]
|
||||
@@ -4649,7 +4649,7 @@ name = "pyte"
|
||||
version = "0.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
{ name = "wcwidth", marker = "python_full_version == '3.13.*' or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-cwsandbox') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-databricks') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-modal') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-dev') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-lint') or sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/ab/b599762933eba04de7dc5b31ae083112a6c9a9db15b01d3109ad797559d9/pyte-0.8.2.tar.gz", hash = "sha256:5af970e843fa96a97149d64e170c984721f20e52227a2f57f0a54207f08f083f", upload-time = "2023-11-12T09:33:43.217Z" }
|
||||
wheels = [
|
||||
@@ -5434,8 +5434,8 @@ name = "secretstorage"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "jeepney" },
|
||||
{ name = "cryptography", marker = "python_full_version == '3.13.*' or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-cwsandbox') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-databricks') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-modal') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-dev') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-lint') or sys_platform != 'win32'" },
|
||||
{ name = "jeepney", marker = "python_full_version == '3.13.*' or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-cwsandbox') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-databricks') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'extra-8-omnigent-modal') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-dev') or (python_full_version != '3.13.*' and extra == 'extra-8-omnigent-antigravity' and extra == 'group-8-omnigent-lint') or sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", upload-time = "2025-11-23T19:02:53.191Z" }
|
||||
wheels = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omnigent-desktop-electron",
|
||||
"productName": "Omnigent",
|
||||
"version": "0.10.0-dev.0",
|
||||
"version": "0.11.0-dev.0",
|
||||
"description": "Omnigent desktop shell (Electron edition) — a thin native wrapper around the server-served web UI.",
|
||||
"private": true,
|
||||
"main": "src/main.js",
|
||||
|
||||
@@ -2,52 +2,53 @@ import { describe, expect, it } from "vitest";
|
||||
import { normalizeExplicitMathDelimiters } from "./mathMarkdown";
|
||||
|
||||
describe("normalizeExplicitMathDelimiters — dollar handling", () => {
|
||||
it("converts TeX delimiters to dollars outside code", () => {
|
||||
expect(normalizeExplicitMathDelimiters("area is \\(a^2\\)")).toBe("area is $a^2$");
|
||||
it("converts TeX delimiters to double dollars outside code", () => {
|
||||
expect(normalizeExplicitMathDelimiters("area is \\(a^2\\)")).toBe("area is $$a^2$$");
|
||||
expect(normalizeExplicitMathDelimiters("\\[a+b\\]")).toBe("$$a+b$$");
|
||||
});
|
||||
|
||||
it("escapes currency so prose doesn't render as math", () => {
|
||||
expect(normalizeExplicitMathDelimiters("it costs $5 or $10")).toBe("it costs \\$5 or \\$10");
|
||||
it("leaves currency verbatim — a lone dollar is prose, not a delimiter", () => {
|
||||
expect(normalizeExplicitMathDelimiters("it costs $5 or $10")).toBe("it costs $5 or $10");
|
||||
// The reported bug: paired-up rate figures turned the prose between them
|
||||
// into math. `$/PR` is the shape no escaping heuristic caught — a `$`
|
||||
// before a slash. Nothing here may be escaped or rewritten.
|
||||
const rates = "$/PR versus $/session, a 60% saving";
|
||||
expect(normalizeExplicitMathDelimiters(rates)).toBe(rates);
|
||||
});
|
||||
|
||||
it("escapes shell-style env-var references so they read as literal text", () => {
|
||||
expect(normalizeExplicitMathDelimiters("Set $LLM_API_KEY")).toBe("Set \\$LLM_API_KEY");
|
||||
expect(
|
||||
normalizeExplicitMathDelimiters(
|
||||
"Unresolved environment variable '$LLM_API_KEY' referenced by 'env:LLM_API_KEY'. " +
|
||||
"Set $LLM_API_KEY or $OMNIGENT_LLM_API_KEY in the environment.",
|
||||
),
|
||||
).toBe(
|
||||
"Unresolved environment variable '\\$LLM_API_KEY' referenced by 'env:LLM_API_KEY'. " +
|
||||
"Set \\$LLM_API_KEY or \\$OMNIGENT_LLM_API_KEY in the environment.",
|
||||
);
|
||||
it("leaves shell-style env-var references verbatim", () => {
|
||||
expect(normalizeExplicitMathDelimiters("Set $LLM_API_KEY")).toBe("Set $LLM_API_KEY");
|
||||
const unresolved =
|
||||
"Unresolved environment variable '$LLM_API_KEY' referenced by 'env:LLM_API_KEY'. " +
|
||||
"Set $LLM_API_KEY or $OMNIGENT_LLM_API_KEY in the environment.";
|
||||
expect(normalizeExplicitMathDelimiters(unresolved)).toBe(unresolved);
|
||||
});
|
||||
|
||||
it("escapes braced env-var references", () => {
|
||||
it("leaves braced env-var references verbatim", () => {
|
||||
expect(normalizeExplicitMathDelimiters("use ${OMNIGENT_LLM_API_KEY} here")).toBe(
|
||||
"use \\${OMNIGENT_LLM_API_KEY} here",
|
||||
"use ${OMNIGENT_LLM_API_KEY} here",
|
||||
);
|
||||
expect(normalizeExplicitMathDelimiters("value ${A} here")).toBe("value ${A} here");
|
||||
});
|
||||
|
||||
it("escapes a single-char braced reference but not a bare single char", () => {
|
||||
// Braces disambiguate `${A}` as a variable; bare `$A …` stays inline math.
|
||||
expect(normalizeExplicitMathDelimiters("value ${A} here")).toBe("value \\${A} here");
|
||||
it("leaves a single-dollar span verbatim, whatever it contains", () => {
|
||||
// None of these are math delimiters any more, so the text passes through
|
||||
// untouched and renders as the literal characters the agent wrote.
|
||||
expect(normalizeExplicitMathDelimiters("the $A + B$ span")).toBe("the $A + B$ span");
|
||||
});
|
||||
|
||||
it("does not match a SCREAMING_CASE prefix of a mixed-case token", () => {
|
||||
// `$FOO` is a prefix of `$FOOBar`; matching it would escape the opener and
|
||||
// strand the closing `$`, breaking the surrounding inline math span.
|
||||
expect(normalizeExplicitMathDelimiters("the $FOOBar$ span")).toBe("the $FOOBar$ span");
|
||||
});
|
||||
|
||||
it("leaves genuine inline math intact", () => {
|
||||
expect(normalizeExplicitMathDelimiters("the value $x + y$ holds")).toBe(
|
||||
"the value $x + y$ holds",
|
||||
);
|
||||
});
|
||||
|
||||
it("still converts delimiters after prose dollars", () => {
|
||||
// A lone `$` must not flip the math-span state, or the conversion below it
|
||||
// would be suppressed for the rest of the message.
|
||||
expect(normalizeExplicitMathDelimiters("costs $5, so \\(x + 1\\)")).toBe(
|
||||
"costs $5, so $$x + 1$$",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves dollars inside inline code verbatim", () => {
|
||||
expect(normalizeExplicitMathDelimiters("`$LLM_API_KEY`")).toBe("`$LLM_API_KEY`");
|
||||
});
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
// A lone `$` reads as a shell-style variable reference — `$VAR_NAME` or
|
||||
// `${VAR_NAME}` — when followed by a SCREAMING_CASE identifier (an env-var
|
||||
// convention), so it's treated as literal text rather than a math opener. The
|
||||
// braces already disambiguate `${A}`, so a single char is enough there; the
|
||||
// bare form requires 2+ chars so a lone `$X …` still reads as inline math.
|
||||
// The bare form also demands a full-token boundary (`(?![A-Za-z0-9_])`) so a
|
||||
// mixed token like `$FOOBar$` isn't matched by its uppercase prefix — the
|
||||
// lookahead rejects any continuing identifier char so greedy backtracking
|
||||
// can't settle on `$FOO`; escaping only the opener would strand the closing
|
||||
// `$` and break genuine inline math.
|
||||
// Anchored at the `$`, which the caller has already matched.
|
||||
const SHELL_VAR_RE = /^\$(?:\{[A-Z_][A-Z0-9_]*\}|[A-Z_][A-Z0-9_]+(?![A-Za-z0-9_]))/;
|
||||
|
||||
// Optional 1–3 space indent + a fence run, per CommonMark. Matching the full
|
||||
// run (not just the first three chars) also keeps a ````-fenced block from
|
||||
// leaking its fourth backtick into inline-code tracking.
|
||||
@@ -18,24 +5,19 @@ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
||||
|
||||
/**
|
||||
* LLMs often emit TeX delimiters (`\(...\)` and `\[...\]`), while remark-math
|
||||
* parses dollar delimiters. Convert them to `$`/`$$`, but only where doing so
|
||||
* is safe:
|
||||
* parses dollar delimiters. Convert both to `$$`, the only math delimiter the
|
||||
* renderer honours (single-dollar math is off, so prose dollars stay prose —
|
||||
* see `STREAMDOWN_PLUGINS`), but only where doing so is safe:
|
||||
*
|
||||
* - Not inside fenced or inline code, so code examples stay verbatim.
|
||||
* - Not inside an already `$`/`$$`-delimited math span, so a LaTeX line break
|
||||
* - Not inside an already `$$`-delimited math span, so a LaTeX line break
|
||||
* (`\\[1em]`) inside `$$\begin{aligned}…\end{aligned}$$` isn't mistaken for a
|
||||
* display-math opener and turned into `\$$1em]`.
|
||||
* - A literal `\\` or `\$` is copied verbatim so its trailing `\[`/`\(` isn't
|
||||
* read as an explicit delimiter and an escaped dollar isn't re-toggled.
|
||||
*
|
||||
* A single `$` reads as literal prose rather than an inline-math opener when it
|
||||
* looks like currency ($5) or a shell-style variable reference ($LLM_API_KEY,
|
||||
* ${OMNIGENT_LLM_API_KEY}), so it's escaped and doesn't flip the math span.
|
||||
* Otherwise, with single-dollar math enabled, prose like "it costs $5 or $10"
|
||||
* or an error such as "Unresolved variable '$LLM_API_KEY' … Set $LLM_API_KEY"
|
||||
* renders as a garbled formula. (Genuine inline math that starts with a digit
|
||||
* (`$3x$`) or a SCREAMING_CASE identifier (`$N_A$`) is the rare casualty;
|
||||
* currency and env-var references in prose are far more common.)
|
||||
* `$$…$$` inside a paragraph stays inline math, so a converted `\(x\)` reads as
|
||||
* inline math and only a `$$` opening its own line renders as a display block.
|
||||
*/
|
||||
export function normalizeExplicitMathDelimiters(text: string): string {
|
||||
let result = "";
|
||||
@@ -47,7 +29,7 @@ export function normalizeExplicitMathDelimiters(text: string): string {
|
||||
// when not in inline code. Tracking the run length lets a ``…`` span close
|
||||
// only on a matching-length run, so single backticks inside it don't leak.
|
||||
let inlineCodeTicks = 0;
|
||||
// Inside a pre-existing `$…$` / `$$…$$` span (toggled on each run of `$`).
|
||||
// Inside a pre-existing `$$…$$` span (toggled on each run of 2+ `$`).
|
||||
let inMath = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
@@ -99,13 +81,10 @@ export function normalizeExplicitMathDelimiters(text: string): string {
|
||||
if (char === "$") {
|
||||
let run = 1;
|
||||
while (text[i + run] === "$") run += 1;
|
||||
const isLiteralDollar =
|
||||
run === 1 && !inMath && (/\d/.test(text[i + 1] ?? "") || SHELL_VAR_RE.test(text.slice(i)));
|
||||
if (isLiteralDollar) {
|
||||
result += "\\$";
|
||||
continue;
|
||||
}
|
||||
inMath = !inMath;
|
||||
// A lone `$` never delimits math, so it stays literal prose and leaves the
|
||||
// span state alone: "$5 … $10" must not swallow the text in between, and a
|
||||
// `\(x\)` after it still converts.
|
||||
if (run > 1) inMath = !inMath;
|
||||
result += text.slice(i, i + run);
|
||||
i += run - 1;
|
||||
continue;
|
||||
@@ -113,12 +92,7 @@ export function normalizeExplicitMathDelimiters(text: string): string {
|
||||
|
||||
if (!inMath) {
|
||||
const pair = text.slice(i, i + 2);
|
||||
if (pair === "\\(" || pair === "\\)") {
|
||||
result += "$";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (pair === "\\[" || pair === "\\]") {
|
||||
if (pair === "\\(" || pair === "\\)" || pair === "\\[" || pair === "\\]") {
|
||||
result += "$$";
|
||||
i += 1;
|
||||
continue;
|
||||
|
||||
@@ -21,7 +21,13 @@ type StreamdownHardenPlugin = StreamdownPluginTuple & {
|
||||
export const STREAMDOWN_PLUGINS = {
|
||||
cjk,
|
||||
code: lazyCodePlugin,
|
||||
math: createMathPlugin({ singleDollarTextMath: true }),
|
||||
// Only `$$…$$` opens math. A single `$` is prose far more often than it is a
|
||||
// math delimiter — currency ($5), rates ($/PR, $/session), shell variables
|
||||
// ($LLM_API_KEY) — and single-dollar math pairs any two of them up, rendering
|
||||
// the whole span between them as letter-by-letter math soup. Explicit TeX
|
||||
// delimiters (`\(…\)`, `\[…\]`), which is what LLMs emit for real math, are
|
||||
// rewritten to `$$…$$` by `normalizeExplicitMathDelimiters`.
|
||||
math: createMathPlugin({ singleDollarTextMath: false }),
|
||||
mermaid,
|
||||
};
|
||||
export const SECURE_STREAMDOWN_REHYPE_PLUGINS = createStreamdownRehypePlugins(false);
|
||||
|
||||
@@ -1158,7 +1158,7 @@ describe("BlockRenderer dispatch", () => {
|
||||
describe("math rendering", () => {
|
||||
it("normalizes explicit TeX delimiters outside code", () => {
|
||||
expect(normalizeExplicitMathDelimiters(String.raw`中文 \(\sqrt{x}\) 文本`)).toBe(
|
||||
String.raw`中文 $\sqrt{x}$ 文本`,
|
||||
String.raw`中文 $$\sqrt{x}$$ 文本`,
|
||||
);
|
||||
expect(normalizeExplicitMathDelimiters(String.raw`\[\sqrt{x}\]`)).toBe(
|
||||
String.raw`$$\sqrt{x}$$`,
|
||||
@@ -1179,8 +1179,8 @@ describe("BlockRenderer dispatch", () => {
|
||||
});
|
||||
|
||||
it("does not convert delimiters already inside a dollar-math span", () => {
|
||||
const inline = String.raw`$\[x\]$`;
|
||||
expect(normalizeExplicitMathDelimiters(inline)).toBe(inline);
|
||||
const span = String.raw`$$\[x\]$$`;
|
||||
expect(normalizeExplicitMathDelimiters(span)).toBe(span);
|
||||
});
|
||||
|
||||
it("skips normalization inside multi-backtick inline code", () => {
|
||||
@@ -1188,16 +1188,12 @@ describe("BlockRenderer dispatch", () => {
|
||||
expect(normalizeExplicitMathDelimiters(doubleTick)).toBe(doubleTick);
|
||||
});
|
||||
|
||||
it("escapes currency dollar amounts so prose isn't parsed as inline math", () => {
|
||||
// With single-dollar math enabled, "it costs $5 or $10" would otherwise
|
||||
// parse "5 or " as inline math. Escaping the digit-led `$` renders literal
|
||||
// dollar figures and stops the run from flipping the math toggle.
|
||||
expect(normalizeExplicitMathDelimiters("it costs $5 or $10")).toBe(
|
||||
String.raw`it costs \$5 or \$10`,
|
||||
);
|
||||
// Delimiters after the currency text still normalize — the toggle didn't flip.
|
||||
it("leaves prose dollar amounts verbatim", () => {
|
||||
expect(normalizeExplicitMathDelimiters("it costs $5 or $10")).toBe("it costs $5 or $10");
|
||||
// Delimiters after the currency text still normalize — the lone `$` didn't
|
||||
// flip the math-span toggle.
|
||||
expect(normalizeExplicitMathDelimiters(String.raw`$5 then \(x\)`)).toBe(
|
||||
String.raw`\$5 then $x$`,
|
||||
String.raw`$5 then $$x$$`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1236,6 +1232,30 @@ describe("BlockRenderer dispatch", () => {
|
||||
expect(indexCss).toContain('@source "../node_modules/streamdown/dist/*.js"');
|
||||
});
|
||||
|
||||
it("renders prose dollars as literal text, not math", async () => {
|
||||
// `$/PR` and `$/session` are the shape that broke: a `$` before a slash is
|
||||
// neither currency-with-a-digit nor a SCREAMING_CASE variable, so the old
|
||||
// escaping heuristics missed them and single-dollar math paired them up,
|
||||
// rendering the words between as letter-by-letter math soup.
|
||||
const prose = "Costs $/PR versus $/session, a 60% saving on $LLM_API_KEY calls.";
|
||||
const { container } = renderMarkdownText(prose);
|
||||
|
||||
await waitFor(() => expect(container.textContent).toContain("60%"));
|
||||
expect(container.querySelector(".katex")).toBeNull();
|
||||
expect(container.textContent).toContain(prose);
|
||||
});
|
||||
|
||||
it("renders an explicit inline TeX span inline, not as a display block", async () => {
|
||||
// `\(…\)` normalizes to `$$…$$`, which is a display block only when it
|
||||
// opens its own line; mid-paragraph it must stay inline math.
|
||||
const { container } = renderMarkdownText(String.raw`the value \(\sqrt{x + 1}\) holds`);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".katex")).not.toBeNull());
|
||||
expect(container.querySelector(".katex-display")).toBeNull();
|
||||
expect(container.textContent).toContain("the value");
|
||||
expect(container.textContent).toContain("holds");
|
||||
});
|
||||
|
||||
it("renders radicals, fractions, and superscripts without dropping the radicand", async () => {
|
||||
const { container } = renderMarkdownText(
|
||||
String.raw`$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$`,
|
||||
|
||||
@@ -234,6 +234,16 @@ describe("useSessionLiveness — derivation truth table", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("imported session skips the grace → local_stranded even when fresh", () => {
|
||||
// An import has no runner booting, so the cold-boot grace must not apply:
|
||||
// a fresh import reads local_stranded at once (offering the resume
|
||||
// picker) instead of "Connecting…" for the whole grace window. Same
|
||||
// inputs as the just-created case above, which is `starting`.
|
||||
expect(
|
||||
derive(false, null, conv({ host_id: null, created_at: freshCreatedAt(), imported: true })),
|
||||
).toEqual({ kind: "local_stranded" });
|
||||
});
|
||||
|
||||
it("starting wins over host_offline for a fresh host-bound session", () => {
|
||||
// The grace precedes the host_offline check: a new host-bound session
|
||||
// whose host hasn't registered yet must not flash "host offline" either.
|
||||
@@ -339,6 +349,7 @@ describe("useSessionLiveness — derivation truth table", () => {
|
||||
created_at: 123,
|
||||
host_resumable: false,
|
||||
kind: undefined,
|
||||
imported: false,
|
||||
});
|
||||
// hostResumable flows through so an off-sidebar resumable host can
|
||||
// classify host_asleep rather than dead-ending on host_offline.
|
||||
|
||||
@@ -47,8 +47,19 @@ export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "c
|
||||
* reconnect modal). Absent ⇒ treated as ``"default"``.
|
||||
*/
|
||||
kind?: "default" | "sub_agent";
|
||||
/**
|
||||
* Whether this session was imported from a local harness transcript (the
|
||||
* `omnigent.import.source` label). An import has no runner booting, so it
|
||||
* must skip the cold-boot startup grace — otherwise it shows "Connecting…"
|
||||
* for {@link STARTING_GRACE_S} before settling to `local_stranded`, when it
|
||||
* should offer the resume picker immediately. Absent ⇒ treated `false`.
|
||||
*/
|
||||
imported?: boolean;
|
||||
};
|
||||
|
||||
/** Label marking a session imported from a local harness transcript. */
|
||||
export const IMPORT_SOURCE_LABEL_KEY = "omnigent.import.source";
|
||||
|
||||
/**
|
||||
* Build a {@link LivenessRow} from the single-session snapshot
|
||||
* ({@link Session}) for callers whose sidebar row (`Conversation`) is
|
||||
@@ -61,7 +72,10 @@ export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "c
|
||||
*/
|
||||
export function livenessRowFromSession(
|
||||
session:
|
||||
| Pick<Session, "hostId" | "permissionLevel" | "createdAt" | "hostResumable" | "kind">
|
||||
| Pick<
|
||||
Session,
|
||||
"hostId" | "permissionLevel" | "createdAt" | "hostResumable" | "kind" | "labels"
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
): LivenessRow | null {
|
||||
@@ -72,6 +86,7 @@ export function livenessRowFromSession(
|
||||
created_at: session.createdAt,
|
||||
host_resumable: session.hostResumable ?? false,
|
||||
kind: session.kind,
|
||||
imported: Boolean(session.labels?.[IMPORT_SOURCE_LABEL_KEY]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,6 +256,7 @@ export function useSessionLiveness(
|
||||
const createdAt = conv?.created_at;
|
||||
if (
|
||||
!runnerEverOnline &&
|
||||
!conv?.imported &&
|
||||
typeof createdAt === "number" &&
|
||||
createdAt > 0 &&
|
||||
Date.now() / 1000 - createdAt < STARTING_GRACE_S
|
||||
|
||||
@@ -46,7 +46,7 @@ function getSnapshot(): number {
|
||||
|
||||
/**
|
||||
* Monotonic wall-clock bucket that advances once every `ROTATE_MS`. Feed it
|
||||
* into `workingIndicatorLabel(bgCount, tick)` to rotate the label. SSR-safe
|
||||
* into `workingIndicatorLabel(tick)` to rotate the label. SSR-safe
|
||||
* (returns 0 on the server, matching `useIsMobileViewport`).
|
||||
*/
|
||||
export function useWorkingLabelTick(): number {
|
||||
|
||||
@@ -573,8 +573,8 @@ export interface SessionAgentChangedEvent {
|
||||
* Each todo item has:
|
||||
* - `content`: the task description string
|
||||
* - `status`: `"pending"` | `"in_progress"` | `"completed"`
|
||||
* - `activeForm`: present-continuous form of the task (e.g. `"Running tests"`).
|
||||
* Shown by the TodoPanel under in-progress items when distinct from `content`.
|
||||
* - `activeForm`: present-continuous form of the task (e.g. `"Running tests"`),
|
||||
* the present-continuous label for an in-progress item when distinct from `content`.
|
||||
*/
|
||||
export interface SessionTodosEvent {
|
||||
type: "session_todos";
|
||||
|
||||
@@ -13,7 +13,6 @@ const RAIL_TABS: readonly RightRailTab[] = [
|
||||
"changes",
|
||||
"subagents",
|
||||
"terminals",
|
||||
"todos",
|
||||
"browser",
|
||||
];
|
||||
|
||||
@@ -22,7 +21,7 @@ export interface SessionWorkspaceState {
|
||||
open?: boolean;
|
||||
/** User-chosen rail width (px) for this session. */
|
||||
widthPx?: number;
|
||||
/** The selected rail tab (Files / Changes / Agents / Shells / Tasks). */
|
||||
/** The selected rail tab (Files / Changes / Agents / Shells). */
|
||||
rightRailTab?: RightRailTab;
|
||||
/** Ordered list of open file tabs. */
|
||||
openFiles?: string[];
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
stripGatedSubagentRoutingChips,
|
||||
stripPendingElicitations,
|
||||
subAgentComposerLabel,
|
||||
unboundSessionResumableInApp,
|
||||
WORKING_MESSAGES,
|
||||
workingIndicatorLabel,
|
||||
} from "./ChatPage";
|
||||
@@ -848,7 +849,7 @@ describe("computeShowsWorking", () => {
|
||||
|
||||
it("parent idle stays idle in the main chat", () => {
|
||||
// Busy children are surfaced by the sidebar/Agents rail, not by the
|
||||
// main chat's shimmer or pinned "Working…" pill.
|
||||
// main chat's "Working…" shimmer.
|
||||
expect(computeShowsWorking("idle", opts())).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1020,75 +1021,69 @@ describe("shouldShowWorkingIndicator", () => {
|
||||
|
||||
describe("workingIndicatorLabel — parked on a dialog", () => {
|
||||
it("names what the agent is waiting on", () => {
|
||||
expect(workingIndicatorLabel(0, 0, "permission prompt")).toBe("Blocked on: permission prompt");
|
||||
expect(workingIndicatorLabel(0, "permission prompt")).toBe("Blocked on: permission prompt");
|
||||
});
|
||||
|
||||
it("outranks the background tally and the rotation", () => {
|
||||
it("outranks the rotation", () => {
|
||||
// Being blocked on the user is the one state that needs an action, and the
|
||||
// dialog may exist only in the terminal tab — so it must not be buried
|
||||
// under a rotating "Cooking…" or a background-shell count.
|
||||
expect(workingIndicatorLabel(3, 2, "dialog open")).toBe("Blocked on: dialog open");
|
||||
// under a rotating "Cooking…".
|
||||
expect(workingIndicatorLabel(2, "dialog open")).toBe("Blocked on: dialog open");
|
||||
});
|
||||
|
||||
it("falls back to the normal label when not parked", () => {
|
||||
expect(workingIndicatorLabel(0, 0, null)).toBe("Working…");
|
||||
expect(workingIndicatorLabel(1, 0, null)).toBe("1 background task still running");
|
||||
expect(workingIndicatorLabel(0, null)).toBe("Working…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workingIndicatorLabel", () => {
|
||||
it("shows the plain Working label when no background tasks remain", () => {
|
||||
it("shows the plain Working label at the first tick", () => {
|
||||
expect(workingIndicatorLabel(0)).toBe("Working…");
|
||||
});
|
||||
|
||||
it("treats a negative count as no background tasks", () => {
|
||||
// Defensive: the store seeds 0, but a stale/negative tally must never
|
||||
// produce a nonsensical "-1 background tasks" label.
|
||||
expect(workingIndicatorLabel(-1)).toBe("Working…");
|
||||
});
|
||||
|
||||
it("uses the singular noun for exactly one background task", () => {
|
||||
expect(workingIndicatorLabel(1)).toBe("1 background task still running");
|
||||
});
|
||||
|
||||
it("pluralizes the noun for more than one background task", () => {
|
||||
expect(workingIndicatorLabel(3)).toBe("3 background tasks still running");
|
||||
it("does not report background tasks — the pill owns that count", () => {
|
||||
// The shimmer is the working indicator; the background-task tally lives in
|
||||
// BackgroundTaskPill, so the label never mentions it regardless of tick.
|
||||
expect(workingIndicatorLabel(0)).toBe("Working…");
|
||||
expect(workingIndicatorLabel(5)).toBe(WORKING_MESSAGES[5 % WORKING_MESSAGES.length]);
|
||||
});
|
||||
|
||||
it("pins the first rotation message to 'Working…'", () => {
|
||||
// A fresh tick and the default arg both land on index 0, so this label
|
||||
// must stay "Working…" — the invariant the (0) / (-1) cases rely on.
|
||||
// must stay "Working…".
|
||||
expect(WORKING_MESSAGES[0]).toBe("Working…");
|
||||
expect(workingIndicatorLabel(0, 0)).toBe("Working…");
|
||||
expect(workingIndicatorLabel(0)).toBe("Working…");
|
||||
});
|
||||
|
||||
it("rotates through the message pool by tick", () => {
|
||||
expect(workingIndicatorLabel(0, 1)).toBe(WORKING_MESSAGES[1]);
|
||||
expect(workingIndicatorLabel(0, 2)).toBe(WORKING_MESSAGES[2]);
|
||||
expect(workingIndicatorLabel(1)).toBe(WORKING_MESSAGES[1]);
|
||||
expect(workingIndicatorLabel(2)).toBe(WORKING_MESSAGES[2]);
|
||||
});
|
||||
|
||||
it("wraps the rotation modulo the pool length", () => {
|
||||
expect(workingIndicatorLabel(0, WORKING_MESSAGES.length)).toBe("Working…");
|
||||
expect(workingIndicatorLabel(0, WORKING_MESSAGES.length + 1)).toBe(WORKING_MESSAGES[1]);
|
||||
});
|
||||
|
||||
it("ignores the tick while background tasks remain", () => {
|
||||
// The count is information, not decoration — it must not rotate away.
|
||||
expect(workingIndicatorLabel(2, 5)).toBe("2 background tasks still running");
|
||||
expect(workingIndicatorLabel(WORKING_MESSAGES.length)).toBe("Working…");
|
||||
expect(workingIndicatorLabel(WORKING_MESSAGES.length + 1)).toBe(WORKING_MESSAGES[1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isBackgroundTasksOnly ───────────────────────────────────────────────────
|
||||
|
||||
describe("isBackgroundTasksOnly", () => {
|
||||
it("is true only when background tasks remain and nothing is blocked", () => {
|
||||
expect(isBackgroundTasksOnly(2, null)).toBe(true);
|
||||
expect(isBackgroundTasksOnly(0, null)).toBe(false);
|
||||
it("is true only once the turn ends with background tasks and nothing blocked", () => {
|
||||
// Turn over (not working) + shells linger → the pill owns the state.
|
||||
expect(isBackgroundTasksOnly(2, null, false)).toBe(true);
|
||||
expect(isBackgroundTasksOnly(0, null, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("yields while the turn is still active so the shimmer shows too", () => {
|
||||
// running/waiting or a local send in flight: the agent's turn is live, so
|
||||
// the "Working…" shimmer wins and the pill sits alongside it.
|
||||
expect(isBackgroundTasksOnly(2, null, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("yields to a parked dialog so the shimmer still shouts", () => {
|
||||
// blockedOn needs an action; it must never sit as a quiet pill.
|
||||
expect(isBackgroundTasksOnly(2, "permission prompt")).toBe(false);
|
||||
expect(isBackgroundTasksOnly(2, "permission prompt", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1640,6 +1635,40 @@ describe("isUnboundCodingFork", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unboundSessionResumableInApp", () => {
|
||||
const owner = { unbound: true, isOwner: true, importSource: null };
|
||||
|
||||
it("is false unless the session is unbound", () => {
|
||||
expect(unboundSessionResumableInApp({ ...owner, unbound: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a non-owner (launch_runner would 404)", () => {
|
||||
// Regression: a shared viewer must not get a picker that always fails.
|
||||
expect(unboundSessionResumableInApp({ ...owner, isOwner: false })).toBe(false);
|
||||
expect(unboundSessionResumableInApp({ ...owner, isOwner: false, importSource: "claude" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a non-import unbound session (e.g. an unbound fork)", () => {
|
||||
expect(unboundSessionResumableInApp(owner)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows host-portable import sources", () => {
|
||||
for (const src of ["claude", "codex", "pi", "opencode"]) {
|
||||
expect(unboundSessionResumableInApp({ ...owner, importSource: src })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks imports whose context does not carry to a chosen host", () => {
|
||||
// kimi has no resume path; kiro/qwen resume only from a local file on the
|
||||
// original machine — a chosen host would launch with no context.
|
||||
for (const src of ["kimi", "kiro", "qwen"]) {
|
||||
expect(unboundSessionResumableInApp({ ...owner, importSource: src })).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The routing gates the ChatPage call site uses. The deployment flag is half of
|
||||
// each gate: a session-shape-eligible session on a server WITHOUT a routing
|
||||
// client must show no routing control at all, and neither must one while the
|
||||
|
||||
+177
-136
@@ -146,6 +146,7 @@ import { useRefreshSessionStateOnRunnerOnline } from "@/hooks/useSessionOnlineRe
|
||||
import {
|
||||
type LivenessRow,
|
||||
type SessionLiveness,
|
||||
IMPORT_SOURCE_LABEL_KEY,
|
||||
livenessRowFromSession,
|
||||
useSessionLiveness,
|
||||
} from "@/hooks/useSessionLiveness";
|
||||
@@ -195,6 +196,7 @@ import {
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import type { ServerInfo } from "@/lib/capabilities";
|
||||
import { MainTerminalView } from "@/shell/MainTerminalView";
|
||||
import { ChatPlanAccordion } from "@/shell/ChatPlanAccordion";
|
||||
import { UNTITLED_CONVERSATION_LABEL } from "@/shell/sidebarNav";
|
||||
import { NewChatLandingScreen } from "@/shell/NewChatDialog";
|
||||
import { ResumeWithDirectoryDialog } from "@/shell/ResumeWithDirectoryDialog";
|
||||
@@ -1069,6 +1071,28 @@ export function ChatPage() {
|
||||
forkSourceId,
|
||||
workspace: activeSession?.workspace ?? activeConv?.workspace ?? null,
|
||||
});
|
||||
// An unbound session (no host, no runner — e.g. an imported one) routes the
|
||||
// offline guard to the directory picker (bind + launch a runner on a chosen
|
||||
// machine) instead of the terminal reconnect dead-end — but only when that
|
||||
// resume would actually work. The picker calls launch_runner, which requires
|
||||
// the caller to OWN the session (a shared non-owner 404s), and — for imports —
|
||||
// only harnesses that reconstruct context from the omnigent transcript carry
|
||||
// onto a chosen host (kimi can't resume at all; kiro/qwen resume from a local
|
||||
// file that lives on the original machine). Everything else falls through to
|
||||
// the reconnect path rather than a picker that would fail or start blank.
|
||||
// See unboundSessionResumableInApp. The picker itself handles the no-online-
|
||||
// host case, so we don't gate on host availability here.
|
||||
const importSource =
|
||||
activeSession?.labels?.[IMPORT_SOURCE_LABEL_KEY] ??
|
||||
activeConv?.labels?.[IMPORT_SOURCE_LABEL_KEY] ??
|
||||
null;
|
||||
const canResumeOnLocalHost = unboundSessionResumableInApp({
|
||||
unbound:
|
||||
(activeSession?.hostId ?? activeConv?.host_id ?? null) === null &&
|
||||
(activeSession?.runnerId ?? activeConv?.runner_id ?? null) === null,
|
||||
isOwner: isOwnerLevel(activeSession?.permissionLevel ?? activeConv?.permission_level ?? null),
|
||||
importSource,
|
||||
});
|
||||
|
||||
// Author labels show only once a session is shared. A non-owner viewer
|
||||
// already implies a share; the owner needs the grant list (manage-only,
|
||||
@@ -1116,6 +1140,10 @@ export function ChatPage() {
|
||||
permission_level: activeSession?.permissionLevel ?? activeConv.permission_level,
|
||||
host_resumable: activeSession?.hostResumable ?? false,
|
||||
kind: activeSession?.kind,
|
||||
// Skip the cold-boot grace for imports (nothing is booting) so the
|
||||
// resume picker shows at once. Snapshot labels win; sidebar row is the
|
||||
// fallback for an off-page session.
|
||||
imported: Boolean((activeSession?.labels ?? activeConv.labels)?.[IMPORT_SOURCE_LABEL_KEY]),
|
||||
}
|
||||
: livenessRowFromSession(activeSession);
|
||||
// Host-switch launch marker; see the store field. Keeps this surface's
|
||||
@@ -1175,7 +1203,7 @@ export function ChatPage() {
|
||||
// the bind. Pin the prompt to THIS session so it replays here, never
|
||||
// into a session the user may switch to first; carry any attachments
|
||||
// so the replay sends the same payload.
|
||||
if (urlConvId && runnerOnline === false && isUnboundFork) {
|
||||
if (urlConvId && runnerOnline === false && (isUnboundFork || canResumeOnLocalHost)) {
|
||||
setPendingResumePrompt({ sessionId: urlConvId, text, files: files ?? [] });
|
||||
setResumeDirDialogOpen(true);
|
||||
return;
|
||||
@@ -1211,7 +1239,7 @@ export function ChatPage() {
|
||||
if (!agentId) return;
|
||||
// Slash commands aren't replayed (an edge), but still route an unbound
|
||||
// coding clone to the directory picker so it isn't a dead end.
|
||||
if (urlConvId && runnerOnline === false && isUnboundFork) {
|
||||
if (urlConvId && runnerOnline === false && (isUnboundFork || canResumeOnLocalHost)) {
|
||||
setResumeDirDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
@@ -1285,9 +1313,10 @@ export function ChatPage() {
|
||||
onStop={onStop}
|
||||
onShowReconnectHelp={() => {
|
||||
// Route the banner to the SAME dialog typing a message would: an
|
||||
// unbound coding clone opens the directory picker (bind + launch),
|
||||
// everything else gets the reconnect dialog.
|
||||
if (isUnboundFork) setResumeDirDialogOpen(true);
|
||||
// unbound coding clone or a host-less session the caller can resume
|
||||
// in-app opens the directory picker (bind + launch), everything else
|
||||
// gets the reconnect dialog.
|
||||
if (isUnboundFork || canResumeOnLocalHost) setResumeDirDialogOpen(true);
|
||||
else setReconnectDialogOpen(true);
|
||||
}}
|
||||
agents={visibleAgents}
|
||||
@@ -1348,12 +1377,19 @@ export function ChatPage() {
|
||||
sourceHostId={activeSession?.hostId}
|
||||
sourceGitBranch={activeSession?.gitBranch}
|
||||
/>
|
||||
{isUnboundFork && forkSourceId && (
|
||||
{((isUnboundFork && forkSourceId) || canResumeOnLocalHost) && (
|
||||
<ResumeWithDirectoryDialog
|
||||
open={resumeDirDialogOpen}
|
||||
onOpenChange={setResumeDirDialogOpen}
|
||||
sessionId={urlConvId}
|
||||
sourceSessionId={forkSourceId}
|
||||
// Fork clone prefills from its source; a host-less session has none
|
||||
// and prefills from its own recorded host/workspace/branch instead.
|
||||
sourceSessionId={isUnboundFork ? forkSourceId : null}
|
||||
prefill={{
|
||||
hostId: activeSession?.hostId ?? null,
|
||||
workspace: activeSession?.workspace ?? null,
|
||||
gitBranch: activeSession?.gitBranch ?? null,
|
||||
}}
|
||||
serverUrl={getCliServerUrl()}
|
||||
wrapper={activeConv?.labels?.["omnigent.wrapper"]}
|
||||
/>
|
||||
@@ -1707,6 +1743,11 @@ function MainAgentSurface({
|
||||
// staring at a fresh session during a slow MCP boot sees the startup band
|
||||
// instead of a bare "What should we work on?" empty state.
|
||||
const mcpStartupActive = useChatStore((s) => s.mcpStartup !== null);
|
||||
// Session publishes a task list — pins the collapsible Plan accordion above
|
||||
// the thread. When set, the accordion (a solid bar) clears the floating
|
||||
// header and occludes scrolled content, so the viewport drops its top fade
|
||||
// and reserves only a small gap instead of the full header clearance.
|
||||
const hasTasks = useChatStore((s) => s.todos.length > 0);
|
||||
// Render the inline terminal whenever the user has opted in via the
|
||||
// connection pill. The terminal surface owns its no-terminal state,
|
||||
// including stopped/resumable sessions, and the connection indicator
|
||||
@@ -1991,6 +2032,13 @@ function MainAgentSurface({
|
||||
{terminalSurfaces}
|
||||
{!showTerminal && (
|
||||
<>
|
||||
{/* Task tracker pinned above the thread. Sibling of the viewport (not
|
||||
an overlay) so it shrinks the scroll area rather than covering
|
||||
messages. mt clears the floating header (h-14 mobile / h-12 desktop).
|
||||
Self-hides with no tasks. ponytail: header offset is the web height;
|
||||
native shells (data-ios/android) size their header via CSS vars — not
|
||||
tuned here. */}
|
||||
<ChatPlanAccordion className="mt-14 md:mt-12" />
|
||||
{/* Wrapper div gives us a ref to scope the SelectionPopup to the
|
||||
conversation area without requiring Conversation to forward refs. */}
|
||||
<div
|
||||
@@ -1999,8 +2047,10 @@ function MainAgentSurface({
|
||||
>
|
||||
{/* chat-scroll-fade masks the viewport's top edge so scrolling
|
||||
content dissolves into the canvas before reaching the
|
||||
ChatHeader overlay's controls (geometry in index.css). */}
|
||||
<Conversation className="chat-scroll-fade flex-1">
|
||||
ChatHeader overlay's controls (geometry in index.css). Dropped
|
||||
when the Plan accordion is pinned above — its solid bar already
|
||||
occludes content scrolling past the viewport's top edge. */}
|
||||
<Conversation className={cn(!hasTasks && "chat-scroll-fade", "flex-1")}>
|
||||
{/* Override ConversationContent's default spacing so the thread keeps
|
||||
16px side gutters and consecutive agent turns read as one thread.
|
||||
The left inset grows *continuously* as the conversation area
|
||||
@@ -2019,7 +2069,11 @@ function MainAgentSurface({
|
||||
<ConversationContent
|
||||
scrollClassName="transcript-hide-native-scrollbar"
|
||||
className={cn(
|
||||
"chat-conversation-content mx-auto w-full gap-4 px-4 pt-20 pb-6",
|
||||
"chat-conversation-content mx-auto w-full gap-4 px-4 pb-6",
|
||||
// pt-20 reserves the floating-header clearance + fade band.
|
||||
// With the Plan accordion pinned above, the header is already
|
||||
// cleared, so only a small gap below the accordion is needed.
|
||||
hasTasks ? "pt-4" : "pt-20",
|
||||
"md:pl-[clamp(1rem,(54rem-100cqi)*0.5+1rem,1.5rem)]",
|
||||
CHAT_COLUMN_WIDTH,
|
||||
)}
|
||||
@@ -2090,8 +2144,9 @@ function MainAgentSurface({
|
||||
{/* Working… shimmer, lit for the whole busy turn so the user
|
||||
always sees the session is still going. Suppressed when the
|
||||
last bubble is a compaction spinner — that bubble already
|
||||
owns the "in-progress" slot. aria-hidden: the pinned pill
|
||||
owns the single aria-live region (see WorkingStatusPin). */}
|
||||
owns the "in-progress" slot. Owns the sole aria-live region
|
||||
announcing the working state (its visible label is
|
||||
aria-hidden, so the rotating text never re-announces). */}
|
||||
{showWorkingIndicator && <WorkingIndicator />}
|
||||
{/* Terminal-first spin-up cue beneath the just-sent first
|
||||
message: the prompt bubble renders immediately (no
|
||||
@@ -2114,12 +2169,14 @@ function MainAgentSurface({
|
||||
Always mounted — including for an empty new conversation — so
|
||||
a fast first send cannot become the captured initial anchor.
|
||||
Last child so it measures everything above it. */}
|
||||
<LatestTurnSpacer scrollElement={scroller?.el ?? null} />
|
||||
<LatestTurnSpacer
|
||||
scrollElement={scroller?.el ?? null}
|
||||
// Match the reduced pt-4 top inset so a framed turn rests just
|
||||
// below the Plan accordion, not under the (now absent) fade band.
|
||||
topGapPx={hasTasks ? 16 : undefined}
|
||||
/>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
{/* Outside ConversationContent so it's pinned to the viewport, not the scroll. See WorkingStatusPin.
|
||||
Suppressed in a sub-agent session: the composer's "Chatting with sub-agent …" tray owns this slot. */}
|
||||
<WorkingStatusPin show={showWorkingIndicator} suppress={subAgentLabel != null} />
|
||||
<UserMessageNavConnected
|
||||
goPrev={nav.goPrev}
|
||||
goNext={nav.goNext}
|
||||
@@ -2130,8 +2187,10 @@ function MainAgentSurface({
|
||||
</Conversation>
|
||||
{/* Constant-height scrollbar. Sibling of Conversation for the same
|
||||
reason as JumpToTopButton — outside the chat-scroll-fade mask, which
|
||||
would otherwise dissolve it against the header. */}
|
||||
<TranscriptScrollbar scroller={scroller} />
|
||||
would otherwise dissolve it against the header. With the Plan
|
||||
accordion pinned above, this container already starts below the
|
||||
header, so the track drops its header-clearing top inset. */}
|
||||
<TranscriptScrollbar scroller={scroller} topInset={hasTasks ? 12 : undefined} />
|
||||
{/* Hover the top edge to reveal a pill that loads all older history and
|
||||
scrolls to the first message. Rendered here (a wrapper sibling of
|
||||
Conversation) rather than inside it so it escapes the chat-scroll-fade
|
||||
@@ -2284,77 +2343,6 @@ function UserMessageNavConnected(props: React.ComponentProps<typeof UserMessageN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll-pinned "Working…" pill — sole aria-live region (inline shimmer is
|
||||
* aria-hidden).
|
||||
*
|
||||
* @param show - True while the main session is working; gates both the
|
||||
* aria-live announcement and the painted tab.
|
||||
* @param suppress - Hides the painted tab without silencing the aria-live
|
||||
* region (still gated on ``show``). Set in a sub-agent session, where the
|
||||
* composer's "Chatting with sub-agent …" tray rises in this same slot and
|
||||
* the "Working…" tab would otherwise stack on top of it.
|
||||
*/
|
||||
function WorkingStatusPin({ show, suppress = false }: { show: boolean; suppress?: boolean }) {
|
||||
const { isAtBottom } = useStickToBottomContext();
|
||||
const bgCount = useChatStore((s) => s.backgroundTaskCount);
|
||||
const blockedOn = useChatStore((s) => s.blockedOn);
|
||||
const tick = useWorkingLabelTick();
|
||||
// BackgroundTaskPill owns the background-tasks-only case; the pinned tab and
|
||||
// its announcement yield to it.
|
||||
const showShimmer = show && !isBackgroundTasksOnly(bgCount, blockedOn);
|
||||
const visible = showShimmer && !isAtBottom && !suppress;
|
||||
return (
|
||||
<div
|
||||
// Always mounted (the aria-live region announces on show); bottom-0 sits
|
||||
// it flush on the composer so the tab reads as rising from behind it.
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="working-indicator-pin"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-20 transition-opacity duration-200",
|
||||
visible ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{/* The single announced string. Held stable at "Working…" so the rotating
|
||||
tab text below never re-announces every few seconds. Present whenever
|
||||
the agent is working, so it announces whether the tab is painted
|
||||
(scrolled up) or collapsed (at the bottom, where the inline shimmer
|
||||
owns the visuals). */}
|
||||
{showShimmer && <span className="sr-only">Working…</span>}
|
||||
{/* Mirror the conversation content column (mx-auto + px-4 + width) so the
|
||||
tab's left edge lines up with the inline shimmer's. */}
|
||||
<div className={cn("mx-auto w-full px-4", CHAT_COLUMN_WIDTH)}>
|
||||
{showShimmer && (
|
||||
// Tab shape (rounded top, no bottom border) so its flat bottom edge
|
||||
// merges into the composer. bg-card-solid, not bg-card: in dark mode
|
||||
// bg-card is a translucent glass surface (the `.dark .bg-card` frosted
|
||||
// backdrop-blur), so the tab read as a see-through pill floating over
|
||||
// the transcript. The opaque solid keeps it readable and, by not
|
||||
// matching the glass rule, lets `border-b-0` actually merge — that
|
||||
// rule's `border` shorthand would otherwise re-add a bottom edge.
|
||||
// aria-hidden: the sr-only span above owns the announcement, so the
|
||||
// rotating label here stays silent to screen readers. Collapses to
|
||||
// sr-only when at the bottom (`!visible`) — the inline shimmer paints
|
||||
// there instead.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-1.5 rounded-t-lg border border-b-0 border-border bg-card-solid px-3 pt-1 pb-1.5",
|
||||
!visible && "sr-only",
|
||||
)}
|
||||
>
|
||||
<BrandLogo variant="icon" className="otto-working h-4 w-auto shrink-0" />
|
||||
<Shimmer className="text-sm font-mono" duration={1.5}>
|
||||
{workingIndicatorLabel(bgCount, tick, blockedOn)}
|
||||
</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the conversation back to the bottom when this client submits a
|
||||
* new message. StickToBottom intentionally respects a user who has scrolled
|
||||
@@ -2627,8 +2615,14 @@ const MAX_RESERVED_VIEWPORT_FRACTION = 1 / 3;
|
||||
*/
|
||||
export function LatestTurnSpacer({
|
||||
scrollElement,
|
||||
// Gap left above the pinned anchor. Defaults to clearing the top fade band;
|
||||
// with the Plan accordion pinned above (fade dropped, container already below
|
||||
// the header), the caller passes the small content inset so a framed turn
|
||||
// rests just below the accordion instead of 80px lower.
|
||||
topGapPx = PINNED_ANCHOR_TOP_GAP_PX,
|
||||
}: {
|
||||
scrollElement?: HTMLElement | null;
|
||||
topGapPx?: number;
|
||||
} = {}) {
|
||||
const ctx = useStickToBottomContext() as ReturnType<typeof useStickToBottomContext> & {
|
||||
scrollRef: React.RefObject<HTMLElement>;
|
||||
@@ -2694,14 +2688,11 @@ export function LatestTurnSpacer({
|
||||
const viewport = scrollEl.clientHeight;
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
viewport - anchorToEnd - PINNED_ANCHOR_TOP_GAP_PX,
|
||||
viewport * MAX_RESERVED_VIEWPORT_FRACTION,
|
||||
),
|
||||
Math.min(viewport - anchorToEnd - topGapPx, viewport * MAX_RESERVED_VIEWPORT_FRACTION),
|
||||
);
|
||||
const current = Number.parseFloat(spacerEl.style.height) || 0;
|
||||
if (Math.abs(current - next) >= 1) spacerEl.style.height = `${next}px`;
|
||||
}, [ctx.scrollRef, scrollElement]);
|
||||
}, [ctx.scrollRef, scrollElement, topGapPx]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
@@ -2987,58 +2978,78 @@ export const WORKING_MESSAGES = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Busy only because background tasks outlive the turn (a dev server, a
|
||||
* background shell) — the agent isn't thinking and nothing's blocked. The
|
||||
* composer's `BackgroundTaskPill` owns this; the "Working…" shimmer yields.
|
||||
* Busy only because background tasks outlive a FINISHED turn (a dev server, a
|
||||
* background shell) — the agent's own turn has ended and nothing's blocked. The
|
||||
* composer's `BackgroundTaskPill` reports the count and the "Working…" shimmer
|
||||
* stays off (it would misread as the agent still thinking). While the turn is
|
||||
* still active (`agentWorking`) the shimmer wins, and the pill shows the count
|
||||
* alongside it.
|
||||
*/
|
||||
export function isBackgroundTasksOnly(bgCount: number, blockedOn: string | null): boolean {
|
||||
return !blockedOn && bgCount > 0;
|
||||
export function isBackgroundTasksOnly(
|
||||
bgCount: number,
|
||||
blockedOn: string | null,
|
||||
agentWorking: boolean,
|
||||
): boolean {
|
||||
return !agentWorking && !blockedOn && bgCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The label shown next to the working spinner. When the agent is parked on a
|
||||
* dialog (`blockedOn`) it says so — that outranks everything else, because it
|
||||
* is the one case where the session needs the user rather than time, and the
|
||||
* dialog may live only in the terminal tab. Otherwise, when background shells
|
||||
* outlive the turn (`bgCount > 0`) it names how many are still running (the
|
||||
* tick is ignored — that count is information, not decoration). Failing both
|
||||
* it rotates through `WORKING_MESSAGES` by wall-clock `tick`.
|
||||
* Whether the agent's own turn is in progress — server `running`/`waiting`, or
|
||||
* a local send in flight — as opposed to being busy only because background
|
||||
* tasks outlive a finished turn. Separates the shimmer's "working" state from
|
||||
* the pill's "background-tasks-only" state.
|
||||
*/
|
||||
export function workingIndicatorLabel(
|
||||
bgCount: number,
|
||||
tick = 0,
|
||||
blockedOn: string | null = null,
|
||||
): string {
|
||||
function useAgentTurnActive(): boolean {
|
||||
const sessionStatus = useChatStore((s) => s.sessionStatus);
|
||||
const localSending = useChatStore((s) => s.status === "streaming");
|
||||
return computeIsWorking(sessionStatus) || localSending;
|
||||
}
|
||||
|
||||
/**
|
||||
* The label shown next to the working shimmer. When the agent is parked on a
|
||||
* dialog (`blockedOn`) it says so — that outranks the rotation, because it is
|
||||
* the one case where the session needs the user rather than time, and the
|
||||
* dialog may live only in the terminal tab. Otherwise it rotates through
|
||||
* `WORKING_MESSAGES` by wall-clock `tick`. Background-task counts belong to
|
||||
* `BackgroundTaskPill`, not this shimmer.
|
||||
*/
|
||||
export function workingIndicatorLabel(tick = 0, blockedOn: string | null = null): string {
|
||||
if (blockedOn) {
|
||||
return `Blocked on: ${blockedOn}`;
|
||||
}
|
||||
if (bgCount > 0) {
|
||||
return bgCount === 1
|
||||
? "1 background task still running"
|
||||
: `${bgCount} background tasks still running`;
|
||||
}
|
||||
return WORKING_MESSAGES[tick % WORKING_MESSAGES.length]!;
|
||||
}
|
||||
|
||||
function WorkingIndicator() {
|
||||
const bgCount = useChatStore((s) => s.backgroundTaskCount);
|
||||
const blockedOn = useChatStore((s) => s.blockedOn);
|
||||
const agentWorking = useAgentTurnActive();
|
||||
const tick = useWorkingLabelTick();
|
||||
// BackgroundTaskPill owns this case; the shimmer would misread as the agent
|
||||
// still thinking.
|
||||
if (isBackgroundTasksOnly(bgCount, blockedOn)) return null;
|
||||
const label = workingIndicatorLabel(bgCount, tick, blockedOn);
|
||||
// Once the turn ends but background shells outlive it, BackgroundTaskPill owns
|
||||
// the state and the shimmer stays off (it would misread as the agent still
|
||||
// thinking). While the turn is active the shimmer shows, with the pill beside it.
|
||||
if (isBackgroundTasksOnly(bgCount, blockedOn, agentWorking)) return null;
|
||||
const label = workingIndicatorLabel(tick, blockedOn);
|
||||
return (
|
||||
<Message from="assistant" data-testid="working-indicator" aria-hidden="true">
|
||||
<MessageContent>
|
||||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<BrandLogo variant="icon" className="otto-working h-4 w-auto shrink-0" />
|
||||
<Shimmer className="text-sm font-mono" duration={1.5}>
|
||||
{label}
|
||||
</Shimmer>
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<>
|
||||
{/* Sole aria-live region for the working state. A stable "Working…" (not
|
||||
the rotating label) so screen readers announce the turn once, without
|
||||
re-announcing every few seconds; the visible shimmer below stays
|
||||
aria-hidden. */}
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
Working…
|
||||
</span>
|
||||
<Message from="assistant" data-testid="working-indicator" aria-hidden="true">
|
||||
<MessageContent>
|
||||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<BrandLogo variant="icon" className="otto-working h-4 w-auto shrink-0" />
|
||||
<Shimmer className="text-sm font-mono" duration={1.5}>
|
||||
{label}
|
||||
</Shimmer>
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4426,15 +4437,16 @@ function SubagentComposerTray({ label }: { label: string }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pill above the composer tallying background tasks that outlive the turn (a
|
||||
* dev server, a background shell), shown in place of the "Working…" shimmer —
|
||||
* which would misread as the agent still thinking. Label-only: the count spans
|
||||
* shells, sub-agents, and tools, so there's no single terminal to open.
|
||||
* Pill above the composer tallying background tasks that are running (a dev
|
||||
* server, a background shell, a sub-agent). Independent of the "Working…"
|
||||
* shimmer: while the turn is active both show — the shimmer for the turn, the
|
||||
* pill for the tally — and once the turn ends the pill carries on alone.
|
||||
* Label-only: the count spans shells, sub-agents, and tools, so there's no
|
||||
* single terminal to open.
|
||||
*/
|
||||
function BackgroundTaskPill() {
|
||||
const bgCount = useChatStore((s) => s.backgroundTaskCount);
|
||||
const blockedOn = useChatStore((s) => s.blockedOn);
|
||||
if (!isBackgroundTasksOnly(bgCount, blockedOn)) return null;
|
||||
if (bgCount <= 0) return null;
|
||||
return (
|
||||
<div className={cn("mx-auto flex w-full px-1 pb-1.5", CHAT_COLUMN_WIDTH)}>
|
||||
<div
|
||||
@@ -5960,6 +5972,35 @@ export function isUnboundCodingFork(params: {
|
||||
return params.forkSourceId !== null && !params.workspace;
|
||||
}
|
||||
|
||||
// Import sources whose resume reconstructs conversation context from the
|
||||
// omnigent-stored transcript, so it carries onto any chosen host. Kimi has no
|
||||
// resume path (blank context regardless of host); kiro/qwen resume only from a
|
||||
// local recording file that exists on the original machine, so a different host
|
||||
// starts blank. The in-app resume picker is restricted to this portable set.
|
||||
const HOST_PORTABLE_IMPORT_SOURCES = new Set(["claude", "codex", "pi", "opencode"]);
|
||||
|
||||
/**
|
||||
* Whether an unbound session may be resumed in-app via the "Resume on a machine"
|
||||
* picker. The picker calls `launch_runner`, which requires the caller to OWN the
|
||||
* session, and — for imported sessions — only reconstructs context for
|
||||
* host-portable harnesses. Non-owners (who would 404) and kimi/kiro/qwen imports
|
||||
* (which would launch with no context) are excluded so they route to the
|
||||
* terminal reconnect path instead of a picker that fails or starts blank.
|
||||
*
|
||||
* @param unbound - Session has no host and no runner.
|
||||
* @param isOwner - Caller holds owner level on the session.
|
||||
* @param importSource - `omnigent.import.source` label, or null for non-imports
|
||||
* (e.g. an unbound fork, which is host-portable and owned by its creator).
|
||||
*/
|
||||
export function unboundSessionResumableInApp(params: {
|
||||
unbound: boolean;
|
||||
isOwner: boolean;
|
||||
importSource: string | null | undefined;
|
||||
}): boolean {
|
||||
if (!params.unbound || !params.isOwner) return false;
|
||||
return params.importSource == null || HOST_PORTABLE_IMPORT_SOURCES.has(params.importSource);
|
||||
}
|
||||
|
||||
const EFFORT_LEVELS = ["low", "medium", "high"] as const;
|
||||
|
||||
/** Anthropic-side efforts for claude-native sessions (matches ANTHROPIC_EFFORTS in reasoning_effort.py). */
|
||||
|
||||
@@ -22,7 +22,17 @@ const THUMB_PX = 56;
|
||||
const TRACK_TOP_PX = 64;
|
||||
const TRACK_BOTTOM_PX = 12;
|
||||
|
||||
export function TranscriptScrollbar({ scroller }: { scroller: Scroller | null }) {
|
||||
export function TranscriptScrollbar({
|
||||
scroller,
|
||||
// Top inset of the track. Defaults to clearing the floating ChatHeader; when
|
||||
// the Plan accordion is pinned above, the scroll container already starts
|
||||
// below the header, so the caller passes a small inset instead — otherwise
|
||||
// the thumb can't reach the top of the (already-cleared) viewport.
|
||||
topInset = TRACK_TOP_PX,
|
||||
}: {
|
||||
scroller: Scroller | null;
|
||||
topInset?: number;
|
||||
}) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [scrollable, setScrollable] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -34,8 +44,8 @@ export function TranscriptScrollbar({ scroller }: { scroller: Scroller | null })
|
||||
|
||||
/** Usable thumb travel: the track minus the thumb's own height. */
|
||||
const travelOf = useCallback(
|
||||
(node: HTMLElement) => node.clientHeight - TRACK_TOP_PX - TRACK_BOTTOM_PX - THUMB_PX,
|
||||
[],
|
||||
(node: HTMLElement) => node.clientHeight - topInset - TRACK_BOTTOM_PX - THUMB_PX,
|
||||
[topInset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,7 +117,7 @@ export function TranscriptScrollbar({ scroller }: { scroller: Scroller | null })
|
||||
aria-hidden
|
||||
data-testid="transcript-scrollbar"
|
||||
className="pointer-events-none absolute right-1 z-10 w-3"
|
||||
style={{ top: TRACK_TOP_PX, bottom: TRACK_BOTTOM_PX }}
|
||||
style={{ top: topInset, bottom: TRACK_BOTTOM_PX }}
|
||||
>
|
||||
<div
|
||||
data-testid="transcript-scrollbar-thumb"
|
||||
|
||||
@@ -91,7 +91,6 @@ vi.mock("./FileViewer", () => ({
|
||||
vi.mock("./InlineTerminalsSection", () => ({
|
||||
InlineTerminalsSection: () => <div data-testid="inline-terminals-section" />,
|
||||
}));
|
||||
vi.mock("./TodoPanel", () => ({ TodoPanel: () => <div data-testid="todo-panel" /> }));
|
||||
vi.mock("./FilesPanelDrawer", () => ({
|
||||
FilesPanelDrawer: () => <div data-testid="files-panel-drawer" />,
|
||||
}));
|
||||
|
||||
@@ -171,9 +171,6 @@ vi.mock("@/components/blocks/TerminalView", () => ({
|
||||
<div data-testid="terminal-view-stub">{terminalId}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./TodoPanel", () => ({
|
||||
TodoPanel: () => <div data-testid="todo-panel" />,
|
||||
}));
|
||||
vi.mock("./FilesPanelDrawer", () => ({
|
||||
FilesPanelDrawer: ({ open, flatView }: { open: boolean; flatView: boolean }) => (
|
||||
<div
|
||||
@@ -518,12 +515,9 @@ beforeEach(() => {
|
||||
// choice carries across sessions. Clear it so a stored preference from one
|
||||
// test can't change another test's default scope.
|
||||
localStorage.clear();
|
||||
// The Tasks tab/drawer gates on chatStore.todos; reset so a populated
|
||||
// todo list from one test doesn't leak into the next.
|
||||
// Reset terminal-first startup signals so one test's terminalPending /
|
||||
// failed status can't leak into another's terminalStartingUp.
|
||||
useChatStore.setState({
|
||||
todos: [],
|
||||
terminalPending: false,
|
||||
sessionStatus: "idle",
|
||||
status: "idle",
|
||||
@@ -2141,10 +2135,10 @@ describe("Right workspace card visibility", () => {
|
||||
});
|
||||
|
||||
it("keeps the card mounted with Agents as the only tab for a minimal agent", () => {
|
||||
// A no-os_env agent (available: false) with no shells and no todos
|
||||
// A no-os_env agent (available: false) with no shells
|
||||
// still has the unconditional Agents tab (the panel lists at least
|
||||
// the main agent), so the card mounts, the Agents tab is selected
|
||||
// by the fallback, and Files/Shells/Tasks are absent. An unmounted
|
||||
// by the fallback, and Files/Shells are absent. An unmounted
|
||||
// card here means the always-visible Agents rule regressed.
|
||||
useEnvironmentMock.mockReturnValue({
|
||||
data: { available: false, root: null, home: null },
|
||||
@@ -2557,7 +2551,7 @@ describe("AppShell URL sync — file param", () => {
|
||||
});
|
||||
|
||||
it("restores the file viewer into the desktop rail on a ?file= reload", () => {
|
||||
// Regression (E2E reload-persistence): the Subagents/Terminals/Todos
|
||||
// Regression (E2E reload-persistence): the Subagents/Terminals
|
||||
// panels are checked before the file viewer in the rail content
|
||||
// precedence. A ?file= reload must pull the rail to Files so the inline
|
||||
// viewer renders instead of another panel shadowing it.
|
||||
@@ -2932,25 +2926,17 @@ describe("Mobile session menu", () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
useChatStore.setState({
|
||||
todos: [
|
||||
{ content: "do a thing", status: "completed", activeForm: "doing a thing" },
|
||||
{ content: "do another", status: "pending", activeForm: "doing another" },
|
||||
],
|
||||
});
|
||||
|
||||
renderShell("/c/conv_native");
|
||||
openSessionMenu();
|
||||
|
||||
// Mirror of the desktop rail's tab strip for a native-wrapper session:
|
||||
// Files · Agents · Tasks. Shells is absent because the only terminal is
|
||||
// Files · Agents. Shells is absent because the only terminal is
|
||||
// the vendor pane (the pill's Terminal view — excluded from the shell
|
||||
// inventory) and the mocked agent declares no terminals. An unexpected
|
||||
// Shells entry means the vendor pane leaked into the inventory.
|
||||
expect(screen.getByRole("menuitem", { name: /^Files$/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /Shells/i })).toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Agents/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: /Tasks/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Terminals entry in terminal-first SDK sessions (no native wrapper)", () => {
|
||||
@@ -3101,67 +3087,8 @@ describe("Mobile session menu", () => {
|
||||
expect(drawer).toHaveAttribute("data-flat-view", "true");
|
||||
});
|
||||
|
||||
it("opens the Tasks drawer for a claude-native session with todos", () => {
|
||||
useEnvironmentMock.mockReturnValue({
|
||||
data: { available: true, root: null },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWorkspaceEnvironment>);
|
||||
mockConversations([
|
||||
{
|
||||
id: "conv_native",
|
||||
permission_level: null,
|
||||
labels: { "omnigent.wrapper": "claude-code-native-ui" },
|
||||
},
|
||||
]);
|
||||
useChatStore.setState({
|
||||
todos: [{ content: "build the thing", status: "in_progress", activeForm: "building" }],
|
||||
});
|
||||
|
||||
renderShell("/c/conv_native");
|
||||
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "closed");
|
||||
expect(screen.queryByTestId("todo-panel")).toBeNull();
|
||||
|
||||
openSessionMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Tasks/i }));
|
||||
|
||||
// Failure: openTodosPanel didn't set todosPanelOpen, or the Tasks entry
|
||||
// was gated out despite isClaudeNative + a non-empty todo list.
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "open");
|
||||
expect(screen.getByTestId("todo-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["codex-native", "conv_codex", "codex-native-ui"],
|
||||
["pi-native", "conv_pi", "pi-native-ui"],
|
||||
])("opens the Tasks drawer for a %s session with todos", (_harness, id, wrapper) => {
|
||||
useEnvironmentMock.mockReturnValue({
|
||||
data: { available: true, root: null },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWorkspaceEnvironment>);
|
||||
mockConversations([
|
||||
{
|
||||
id,
|
||||
permission_level: null,
|
||||
labels: { "omnigent.wrapper": wrapper },
|
||||
},
|
||||
]);
|
||||
useChatStore.setState({
|
||||
todos: [{ content: "Locate CLI parser", status: "in_progress", activeForm: "Locating" }],
|
||||
});
|
||||
|
||||
renderShell(`/c/${id}`);
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "closed");
|
||||
|
||||
openSessionMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Tasks/i }));
|
||||
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "open");
|
||||
expect(screen.getByTestId("todo-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the FAB with only the Agents entry for a minimal agent", () => {
|
||||
// available:false → no files; no shells, no todos, no debug. The
|
||||
// available:false → no files; no shells, no debug. The
|
||||
// Agents entry is unconditional (badge = 1, the main agent), so the
|
||||
// FAB still renders with exactly that entry. A missing FAB means
|
||||
// the always-visible Agents rule regressed on mobile.
|
||||
|
||||
@@ -91,7 +91,6 @@ import {
|
||||
type TerminalFirstContextValue,
|
||||
} from "./TerminalFirstContext";
|
||||
import { TerminalsPanel } from "./TerminalsPanel";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
import { PermissionsModal } from "@/components/PermissionsModal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -307,7 +306,6 @@ export function AppShell() {
|
||||
// on a phone they open as full-screen overlays from the session-menu FAB.
|
||||
const [subagentsPanelOpen, setSubagentsPanelOpen] = useState(false);
|
||||
const [shellsPanelOpen, setShellsPanelOpen] = useState(false);
|
||||
const [todosPanelOpen, setTodosPanelOpen] = useState(false);
|
||||
// The right "Workspace" rail (WorkspacePanel) remembers its open/closed
|
||||
// state per session. A brand-new session (no saved `open`) follows the
|
||||
// Appearance "Workspace panel" default; reopening a session restores how
|
||||
@@ -418,15 +416,11 @@ export function AppShell() {
|
||||
const sessionLabels = { ...activeConv?.labels, ...activeSession?.labels };
|
||||
const terminalFirst = sessionLabels["omnigent.ui"] === "terminal";
|
||||
const isClaudeNative = sessionLabels["omnigent.wrapper"] === "claude-code-native-ui";
|
||||
const todos = useChatStore((s) => s.todos);
|
||||
// The session.todos contract is harness-agnostic; show Tasks when it has data.
|
||||
const todosSupported = todos.length > 0;
|
||||
// Native-CLI wrapper of either family. Keys harness behavior gates
|
||||
// (composer slash commands, `/model`); terminal-first SDK sessions
|
||||
// (embedded Omnigent REPL terminal) have NO wrapper label and must
|
||||
// keep regular chat behavior. See TerminalFirstContext.tsx.
|
||||
const isNativeWrapper = isNativeWrapperLabel(sessionLabels["omnigent.wrapper"]);
|
||||
const todosCompleted = todos.filter((t) => t.status === "completed").length;
|
||||
// Used for the header "Back to parent" link, which is hidden on
|
||||
// top-level sessions. The Subagents tab itself is always visible —
|
||||
// it lists the root's children plus a "main" entry, so the user
|
||||
@@ -638,24 +632,23 @@ export function AppShell() {
|
||||
// ``railTerminals`` starts empty while the agent loads, so native
|
||||
// sessions don't flash the tab.
|
||||
terminals: !hideTerminalsTab && railTerminals.length > 0,
|
||||
todos: todosSupported && todos.length > 0,
|
||||
}) as const,
|
||||
[showFilesPanel, hideTerminalsTab, railTerminals.length, todosSupported, todos.length],
|
||||
[showFilesPanel, hideTerminalsTab, railTerminals.length],
|
||||
);
|
||||
// Whether the rail has anything at all to show. When false the workspace
|
||||
// card doesn't mount and the header hides its collapse toggle — a
|
||||
// no-filesystem agent with no terminals/sub-agents/todos would otherwise
|
||||
// no-filesystem agent with no terminals/sub-agents would otherwise
|
||||
// render an empty white card with no way to dismiss it.
|
||||
const hasRailContent = Object.values(railTabsAvailable).some(Boolean);
|
||||
// Keep the selected tab valid. When the current tab disappears — files
|
||||
// panel turns off, or the Shells tab hides (native wrapper / no shell
|
||||
// and no shell access) — fall back to the first still-visible tab in
|
||||
// display order (Files · Changes · Agents · Shells · Tasks · Browser). Picking
|
||||
// display order (Files · Changes · Agents · Shells · Browser). Picking
|
||||
// the first available (rather than ping-ponging between two effects) keeps
|
||||
// this convergent even when several tabs vanish at once.
|
||||
useEffect(() => {
|
||||
if (railTabsAvailable[rightRailTab]) return;
|
||||
const next = (["files", "changes", "subagents", "terminals", "todos", "browser"] as const).find(
|
||||
const next = (["files", "changes", "subagents", "terminals", "browser"] as const).find(
|
||||
(t) => railTabsAvailable[t],
|
||||
);
|
||||
if (next) setRightRailTab(next);
|
||||
@@ -819,7 +812,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false);
|
||||
setSubagentsPanelOpen(false);
|
||||
setShellsPanelOpen(false);
|
||||
setTodosPanelOpen(false);
|
||||
setFilesPanelShowHidden(true);
|
||||
if (!conversationId) {
|
||||
// No session → no rail; false (not the open default) so rail-gated
|
||||
@@ -870,7 +862,7 @@ export function AppShell() {
|
||||
return false;
|
||||
});
|
||||
// A selected file must be visible in the rail. The Files and Changes tabs
|
||||
// both surface the inline viewer; the Agents/Todos/Terminals tabs don't, so
|
||||
// both surface the inline viewer; the Agents/Terminals tabs don't, so
|
||||
// pull the rail to Files unless it's already on a files scope.
|
||||
if (nextSelected && nextTab !== "files" && nextTab !== "changes") {
|
||||
nextTab = "files";
|
||||
@@ -942,13 +934,10 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer so the viewer is unobscured
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
// Pull the rail to the Files tab when parked on a tab where the viewer
|
||||
// won't render (Terminals, Subagents, Todos). The Files tab surfaces the
|
||||
// won't render (Terminals, Subagents). The Files tab surfaces the
|
||||
// FileViewer inline, so leave it undisturbed.
|
||||
setRightRailTab((prev) =>
|
||||
prev === "terminals" || prev === "subagents" || prev === "todos" ? "files" : prev,
|
||||
);
|
||||
setRightRailTab((prev) => (prev === "terminals" || prev === "subagents" ? "files" : prev));
|
||||
// Reveal the rail so the viewer is actually visible — a session the user
|
||||
// collapsed (or one that started collapsed via the Appearance default)
|
||||
// would otherwise route the file into an invisible panel. Persist
|
||||
@@ -1255,7 +1244,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setPanelInitialKey(key);
|
||||
}
|
||||
|
||||
@@ -1275,7 +1263,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false);
|
||||
setSubagentsPanelOpen(false);
|
||||
setShellsPanelOpen(false);
|
||||
setTodosPanelOpen(false);
|
||||
setRightPanelOpen(true);
|
||||
if (conversationId) writeSessionWorkspaceState(conversationId, { open: true });
|
||||
},
|
||||
@@ -1323,7 +1310,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setExecutionLogsKey(key);
|
||||
}
|
||||
|
||||
@@ -1336,7 +1322,6 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setFilesDrawerFlatView(flatView);
|
||||
setFilesPanelOpen(true);
|
||||
}
|
||||
@@ -1352,7 +1337,6 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setSubagentsPanelOpen(true);
|
||||
}
|
||||
|
||||
@@ -1366,23 +1350,9 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setShellsPanelOpen(true);
|
||||
}
|
||||
|
||||
// Mobile FAB → "Tasks" opens the todo list (the desktop rail's Tasks tab)
|
||||
// as a full-screen drawer.
|
||||
function openTodosPanel() {
|
||||
setSelectedFilePath(null); // close file viewer
|
||||
clearFileViewerUrl();
|
||||
setPanelInitialKey(null); // close terminals panel
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(true);
|
||||
}
|
||||
|
||||
function openMainExecutionLog() {
|
||||
// Mobile FAB → "Execution logs" jumps straight to the main thread.
|
||||
// Children are reachable via the panel's tab switcher.
|
||||
@@ -1689,7 +1659,6 @@ export function AppShell() {
|
||||
filesPanelOpen,
|
||||
subagentsPanelOpen,
|
||||
shellsPanelOpen,
|
||||
todosPanelOpen,
|
||||
hideTerminalsTab,
|
||||
// Mobile: reachable when a shell exists OR the agent
|
||||
// declares shell access (so the drawer's "+ New shell" row
|
||||
@@ -1698,9 +1667,6 @@ export function AppShell() {
|
||||
showShellsTab:
|
||||
!hideTerminalsTab && (railTerminals.length > 0 || agentSupportsShells),
|
||||
terminalsLength: railTerminals.length,
|
||||
todosSupported,
|
||||
todosCompleted,
|
||||
todosTotal: todos.length,
|
||||
debugMode,
|
||||
changedCount,
|
||||
subagentsWorking,
|
||||
@@ -1709,7 +1675,6 @@ export function AppShell() {
|
||||
onOpenChanges: openChangesPanel,
|
||||
onOpenShells: openShellsPanel,
|
||||
onOpenSubagents: openSubagentsPanel,
|
||||
onOpenTodos: openTodosPanel,
|
||||
onOpenMainExecutionLog: openMainExecutionLog,
|
||||
}}
|
||||
/>
|
||||
@@ -1756,9 +1721,6 @@ export function AppShell() {
|
||||
terminalsLength={railTerminals.length}
|
||||
subagentsWorking={subagentsWorking}
|
||||
agentCount={agentCount}
|
||||
todosSupported={todosSupported}
|
||||
todosCompleted={todosCompleted}
|
||||
todosTotal={todos.length}
|
||||
rootSessionId={rootSessionId}
|
||||
selectedFilePath={selectedFilePath}
|
||||
openFiles={openFiles}
|
||||
@@ -1849,16 +1811,6 @@ export function AppShell() {
|
||||
/>
|
||||
</MobilePanelDrawer>
|
||||
)}
|
||||
{conversationId && (
|
||||
<MobilePanelDrawer
|
||||
open={todosPanelOpen}
|
||||
title="Tasks"
|
||||
onClose={() => setTodosPanelOpen(false)}
|
||||
testId="todos-panel-drawer"
|
||||
>
|
||||
<TodoPanel frameless />
|
||||
</MobilePanelDrawer>
|
||||
)}
|
||||
{/* Mobile-only push panel — on desktop the viewer lives inside the inline aside. */}
|
||||
{conversationId && selectedFilePath !== null && (
|
||||
<div className="md:hidden">
|
||||
|
||||
@@ -36,13 +36,9 @@ const mobileMenu = {
|
||||
filesPanelOpen: false,
|
||||
subagentsPanelOpen: false,
|
||||
shellsPanelOpen: false,
|
||||
todosPanelOpen: false,
|
||||
hideTerminalsTab: false,
|
||||
showShellsTab: false,
|
||||
terminalsLength: 0,
|
||||
todosSupported: false,
|
||||
todosCompleted: 0,
|
||||
todosTotal: 0,
|
||||
debugMode: false,
|
||||
changedCount: 0,
|
||||
subagentsWorking: 0,
|
||||
@@ -51,7 +47,6 @@ const mobileMenu = {
|
||||
onOpenChanges: () => {},
|
||||
onOpenShells: () => {},
|
||||
onOpenSubagents: () => {},
|
||||
onOpenTodos: () => {},
|
||||
onOpenMainExecutionLog: () => {},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
GitCompareIcon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListTodoIcon,
|
||||
PanelLeftIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightIcon,
|
||||
@@ -53,20 +52,12 @@ interface MobileSessionMenuProps {
|
||||
subagentsPanelOpen: boolean;
|
||||
/** True while the mobile shells drawer is open. */
|
||||
shellsPanelOpen: boolean;
|
||||
/** True while the mobile tasks drawer is open. */
|
||||
todosPanelOpen: boolean;
|
||||
/** Hide the Shells entry (claude-native sub-agents only). */
|
||||
hideTerminalsTab: boolean;
|
||||
/** Whether the Shells entry is available. */
|
||||
showShellsTab: boolean;
|
||||
/** Number of open terminals (entry badge). */
|
||||
terminalsLength: number;
|
||||
/** Whether the session publishes a todo list (gates the Tasks entry). */
|
||||
todosSupported: boolean;
|
||||
/** Completed todo count (Tasks entry badge numerator). */
|
||||
todosCompleted: number;
|
||||
/** Total todo count (Tasks entry badge denominator + visibility). */
|
||||
todosTotal: number;
|
||||
/** Debug mode — surfaces the Logs entry. */
|
||||
debugMode: boolean;
|
||||
/** Changed-file count (Files entry badge). */
|
||||
@@ -86,8 +77,6 @@ interface MobileSessionMenuProps {
|
||||
onOpenShells: () => void;
|
||||
/** Open the mobile agents drawer. */
|
||||
onOpenSubagents: () => void;
|
||||
/** Open the mobile tasks drawer. */
|
||||
onOpenTodos: () => void;
|
||||
/** Open the main execution-log push panel. */
|
||||
onOpenMainExecutionLog: () => void;
|
||||
}
|
||||
@@ -151,7 +140,7 @@ interface ChatHeaderProps {
|
||||
showFilesPanel: boolean;
|
||||
/**
|
||||
* Whether the right workspace rail has at least one available tab
|
||||
* (files, terminals, sub-agents, or todos). Gates the desktop
|
||||
* (files, terminals, or sub-agents). Gates the desktop
|
||||
* collapse toggle — with no rail content the panel doesn't mount
|
||||
* (see AppShell), so a toggle would flip an invisible card.
|
||||
*/
|
||||
@@ -449,7 +438,6 @@ export function ChatHeader({
|
||||
!mobileMenu.filesPanelOpen &&
|
||||
!mobileMenu.subagentsPanelOpen &&
|
||||
!mobileMenu.shellsPanelOpen &&
|
||||
!mobileMenu.todosPanelOpen &&
|
||||
(hasRailContent || mobileMenu.debugMode) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -532,18 +520,6 @@ export function ChatHeader({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{mobileMenu.todosSupported && mobileMenu.todosTotal > 0 && (
|
||||
<DropdownMenuItem
|
||||
onSelect={mobileMenu.onOpenTodos}
|
||||
className="gap-2.5 px-2.5 py-2 text-ui"
|
||||
>
|
||||
<ListTodoIcon className="size-4" />
|
||||
Tasks
|
||||
<span className={cn(TAB_BADGE_BASE, "ml-auto bg-muted text-muted-foreground")}>
|
||||
{mobileMenu.todosCompleted}/{mobileMenu.todosTotal}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{mobileMenu.debugMode && (
|
||||
<DropdownMenuItem
|
||||
onSelect={mobileMenu.onOpenMainExecutionLog}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Tests for ChatPlanAccordion — the pinned "Plan (N/M)" tracker above the
|
||||
// chat thread. We mock the store so the summary count logic and self-hide are
|
||||
// exercised in isolation; TodoPanel (the expanded list) is covered separately.
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
const h = vi.hoisted(() => ({ todos: [] as TodoItem[] }));
|
||||
vi.mock("@/store/chatStore", () => ({
|
||||
useChatStore: (selector: (s: { todos: TodoItem[] }) => unknown) => selector({ todos: h.todos }),
|
||||
}));
|
||||
|
||||
import { ChatPlanAccordion } from "./ChatPlanAccordion";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
h.todos = [];
|
||||
});
|
||||
|
||||
describe("ChatPlanAccordion", () => {
|
||||
it("renders nothing when there are no tasks", () => {
|
||||
// WHY: the accordion must occupy no space for sessions with no plan — it
|
||||
// returns null so it never shrinks the chat scroll area needlessly.
|
||||
h.todos = [];
|
||||
const { container } = render(<ChatPlanAccordion />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Plan with completed/total counts", () => {
|
||||
// WHY: the always-visible summary is the label the issue specifies —
|
||||
// "Plan (N/M)" where N is completed and M is the total task count.
|
||||
h.todos = [
|
||||
{ content: "a", status: "completed", activeForm: "a" },
|
||||
{ content: "b", status: "in_progress", activeForm: "b" },
|
||||
{ content: "c", status: "pending", activeForm: "c" },
|
||||
];
|
||||
render(<ChatPlanAccordion />);
|
||||
expect(screen.getByText("Plan")).toBeInTheDocument();
|
||||
expect(screen.getByText("(1/3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the tasks in the expandable body", () => {
|
||||
// WHY: the expanded section reuses TodoPanel, so each task's content shows.
|
||||
h.todos = [
|
||||
{ content: "Write tests", status: "pending", activeForm: "Writing tests" },
|
||||
{ content: "Ship it", status: "completed", activeForm: "Shipping it" },
|
||||
];
|
||||
render(<ChatPlanAccordion />);
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ship it")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChevronDownIcon, ListTodoIcon } from "lucide-react";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
|
||||
/**
|
||||
* Collapsible "Plan (N/M)" tracker pinned to the top of the chat thread.
|
||||
*
|
||||
* A bounded, centered card (not a full-width pane) with an accent-tinted
|
||||
* header bar: the always-visible summary shows completed/total, and expanding
|
||||
* reveals the same task list as the workspace Tasks tab (TodoPanel). A flex
|
||||
* sibling of the conversation viewport (not an overlay) so it shrinks the
|
||||
* scroll area rather than covering messages. Self-hides when the session has
|
||||
* no tasks, matching the Tasks tab's `todosSupported` gate.
|
||||
*/
|
||||
export function ChatPlanAccordion({ className }: { className?: string }) {
|
||||
const todos = useChatStore((s) => s.todos);
|
||||
if (todos.length === 0) return null;
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
|
||||
return (
|
||||
// Full-width layout row with side gutters; the card centers on the chat
|
||||
// column so it lines up with the message thread below.
|
||||
<div className={cn("shrink-0 px-3 md:px-4", className)}>
|
||||
<details
|
||||
data-testid="plan-tracker"
|
||||
className="group mx-auto max-w-3xl overflow-hidden rounded-xl border border-accent-foreground/20 bg-card shadow-sm mb-1"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none select-none items-center gap-2 bg-accent px-3 py-2 text-ui font-medium text-accent-foreground [&::-webkit-details-marker]:hidden">
|
||||
<ListTodoIcon className="size-4 shrink-0" />
|
||||
<span>Plan</span>
|
||||
<span
|
||||
className="opacity-70"
|
||||
aria-label={`${completed} of ${todos.length} tasks completed`}
|
||||
>
|
||||
({completed}/{todos.length})
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-auto size-4 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
{/* Cap the expanded list at 150px so a long plan can't swallow the
|
||||
chat; it scrolls internally past the cap. */}
|
||||
<div className="max-h-[150px] overflow-y-auto border-t border-border">
|
||||
<TodoPanel frameless />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AlertTriangleIcon,
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CloudOffIcon,
|
||||
@@ -806,6 +807,8 @@ function FileViewerBody({
|
||||
key: string;
|
||||
/** Accessible name for the inline icon button. */
|
||||
label: string;
|
||||
/** Text label for the inline icon button. */
|
||||
textLabel?: string;
|
||||
/** Tooltip + dropdown row text; falls back to `label` when omitted. */
|
||||
tooltip?: string;
|
||||
icon: ReactNode;
|
||||
@@ -876,6 +879,7 @@ function FileViewerBody({
|
||||
toolbarActions.push({
|
||||
key: "md-view-mode",
|
||||
label: `View mode: ${activeMode.label}`,
|
||||
textLabel: activeMode.label,
|
||||
tooltip: "View mode",
|
||||
icon: activeMode.icon,
|
||||
options: modeOptions,
|
||||
@@ -1091,11 +1095,13 @@ function FileViewerBody({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size={action.textLabel ? "sm" : "icon-sm"}
|
||||
aria-label={action.label}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.icon}
|
||||
{action.textLabel}
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -1107,7 +1113,7 @@ function FileViewerBody({
|
||||
{action.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.key}
|
||||
className={cn("whitespace-nowrap", option.active && "bg-muted dark:bg-muted/50")}
|
||||
className={cn("whitespace-nowrap")}
|
||||
onSelect={interactive ? option.onSelect : undefined}
|
||||
>
|
||||
{option.icon}
|
||||
|
||||
@@ -249,3 +249,100 @@ describe("ResumeWithDirectoryDialog", () => {
|
||||
expect(screen.queryByTestId("resume-dir-bind-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function renderHostless(prefill?: {
|
||||
hostId?: string | null;
|
||||
workspace?: string | null;
|
||||
gitBranch?: string | null;
|
||||
}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ResumeWithDirectoryDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
sessionId="conv_imported"
|
||||
// Host-less: no fork source.
|
||||
sourceSessionId={null}
|
||||
prefill={prefill}
|
||||
serverUrl="http://localhost:5173"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ResumeWithDirectoryDialog (host-less session)", () => {
|
||||
it("prefills from the session's own fields and binds without fetching a source", async () => {
|
||||
useHostsMock.mockReturnValue({
|
||||
data: [
|
||||
{ host_id: "host_a", name: "laptop", owner: "me", status: "online" },
|
||||
{ host_id: "host_b", name: "desktop", owner: "me", status: "online" },
|
||||
],
|
||||
} as unknown as ReturnType<typeof useHosts>);
|
||||
|
||||
// Prefer the session's recorded host when it's online; prefill the dir.
|
||||
renderHostless({ hostId: "host_b", workspace: "/Users/alice/imported" });
|
||||
|
||||
const bindBtn = await screen.findByTestId("resume-dir-bind-button");
|
||||
await waitFor(() => expect((bindBtn as HTMLButtonElement).disabled).toBe(false));
|
||||
await waitFor(() =>
|
||||
expect((screen.getByTestId("mock-workspace-input") as HTMLInputElement).value).toBe(
|
||||
"/Users/alice/imported",
|
||||
),
|
||||
);
|
||||
fireEvent.click(bindBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(launchRunnerMock).toHaveBeenCalledWith(
|
||||
"host_b",
|
||||
"conv_imported",
|
||||
"/Users/alice/imported",
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
// No fork source → the source session is never fetched.
|
||||
expect(getSessionMock).not.toHaveBeenCalled();
|
||||
// No source means no mismatch/CLI-fallback machinery.
|
||||
expect(screen.queryByTestId("resume-dir-mismatch-warning")).toBeNull();
|
||||
expect(screen.queryByTestId("resume-dir-cli-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults the host to the caller's current online machine when the recorded host is gone", async () => {
|
||||
// Recorded host offline (or absent from the list) → fall back to the
|
||||
// most-recent online machine (first in the list).
|
||||
useHostsMock.mockReturnValue({
|
||||
data: [
|
||||
{ host_id: "host_current", name: "laptop", owner: "me", status: "online" },
|
||||
{ host_id: "host_old", name: "old", owner: "me", status: "offline" },
|
||||
],
|
||||
} as unknown as ReturnType<typeof useHosts>);
|
||||
|
||||
renderHostless({ hostId: "host_old", workspace: "/repo" });
|
||||
|
||||
const bindBtn = await screen.findByTestId("resume-dir-bind-button");
|
||||
await waitFor(() => expect((bindBtn as HTMLButtonElement).disabled).toBe(false));
|
||||
fireEvent.click(bindBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(launchRunnerMock).toHaveBeenCalledWith(
|
||||
"host_current",
|
||||
"conv_imported",
|
||||
"/repo",
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the no-online-hosts message (no CLI fallback) when none are online", async () => {
|
||||
useHostsMock.mockReturnValue({
|
||||
data: [{ host_id: "host_old", name: "old", owner: "me", status: "offline" }],
|
||||
} as unknown as ReturnType<typeof useHosts>);
|
||||
|
||||
renderHostless({ workspace: "/repo" });
|
||||
|
||||
expect(await screen.findByTestId("resume-dir-no-hosts")).toBeTruthy();
|
||||
expect(screen.queryByTestId("resume-dir-cli-fallback")).toBeNull();
|
||||
expect(screen.queryByTestId("resume-dir-host-select")).toBeNull();
|
||||
expect(launchRunnerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,31 +34,34 @@ import { useRecentWorkspaces } from "@/hooks/useRecentWorkspaces";
|
||||
import { getSessionSlim, launchRunner } from "@/lib/sessionsApi";
|
||||
|
||||
/**
|
||||
* Dialog surfaced when the user tries to chat with an unbound *coding*
|
||||
* clone (a fork of a session that had a working directory — it carries
|
||||
* the ``omnigent.fork.source_id`` label). Unlike ``ResumeChatDialog``
|
||||
* (which only prints a CLI command), this binds the clone to a host +
|
||||
* directory in-app via ``POST /v1/hosts/{id}/runners`` (``launchRunner``)
|
||||
* and lets the runner start, after which ChatPage replays the queued
|
||||
* message.
|
||||
* Dialog that binds an *unbound* session to a host + directory in-app via
|
||||
* ``POST /v1/hosts/{id}/runners`` (``launchRunner``) and lets the runner
|
||||
* start, after which ChatPage replays any queued message. Unlike
|
||||
* ``ResumeChatDialog`` (which only prints a CLI command), this resumes the
|
||||
* session from the browser. Serves two unbound cases:
|
||||
*
|
||||
* The picker prefills from the *source* session (same-user CUJ 1):
|
||||
* the source's host is the default, its workspace the default directory,
|
||||
* and — when the source used a git worktree — a branch is suggested so
|
||||
* the clone diverges onto its own worktree rather than fighting the
|
||||
* original over the same files.
|
||||
* - **Fork clone** (``sourceSessionId`` set): a fork of a session that had a
|
||||
* working directory (``omnigent.fork.source_id`` label). Prefills from the
|
||||
* *source* session — its host is the default, its workspace the default
|
||||
* directory, and when it used a git worktree a branch is suggested so the
|
||||
* clone diverges onto its own worktree. When the source's host is offline
|
||||
* there's nothing to launch on, so it falls back to the CLI reconnect
|
||||
* command — the escape hatch ``ResumeChatDialog`` shows.
|
||||
*
|
||||
* When the source's host is offline there is no runner to launch, so the
|
||||
* dialog falls back to the CLI reconnect command (``omnigent connect``)
|
||||
* — the same escape hatch ``ResumeChatDialog`` shows.
|
||||
* - **Host-less session** (no ``sourceSessionId``): an imported session with
|
||||
* no host/runner of its own. Prefills from ``prefill`` (the session's own
|
||||
* recorded workspace/host) and defaults the host to the caller's current
|
||||
* online machine, so the common case is one click.
|
||||
*
|
||||
* @param open - Whether the dialog is visible.
|
||||
* @param onOpenChange - Radix-controlled visibility setter.
|
||||
* @param sessionId - The unbound clone to bind, e.g. ``"conv_clone"``.
|
||||
* @param sourceSessionId - The source the clone was forked from
|
||||
* (``omnigent.fork.source_id``); read for host/dir/branch prefill.
|
||||
* @param sessionId - The unbound session to bind, e.g. ``"conv_abc"``.
|
||||
* @param sourceSessionId - Fork source (``omnigent.fork.source_id``) read for
|
||||
* host/dir/branch prefill; ``null``/absent for a host-less session.
|
||||
* @param prefill - Defaults for the host-less case (the session's own
|
||||
* host/workspace/branch). Ignored when ``sourceSessionId`` is set.
|
||||
* @param serverUrl - Origin for the CLI fallback command.
|
||||
* @param wrapper - The clone's ``omnigent.wrapper`` label (CLI fallback).
|
||||
* @param wrapper - The session's ``omnigent.wrapper`` label (CLI fallback).
|
||||
* @param onBound - Called after a successful bind so the caller can
|
||||
* replay the message the user was trying to send.
|
||||
*/
|
||||
@@ -67,6 +70,7 @@ export function ResumeWithDirectoryDialog({
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
sourceSessionId,
|
||||
prefill,
|
||||
serverUrl,
|
||||
wrapper,
|
||||
onBound,
|
||||
@@ -74,19 +78,24 @@ export function ResumeWithDirectoryDialog({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string;
|
||||
sourceSessionId: string;
|
||||
sourceSessionId?: string | null;
|
||||
prefill?: { hostId?: string | null; workspace?: string | null; gitBranch?: string | null };
|
||||
serverUrl: string;
|
||||
wrapper?: string | null;
|
||||
onBound?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// A fork clone prefills from its source session; a host-less session has no
|
||||
// source and prefills from its own recorded fields instead.
|
||||
const hasSource = sourceSessionId != null && sourceSessionId !== "";
|
||||
|
||||
// Source session prefill (host/workspace/git_branch). Only fetch while
|
||||
// the dialog is open.
|
||||
// the dialog is open and we actually have a source.
|
||||
const { data: source, isLoading: sourceLoading } = useQuery({
|
||||
queryKey: ["session", sourceSessionId],
|
||||
queryFn: () => getSessionSlim(sourceSessionId),
|
||||
enabled: open,
|
||||
queryFn: () => getSessionSlim(sourceSessionId as string),
|
||||
enabled: open && hasSource,
|
||||
});
|
||||
const { data: hosts } = useHosts({ enabled: open });
|
||||
|
||||
@@ -98,6 +107,19 @@ export function ResumeWithDirectoryDialog({
|
||||
const sourceHostOnline = sourceHost?.status === "online";
|
||||
const onlineHosts = useMemo(() => (hosts ?? []).filter((h) => h.status === "online"), [hosts]);
|
||||
|
||||
// Unified prefill: a fork reads its source session; a host-less session
|
||||
// reads the ``prefill`` its own snapshot supplied.
|
||||
const prefillWorkspace = hasSource ? source?.workspace : prefill?.workspace;
|
||||
const prefillBranch = hasSource ? source?.gitBranch : prefill?.gitBranch;
|
||||
// Default host: the source's host (fork, only if online — otherwise the CLI
|
||||
// fallback fires) or, for a host-less session, its own recorded host when
|
||||
// still online, else the caller's current (most-recent) online machine.
|
||||
const defaultHostId = useMemo(() => {
|
||||
if (hasSource) return sourceHostOnline ? sourceHostId : null;
|
||||
const preferred = onlineHosts.find((h) => h.host_id === prefill?.hostId);
|
||||
return (preferred ?? onlineHosts[0])?.host_id ?? null;
|
||||
}, [hasSource, sourceHostOnline, sourceHostId, onlineHosts, prefill?.hostId]);
|
||||
|
||||
const [selectedHostId, setSelectedHostId] = useState<string | null>(null);
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [branchName, setBranchName] = useState("");
|
||||
@@ -109,27 +131,27 @@ export function ResumeWithDirectoryDialog({
|
||||
|
||||
const { recent, addRecent } = useRecentWorkspaces(selectedHostId);
|
||||
|
||||
// Prefill host = source host (when it is online) once both load.
|
||||
// Prefill the host selection once the hosts (and source, if any) load.
|
||||
useEffect(() => {
|
||||
if (open && selectedHostId === null && sourceHostId && sourceHostOnline) {
|
||||
setSelectedHostId(sourceHostId);
|
||||
if (open && selectedHostId === null && defaultHostId) {
|
||||
setSelectedHostId(defaultHostId);
|
||||
}
|
||||
}, [open, selectedHostId, sourceHostId, sourceHostOnline]);
|
||||
}, [open, selectedHostId, defaultHostId]);
|
||||
|
||||
// Prefill the directory with the source's workspace.
|
||||
// Prefill the directory with the session's recorded workspace.
|
||||
useEffect(() => {
|
||||
if (open && workspace === "" && source?.workspace) {
|
||||
setWorkspace(source.workspace);
|
||||
if (open && workspace === "" && prefillWorkspace) {
|
||||
setWorkspace(prefillWorkspace);
|
||||
}
|
||||
}, [open, workspace, source?.workspace]);
|
||||
}, [open, workspace, prefillWorkspace]);
|
||||
|
||||
// When the source used a worktree, default the base ref to that branch
|
||||
// so the clone branches off where the original left work.
|
||||
useEffect(() => {
|
||||
if (open && baseBranch === "" && source?.gitBranch) {
|
||||
setBaseBranch(source.gitBranch);
|
||||
if (open && baseBranch === "" && prefillBranch) {
|
||||
setBaseBranch(prefillBranch);
|
||||
}
|
||||
}, [open, baseBranch, source?.gitBranch]);
|
||||
}, [open, baseBranch, prefillBranch]);
|
||||
|
||||
// Reset transient state when the dialog closes.
|
||||
function handleOpenChange(next: boolean): void {
|
||||
@@ -227,21 +249,32 @@ export function ResumeWithDirectoryDialog({
|
||||
// flashing the CLI fallback for an online source host would be wrong.
|
||||
const hostsLoaded = hosts !== undefined;
|
||||
const showCliFallback = !sourceLoading && hostsLoaded && source != null && !sourceHostOnline;
|
||||
const loading = (hasSource && sourceLoading) || !hostsLoaded;
|
||||
// Host-less sessions have no source host to reconnect, so there's no CLI
|
||||
// fallback: when the caller owns no online machine, say so plainly.
|
||||
const noOnlineHosts = !hasSource && hostsLoaded && onlineHosts.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent data-testid="resume-dir-dialog" className="flex flex-col gap-4 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Resume this session</DialogTitle>
|
||||
<DialogTitle>{hasSource ? "Resume this session" : "Resume on a machine"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This clone hasn't picked a working directory yet. Choose a host and directory to
|
||||
continue the conversation against your files.
|
||||
{hasSource
|
||||
? "This clone hasn't picked a working directory yet. Choose a host and directory to continue the conversation against your files."
|
||||
: "This session isn't running on any machine. Pick one of your machines and a directory to continue the conversation against your files."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{sourceLoading || !hostsLoaded ? (
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="resume-dir-loading">
|
||||
Loading the original session's directory…
|
||||
{hasSource ? "Loading the original session's directory…" : "Loading your machines…"}
|
||||
</p>
|
||||
) : noOnlineHosts ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="resume-dir-no-hosts">
|
||||
None of your machines are online. Start one with{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono">omnigent host</code> from your
|
||||
terminal, then reopen this dialog.
|
||||
</p>
|
||||
) : showCliFallback ? (
|
||||
<div className="flex flex-col gap-2" data-testid="resume-dir-cli-fallback">
|
||||
|
||||
@@ -31,9 +31,6 @@ vi.mock("./InlineTerminalsSection", () => ({
|
||||
vi.mock("./SubagentsPanel", () => ({
|
||||
SubagentsPanel: () => <div data-testid="subagents-stub" />,
|
||||
}));
|
||||
vi.mock("./TodoPanel", () => ({
|
||||
TodoPanel: () => <div data-testid="todos-stub" />,
|
||||
}));
|
||||
vi.mock("@/components/BrowserPane/BrowserPane", () => ({
|
||||
BrowserPane: ({ conversationId }: { conversationId: string }) => (
|
||||
<div data-testid="browser-pane-stub">{conversationId}</div>
|
||||
@@ -115,9 +112,6 @@ function renderWorkspace(
|
||||
terminalsLength={0}
|
||||
subagentsWorking={0}
|
||||
agentCount={1}
|
||||
todosSupported={false}
|
||||
todosCompleted={0}
|
||||
todosTotal={0}
|
||||
rootSessionId={null}
|
||||
selectedFilePath={overrides.selectedFilePath ?? null}
|
||||
openFiles={overrides.openFiles ?? []}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
FolderTreeIcon,
|
||||
FileDiffIcon,
|
||||
GlobeIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
MaximizeIcon,
|
||||
MinimizeIcon,
|
||||
@@ -40,7 +39,6 @@ import { FileViewer } from "./FileViewer";
|
||||
import type { ChangedSort } from "./FlatFileList";
|
||||
import { InlineTerminalsSection } from "./InlineTerminalsSection";
|
||||
import { SubagentsPanel } from "./SubagentsPanel";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
import { useTerminalStatuses } from "./useTerminalStatuses";
|
||||
import { type RightRailTab, TAB_BADGE_BASE } from "./railTabs";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -262,7 +260,7 @@ function NewTabMenu({
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileTabsStrip — open file tabs rendered in the top rail tab strip, as peers
|
||||
// of the fixed Files/Terminals/Agents/Tasks tabs. Each tab is a cell with the
|
||||
// of the fixed Files/Terminals/Agents tabs. Each tab is a cell with the
|
||||
// file's basename and an "x" close button. Clicking the cell activates the
|
||||
// tab (opening its viewer); clicking the x closes it. No own scroll container
|
||||
// or flex-1: the parent strip's overflow-x-auto scrolls the whole row.
|
||||
@@ -544,12 +542,6 @@ interface WorkspacePanelProps {
|
||||
* badge denominator) — starts at 1 for a lone agent.
|
||||
*/
|
||||
agentCount: number;
|
||||
/** Whether the session publishes a todo list (gates the Tasks tab). */
|
||||
todosSupported: boolean;
|
||||
/** Number of completed todos (Tasks tab badge numerator). */
|
||||
todosCompleted: number;
|
||||
/** Total todo count (Tasks tab badge denominator + visibility gate). */
|
||||
todosTotal: number;
|
||||
/**
|
||||
* The "root" session id for the Agents tab — the active session's
|
||||
* parent when inside a child, else the active id. May be null while
|
||||
@@ -603,7 +595,7 @@ interface WorkspacePanelProps {
|
||||
* WorkspacePanel — the desktop right "Workspace" rail, rendered as a
|
||||
* floating card (bg-card, rounded, bordered, shadowed) sitting below the
|
||||
* full-width chat header band. Internally tabbed between Files, Changes,
|
||||
* Terminals, Agents and Tasks so each can claim the full rail height
|
||||
* Terminals and Agents so each can claim the full rail height
|
||||
* instead of competing for a vertically-split slot.
|
||||
*
|
||||
* Desktop-only (``hidden md:flex``): on mobile the rail's contents are
|
||||
@@ -628,9 +620,6 @@ export function WorkspacePanel({
|
||||
terminalsLength,
|
||||
subagentsWorking,
|
||||
agentCount,
|
||||
todosSupported,
|
||||
todosCompleted,
|
||||
todosTotal,
|
||||
rootSessionId,
|
||||
selectedFilePath,
|
||||
openFiles,
|
||||
@@ -702,7 +691,7 @@ export function WorkspacePanel({
|
||||
className="absolute inset-y-0 left-0 z-10 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors"
|
||||
/>
|
||||
)}
|
||||
{/* Tab strip, in display order Files · Changes · Agents · Shells · Tasks.
|
||||
{/* Tab strip, in display order Files · Changes · Agents · Shells.
|
||||
Files (full folder tree) and Changes (changed-files-only list) are
|
||||
two peer tabs — same gate (an on-disk workspace), same FilesPanel,
|
||||
each pinned to one scope. Agents is always present (the Agents panel
|
||||
@@ -796,21 +785,6 @@ export function WorkspacePanel({
|
||||
</TabsTrigger>
|
||||
</WorkspaceTabTooltip>
|
||||
)}
|
||||
{todosSupported && todosTotal > 0 && (
|
||||
<WorkspaceTabTooltip label="Tasks">
|
||||
<TabsTrigger
|
||||
value="todos"
|
||||
aria-label={`Tasks ${todosCompleted} of ${todosTotal} completed`}
|
||||
className="size-6 shrink-0 p-0 hover:border-1 hover:border-muted rounded-md!"
|
||||
>
|
||||
<ListTodoIcon />
|
||||
<span className="sr-only">Tasks</span>
|
||||
<span className="sr-only">
|
||||
{todosCompleted}/{todosTotal}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</WorkspaceTabTooltip>
|
||||
)}
|
||||
{showBrowserTab && (
|
||||
<WorkspaceTabTooltip label="Browser">
|
||||
<TabsTrigger
|
||||
@@ -930,8 +904,6 @@ export function WorkspacePanel({
|
||||
<BrowserPane conversationId={conversationId} className="min-h-0 flex-1" />
|
||||
) : rightRailTab === "subagents" && rootSessionId ? (
|
||||
<SubagentsPanel conversationId={conversationId} rootSessionId={rootSessionId} />
|
||||
) : rightRailTab === "todos" && todosSupported ? (
|
||||
<TodoPanel frameless />
|
||||
) : rightRailTab === "terminals" && showShellsTab ? (
|
||||
<InlineTerminalsSection conversationId={conversationId} onExpand={openTerminalTab} />
|
||||
) : (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
/** The selectable tabs in the right workspace rail, in display order. */
|
||||
export type RightRailTab = "files" | "changes" | "subagents" | "terminals" | "todos" | "browser";
|
||||
export type RightRailTab = "files" | "changes" | "subagents" | "terminals" | "browser";
|
||||
|
||||
/**
|
||||
* Count/status badge geometry. Fixed height with min-width == height keeps a
|
||||
|
||||
@@ -2969,10 +2969,12 @@ describe("chatStore — background-shell tally (claude-native)", () => {
|
||||
expect(state.activeResponse?.state).toBe("completed");
|
||||
});
|
||||
|
||||
it("clears the shell count when a new turn starts (running edge)", () => {
|
||||
// A `running` edge with no count means a fresh turn began; the prior
|
||||
// turn's tally is stale and must clear, mirroring the server's
|
||||
// `_publish_status`. (The PTY `running` carries no count.)
|
||||
it("keeps the sticky shell count when a new turn starts (running edge)", () => {
|
||||
// A `running` edge with no count means a fresh turn began, but shells
|
||||
// launched earlier keep running across the turn boundary — so the tally
|
||||
// persists and the composer pill stays lit alongside the "Working…"
|
||||
// shimmer. (The PTY `running` carries no count; the next Stop hook
|
||||
// re-reports authoritatively.) Mirrors the server's `_publish_status`.
|
||||
useChatStore.setState({
|
||||
conversationId: "conv_abc",
|
||||
sessionStatus: "idle",
|
||||
@@ -2984,23 +2986,31 @@ describe("chatStore — background-shell tally (claude-native)", () => {
|
||||
conversationId: "conv_abc",
|
||||
status: "running",
|
||||
});
|
||||
expect(useChatStore.getState().backgroundTaskCount).toBe(0);
|
||||
const state = useChatStore.getState();
|
||||
expect(state.sessionStatus).toBe("running");
|
||||
expect(state.backgroundTaskCount).toBe(2);
|
||||
});
|
||||
|
||||
it("clears the sticky shell count when the user sends a new turn", async () => {
|
||||
// Asking another question while shells run supersedes the prior turn's
|
||||
// tally: the label must flip from "N background tasks still running" to "Working…"
|
||||
// immediately, not linger until the next status edge.
|
||||
it("keeps the sticky shell count when the user sends a new turn", async () => {
|
||||
// Asking another question while shells run must not blink the pill off: the
|
||||
// shells keep running across the new turn, so the tally persists and the
|
||||
// pill stays up alongside the "Working…" shimmer. The next Stop hook
|
||||
// re-reports it authoritatively.
|
||||
seedSession("conv_abc");
|
||||
useChatStore.setState({ conversationId: "conv_abc", status: "idle" });
|
||||
// Bind the stream first (a real, open session the user is looking at) so the
|
||||
// follow-up send below takes the already-bound path and does NOT re-hydrate
|
||||
// the count from the snapshot.
|
||||
await useChatStore.getState().send("first question", "agent_xyz");
|
||||
// A prior turn ended with a background shell still running.
|
||||
useChatStore.setState({
|
||||
conversationId: "conv_abc",
|
||||
sessionStatus: "waiting",
|
||||
backgroundTaskCount: 2,
|
||||
status: "idle",
|
||||
activeResponse: null,
|
||||
});
|
||||
await useChatStore.getState().send("another question", "agent_xyz");
|
||||
expect(useChatStore.getState().backgroundTaskCount).toBe(0);
|
||||
expect(useChatStore.getState().backgroundTaskCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1569,12 +1569,13 @@ export const useChatStore = create<ChatState>((_rootSet, get) => ({
|
||||
...(selfAuthor !== null ? { author: selfAuthor } : {}),
|
||||
},
|
||||
],
|
||||
// A new turn supersedes the prior turn's background-shell tally: the
|
||||
// "N background tasks still running" label must give way to "Working…" the
|
||||
// moment the user sends, not linger until the next status edge. The
|
||||
// count is sticky (see the `session_status` handler) precisely so a
|
||||
// trailing idle can't wipe it, so it has to be cleared explicitly here.
|
||||
backgroundTaskCount: 0,
|
||||
// A new turn does NOT supersede the background-shell tally: shells
|
||||
// launched in an earlier turn keep running across the turn boundary, so
|
||||
// the composer pill must stay lit alongside the "Working…" shimmer rather
|
||||
// than blink off the moment the user sends. The count is sticky (see the
|
||||
// `session_status` handler) and the next Stop hook re-reports it
|
||||
// authoritatively. Only the parked-dialog reason clears — a fresh send is
|
||||
// not parked on a dialog.
|
||||
blockedOn: null,
|
||||
}));
|
||||
|
||||
@@ -5308,11 +5309,14 @@ export function handleSessionEvent(event: StreamEvent, streamConversationId?: st
|
||||
// after it appeared. So: an explicit count is authoritative (a Stop
|
||||
// hook's `0` clears it, so a finished shell drops the indicator on the
|
||||
// next turn end; a positive count sets it); `undefined` leaves it
|
||||
// untouched; and a new turn (`running`) or a failure clears it —
|
||||
// mirroring the server's `_publish_status`.
|
||||
// untouched. A new `running` turn does NOT clear it — background shells
|
||||
// outlive turn boundaries, so the pill stays lit alongside the "Working…"
|
||||
// shimmer and the next Stop hook re-reports authoritatively. Only a
|
||||
// failure clears it (a dead session may never post another count to drop
|
||||
// a stale tally). Mirrors the server's `_publish_status`.
|
||||
if (event.backgroundTaskCount !== undefined) {
|
||||
patch.backgroundTaskCount = event.backgroundTaskCount;
|
||||
} else if (event.status === "running" || event.status === "failed") {
|
||||
} else if (event.status === "failed") {
|
||||
patch.backgroundTaskCount = 0;
|
||||
}
|
||||
if (event.responseId !== undefined && event.status === "running") {
|
||||
|
||||
Reference in New Issue
Block a user