Compare commits

...

16 Commits

Author SHA1 Message Date
harry-yao_data 4fc575e518 perf(terminals): keep FastAPI out of the native CLI launch path
`claude_native.py` imported two integer close codes from
`omnigent.terminals.ws_bridge`. That bridge serves the `/attach`
WebSocket, so it imports FastAPI (~120ms) and, through the package
barrel, the tmux registry (~100ms) — all of it loaded on every
`omnigent claude` launch to compare a close code to `4404`.

Move the four 4xxx codes into a dependency-free
`omnigent.terminals.close_codes`. They are the wire contract between the
server route, the runner, the native client, and the browser, so no
consumer should have to import the socket implementation to read one.
Every consumer now imports from the leaf module, leaving one definition
site rather than an implicit re-export.

Also resolve the `omnigent.terminals` barrel's two exports through PEP
562, so importing a leaf module no longer builds `TerminalRegistry` (and
`omnigent.inner.terminal` under it). `from omnigent.terminals import
TerminalRegistry` is unchanged.

`import omnigent.claude_native`: 0.72s -> 0.57s (-150ms), with FastAPI,
Starlette, and the tmux registry no longer in the graph. Reading a close
code loads 85 modules instead of 288.

The guards pin the published code values (a change there is a protocol
break needing the browser mirror updated) and both import boundaries.

Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-22 08:50:24 +00:00
Jeremiah lu b551f669d6 fix(openai-agents): wrap string assistant content for the chat converter (#4824)
Resuming a conversation whose history contained an assistant message with
plain-string content crashed before reaching the model:

    TypeError: string indices must be integers, not 'str'
      chatcmpl_converter.py:625 in items_to_messages

A string is legal Responses-API content, but items_to_messages iterates an
assistant message's content expecting blocks. Given a string it walks the text
character by character and indexes each character, so the very first one raises.
User strings are unaffected — they reach extract_text_content, which accepts
them, and callers depend on them staying strings.

Because history is replayed on every turn, one such item ends the conversation
permanently: each retry fails identically before the model is reached, and the
only escape is to abandon the conversation.

Only the assistant branch is normalized, and only when content is a string, so
block content and user strings pass through untouched.

Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-20 05:41:00 +09:00
Yuan Tang ddc5f6ee60 chore(k8s): Address ArgoCD overlay review follow-ups and PR #4744 comments (#4982)
* Address ArgoCD overlay review follow-ups (#4977) and PR #4744 comments

Add CI validation of kustomize overlays, clarify the ignoreDifferences
/data vs /stringData ArgoCD normalization, separate sync-completes from
app-healthy in the Ingress wave comment, add TODO(v0.29) to the
bare-Pod fallback in terminate(), and update the sandbox-runners README
to reflect the bare-Pod → Job migration.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix(ci): install kustomize via official script instead of third-party action

The pinned SHA for imranismail/setup-kustomize was unresolvable.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix(docs): correct backoffLimit value in sandbox-runners README

The README stated `backoffLimit: 0` but the actual code uses
`_JOB_BACKOFF_LIMIT = 6` — fix the doc to match.

---------

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-08-19 12:38:39 -07:00
Dhruv Gupta 473941e322 fix(acp): resolve the tool identity on a bare permission request (#5050)
An ACP agent may ask permission carrying only a `toolCallId` — no `title`,
`kind`, or `rawInput`. Devin does. `_extract_tool_call` then resolved the name to
the literal string "tool" with empty arguments, so:

- the approval card asked the user to approve "Devin wants to use **tool**",
  preview `tool({})`, with no command shown; and
- the TOOL_CALL policy was evaluated as `{"name": "tool", "arguments": {}}`,
  which no builtin rule can match — rules gate on the tool name before reading
  `arguments["command"]`, so a "deny `rm -rf`" policy sat silent.

The originating `tool_call` update carries the real name and command and always
arrives first, and the executor already caches `toolCallId -> name` there to
close the right tool card. Cache the `rawInput` beside it and fall back to both
when the request omits them; values the request does carry still win. The
correlation is the protocol's own id, so no vendor `_meta` key is read.

Both caches are released when the call closes, as the name cache already was.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-08-19 12:31:39 -07:00
Corey Zumar 1f575ba8de fix(docker): honor configured execution timeout (#5016)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 11:27:43 -07:00
Corey Zumar 8f63f3271b fix: preserve managed host logs on relaunch (#5042)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 11:17:01 -07:00
Edwin He 9df23f7aeb chore(ucode): pin OSS ucode to a fixed commit (#5043)
Omnigent's uvx setup path resolved ucode from the mutable `main` branch, so
setup could silently pick up a new ucode commit between runs and break
unexpectedly. Pin `_UCODE_GIT_REF` to a fixed, known-good commit
(94271a78c7139220b7333bcae91e522f95ef3af3) so setup is reproducible.

A full SHA is immutable, so uvx caches the built wheel by ref and reuses it
across runs; drop the `--refresh-package ucode` that existed only to defeat
the mutable branch's stale cache.

Co-authored-by: Isaac

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-19 18:08:48 +00:00
Mark Tai c6d1f7d5e0 feat(web): resume imported / host-less sessions from the web (#4905)
An imported or otherwise unbound session (no host, no runner) couldn't run from
the web: it read as reachable (so the first message dropped against a runner
that can't start) or dead-ended on the terminal reconnect path.

The fix is mostly server-side liveness. An imported transcript is a
native-harness session that only runs in a runner on a host, never in-process,
so report it as runner_online=false via a new `imported` connectivity marker
(keyed on the omnigent.import.source label — the sibling of the existing fork
`needs_workspace` marker, computed in the same query). With that, the open view
routes to the EXISTING host picker (ResumeWithDirectoryDialog) instead of the
dead end. That picker — the same one forks and new-chat use — binds the session
to an online host + workspace (defaulting to the caller's current host) and
launches a runner via the existing POST /v1/hosts/{id}/runners path. No new
host-selection UI, no new launch route.

The picker is offered only when the resume will actually work
(unboundSessionResumableInApp): the caller must OWN the session (launch_runner
requires owner — a shared non-owner 404s), and for imports the harness must
reconstruct context from the omnigent transcript so it carries onto a chosen
host. Kimi has no resume path, and kiro/qwen resume only from a local recording
that lives on the original machine, so those route to the terminal reconnect
path instead of a picker that would start blank.

Also:
- Skip the cold-boot startup grace for imports so the picker shows at once.
- Generalize ResumeWithDirectoryDialog to prefill from the session's own fields
  when there is no fork source.
- `omnigent import` prints the session's browser URL instead of the bare id.

Co-authored-by: Isaac

Signed-off-by: Mark Tai <mark.tai@databricks.com>
Co-authored-by: Mark Tai <mark.tai@databricks.com>
2026-08-19 10:16:18 -07:00
Corey Zumar 05eaa253d1 fix(host): retry Databricks auth refresh (#5014)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 10:12:39 -07:00
Evelyn Hur 699809de6e Show actual server target in host-daemon conflict error (#4821)
The "A host daemon is already running for this server" error suggested
`omnigent host stop --server ...`, where the literal `...` hid the fact
that `--server` needs an argument and left users guessing which value to
pass. Build the hint via the existing `_host_stop_command` helper from
the conflicting record, so the message prints a ready-to-run command:
the real URL for a remote daemon, or `--server ""` (the empty-string
alias) for a local daemon, matching the `host --background` hint.

Co-authored-by: Isaac

Signed-off-by: Evelyn Hur <122575337+evelyn-hur@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-20 01:11:47 +08:00
Hubert 4f05fbd0ac [OMNI-2359] [OMNI-2350] Show the task tracker inside the chat (#5036)
* Move tasks to chat box

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* Remove tasks from tab

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* padding

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* test(web): e2e test for the in-chat Plan tracker

Drives the real chat store (mocked todos) through ChatPlanAccordion:
collapsed by default, expands to the task list, tracks a live
completion-count update, and self-hides when the list is cleared.

Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* test(server): cover per-item todo validation filter; fix e2e-test lint

Adds a pytest case proving _handle_external_session_todos drops
malformed todo items (bad status / non-str content / non-str
activeForm / non-dict) while keeping well-formed ones, on both the
session.todos SSE channel and the cached snapshot — the one todos-
pipeline path the existing tests didn't exercise.

Also switch the ChatPlanAccordion e2e test's Todo `type` to an
`interface` to satisfy oxlint (consistent-type-definitions).

Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* test(e2e_ui): browser e2e for the in-chat Plan tracker

Move the tracker's e2e coverage into the Playwright suite where it can
exercise the real UI: tests/e2e_ui/chat/test_plan_tracker.py seeds the
session.todos contract through the events route (the forwarders' path),
then asserts the pinned Plan card seeds from the snapshot on load, stays
collapsed by default, expands to the task list on click, tracks a live
completion count, and disappears when the list clears. Mirrors
test_mcp_startup_indicator.py's seed-then-republish pattern.

Adds a data-testid="plan-tracker" hook to ChatPlanAccordion, and drops
the jsdom vitest e2e (web/.../ChatPlanAccordion.e2e.test.tsx) it
supersedes; the ChatPlanAccordion unit test stays.

Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

* docs(web): align Plan accordion max-height comment with code

The comment said "Cap the expanded list at 100px" while the class is
max-h-[150px]; sync the number (flagged by Polly review). Comment-only.

Co-authored-by: Isaac
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
2026-08-19 17:11:45 +02:00
Hubert 537620909c [OMNI-3751] Change document view mode toggle to dropdown (#5015)
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-08-19 14:47:33 +02:00
Corey Zumar ed9369474f fix(web): stop rendering single-dollar spans as LaTeX math (#5013)
A single $ is prose far more often than a math delimiter — currency,
rates like $/PR and $/session, shell variables — and single-dollar math
paired any two of them up, rendering everything in between as
letter-by-letter math soup. Require $$ to open math and drop the
currency/env-var escaping heuristics that tried to guess prose apart from
math. Explicit TeX delimiters now normalize to $$ so \(x\) still
renders as inline math.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 01:52:35 -07:00
omnigent-ci[bot] 1fceeeb754 Bump version to 0.11.0.dev0 (#4989)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 05:44:29 +00:00
omnigent-ci[bot] 71a3437168 docs(changelog): record v0.10.0 (#4991)
* docs(changelog): record v0.10.0

* docs(changelog): fix truncated and malformed entries in v0.10.0

Complete 18 truncated entries, add proper [Bug fix / Test/CI] tags to
#4508 and #4509 (which had bare `*` bullets), and drop the internal-only
[Docs] N/A entry (#4925).

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-19 04:33:43 +00:00
Daniel Lok 86726e0ab5 fix(web): keep the Working shimmer lit while background tasks run (#4906)
* fix(web): keep the Working shimmer lit while background tasks run

The background-tasks pill (#4893) introduced a shared `isBackgroundTasksOnly`
predicate that gated all three busy surfaces off `bgCount > 0` alone, without
checking whether the agent's turn was still active. So any live turn that
coincided with a background task — notably `waiting`, where the parent is
parked on its async-work drain of sub-agents / background shells — had its
"Working…" shimmer suppressed and replaced by the pill, misreading an active
turn as finished.

Make the shimmer and the pill independent surfaces:

- `isBackgroundTasksOnly` now also requires the turn to be inactive
  (`!agentWorking`), so the shimmer yields only once the turn has genuinely
  ended (`idle`) with tasks lingering.
- `BackgroundTaskPill` shows on `bgCount > 0` alone, decoupled from the shimmer,
  so both appear together while the turn is active.
- `workingIndicatorLabel` no longer emits the background count (the pill owns
  it); the shimmer just rotates its working messages or shows "Blocked on: …".

Co-authored-by: Isaac

* fix(web): keep the background-task pill lit while the turn works

The pill vanished the moment the "Working…" shimmer appeared, so the two
surfaces were still effectively mutually exclusive. The cause was the
background-shell tally being zeroed on every new turn: the server's
_publish_status popped the cache on a `running` edge, the client's
session_status reducer zeroed it on `running`, and the optimistic send path
cleared it synchronously. All three date from the single-surface design, where
the count was a LABEL on the shimmer ("N background tasks still running") that a
new turn should replace with "Working…".

Now that the pill is a separate surface, background shells outlive turn
boundaries and the tally must persist across the turn so the pill stays lit
beside the shimmer. Stop clearing on `running` in all three places; keep
clearing only on an authoritative Stop-hook `0` (shell finished) and on
`failed` (a dead session may never post another count). The next Stop hook
re-reports the count authoritatively.

Also note the server normalizes a claude-native turn-end `waiting`+count to
`idle` (see _background_task_delivery_status), so the client's real
"working + shell" state is `running` with a preserved count — reflected in the
reworked e2e coverage.

Co-authored-by: Isaac

* fix(web): remove the scroll-pinned Working tab

The pinned "Working…" tab (shown while scrolled up) was designed to merge its
flat bottom edge into the composer, but the background-task pill now sits
between them — so the tab reads as a stray rounded card floating above the
pill. Remove the sticky tab entirely (WorkingStatusPin); the inline shimmer at
the end of the thread is the working cue.

Move the tab's one non-visual job — the sole aria-live region announcing the
working state — onto the inline WorkingIndicator: a stable "Working…" in a
role=status region, with the rotating visible label kept aria-hidden so it
never re-announces. Screen readers still get one announcement per turn.

Co-authored-by: Isaac
2026-08-19 08:18:47 +08:00
80 changed files with 2183 additions and 737 deletions
+55
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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.
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+28 -10
View File
@@ -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,
+6 -1
View File
@@ -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}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
)
+16 -15
View File
@@ -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:
+1 -1
View File
@@ -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 (
+7 -6
View File
@@ -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.
+7 -2
View File
@@ -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",
+4 -2
View File
@@ -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
+37 -1
View File
@@ -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",
+47
View File
@@ -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",
]
+3 -1
View File
@@ -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,
+6 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
+40
View File
@@ -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
+3 -1
View File
@@ -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
+8 -7
View File
@@ -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
+144
View File
@@ -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
View File
@@ -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)
+134 -2
View File
@@ -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 = [
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 == []
+9 -14
View File
@@ -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:
+55
View File
@@ -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:
"""
+22
View File
@@ -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:
+83
View File
@@ -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()}"
)
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
)
Generated
+17 -17
View File
@@ -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 -1
View File
@@ -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`");
});
+12 -38
View File
@@ -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 13 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}$$`,
+11
View File
@@ -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.
+17 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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";
+1 -2
View File
@@ -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[];
+64 -35
View File
@@ -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
View File
@@ -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). */
+14 -4
View File
@@ -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" />,
}));
+5 -78
View File
@@ -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.
+7 -55
View File
@@ -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">
-5
View File
@@ -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: () => {},
};
+1 -25
View File
@@ -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}
+58
View File
@@ -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();
});
});
+48
View File
@@ -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>
);
}
+8 -2
View File
@@ -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();
});
});
+72 -39
View File
@@ -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">
-6
View File
@@ -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 ?? []}
+3 -31
View File
@@ -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} />
) : (
+1 -1
View File
@@ -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
+21 -11
View File
@@ -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);
});
});
+13 -9
View File
@@ -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") {