Compare commits

...

12 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
61 changed files with 1572 additions and 437 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"
+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
@@ -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.
+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
+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),
)
@@ -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,
+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,
)
+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
+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[];
+35
View File
@@ -39,6 +39,7 @@ import {
stripGatedSubagentRoutingChips,
stripPendingElicitations,
subAgentComposerLabel,
unboundSessionResumableInApp,
WORKING_MESSAGES,
workingIndicatorLabel,
} from "./ChatPage";
@@ -1634,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
+111 -18
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,
)}
@@ -2115,7 +2169,12 @@ 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 />
<UserMessageNavConnected
@@ -2128,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
@@ -2554,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>;
@@ -2621,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();
@@ -5908,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