Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc575e518 | |||
| b551f669d6 | |||
| ddc5f6ee60 | |||
| 473941e322 | |||
| 1f575ba8de | |||
| 8f63f3271b | |||
| 9df23f7aeb | |||
| c6d1f7d5e0 | |||
| 05eaa253d1 | |||
| 699809de6e | |||
| 4f05fbd0ac | |||
| 537620909c |
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -133,7 +133,7 @@ from omnigent.native_terminal import (
|
||||
terminal_attach_url as _attach_url,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
|
||||
+25
-13
@@ -2971,11 +2971,13 @@ def _claim_foreground_daemon_record(
|
||||
"""
|
||||
conflict = _live_daemon_conflict(record)
|
||||
if conflict is not None:
|
||||
# server_url is None in local mode; "" makes the hint say --server "".
|
||||
stop_command = _host_stop_command(conflict.server_url or "")
|
||||
raise click.ClickException(
|
||||
"A host daemon is already running for this server "
|
||||
f"(pid={conflict.pid}, target={conflict.target}). "
|
||||
"Run `omnigent host status` to inspect it or "
|
||||
"`omnigent host stop --server ...` to stop it first."
|
||||
f"Run `omnigent host status` to inspect it or `{stop_command}` "
|
||||
"to stop it first."
|
||||
)
|
||||
previous = _find_daemon_record(record.target)
|
||||
if previous is not None and not _pid_alive(previous.pid):
|
||||
@@ -3159,9 +3161,10 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
today. A non-200 answer that carries the Databricks edge signature
|
||||
(302 to the workspace OAuth page, or a DatabricksRealm 401) means
|
||||
the run would otherwise die much later with an opaque "non-JSON
|
||||
response (status=302)" traceback from the session-create call. On a
|
||||
TTY we run the same flow ``omnigent login`` would and continue;
|
||||
headless invocations get the exact command to run instead.
|
||||
response (status=302)" traceback from the session-create call. First,
|
||||
it asks the SDK for a fresh workspace token; only then does a TTY run
|
||||
the same flow ``omnigent login`` would, while headless invocations get
|
||||
the exact command to run instead.
|
||||
|
||||
Non-Databricks postures are deliberately left alone: local accounts
|
||||
servers auto-authenticate downstream (magic-link redeem), and
|
||||
@@ -3181,6 +3184,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
import httpx as _httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.cli_auth import load_databricks_org_id, store_databricks_auth
|
||||
|
||||
try:
|
||||
probe = _httpx.get(
|
||||
@@ -3197,6 +3201,13 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
workspace_host = _databricks_workspace_login_target(server, probe)
|
||||
if workspace_host is None:
|
||||
return
|
||||
org_id = load_databricks_org_id(server)
|
||||
token = _databricks_workspace_token(workspace_host)
|
||||
if token is not None:
|
||||
refreshed_probe = _verify_databricks_server_token(server, token, org_id)
|
||||
if refreshed_probe.status_code == 200:
|
||||
store_databricks_auth(server, workspace_host, org_id=org_id)
|
||||
return
|
||||
login_cmd = f"omnigent login {server}"
|
||||
if non_interactive or not sys.stdin.isatty():
|
||||
raise click.ClickException(
|
||||
@@ -3204,11 +3215,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
f"HTTP {probe.status_code}). Run `{login_cmd}` and retry."
|
||||
)
|
||||
click.echo(f"Not signed in to {server} — running `{login_cmd}` first.")
|
||||
# Recover the ``?o=`` selector from a prior login record so a re-login
|
||||
# still targets the right workspace.
|
||||
from omnigent.cli_auth import load_databricks_org_id
|
||||
|
||||
_databricks_login(server, workspace_host, org_id=load_databricks_org_id(server))
|
||||
_databricks_login(server, workspace_host, org_id=org_id)
|
||||
|
||||
|
||||
def _ensure_backend(server: str | None) -> str:
|
||||
@@ -5735,6 +5742,7 @@ def import_session_command(
|
||||
import httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.conversation_browser import conversation_url
|
||||
from omnigent.session_import import (
|
||||
ImportSource,
|
||||
SessionImportNotFoundError,
|
||||
@@ -5840,13 +5848,17 @@ def import_session_command(
|
||||
)
|
||||
continue
|
||||
imported_count += 1
|
||||
# Surface the browser URL, not the bare id, so the user can open the
|
||||
# imported session straight into the web (where it offers the resume
|
||||
# picker). Maps a Databricks API base to its workspace SPA link.
|
||||
session_link = conversation_url(base_url, session_id)
|
||||
if is_batch:
|
||||
click.echo(
|
||||
f"Imported {item_count} item(s) from {current_source_session_id} "
|
||||
f"into {session_id}."
|
||||
f"into {session_link}"
|
||||
)
|
||||
else:
|
||||
click.echo(f"Imported {item_count} item(s) into {session_id}.")
|
||||
click.echo(f"Imported {item_count} item(s) into {session_link}")
|
||||
|
||||
if is_batch:
|
||||
click.echo(f"\nImported: {imported_count}")
|
||||
@@ -10960,7 +10972,7 @@ def _databricks_workspace_token(workspace_host: str) -> str | None:
|
||||
try:
|
||||
auth, _host = _resolve_databricks_auth(host=workspace_host)
|
||||
return auth.current_token()
|
||||
except (DatabricksAuthError, ValueError):
|
||||
except (DatabricksAuthError, ImportError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+23
-21
@@ -328,6 +328,9 @@ def _connection_refused(exc: BaseException) -> bool:
|
||||
_RECONNECT_BASE_S = 0.5
|
||||
_RECONNECT_CAP_S = 10.0
|
||||
_RECONNECT_JITTER = 0.5
|
||||
# Fresh hosts get a short auth-retry window for Databricks OAuth refreshes.
|
||||
# Established hosts retry auth failures indefinitely to preserve sessions.
|
||||
_MAX_CONSECUTIVE_AUTH_ERRORS = 3
|
||||
# Consecutive connection-refused failures against a loopback server before the
|
||||
# host exits (~5 minutes at the backoff cap). Refused on loopback means no
|
||||
# process listens on the port — the local server is gone, not unreachable.
|
||||
@@ -834,9 +837,7 @@ class HostProcess:
|
||||
self._capabilities_initialized = False
|
||||
# Consecutive login-page redirects; reset by a successful upgrade.
|
||||
self._login_redirect_streak = 0
|
||||
# Consecutive 401/403 upgrade rejections on an already-connected host;
|
||||
# reset by a successful upgrade. Gates the once-per-episode terminal
|
||||
# notice so a VPN outage doesn't spam stderr on every retry.
|
||||
# Reset by a successful upgrade or non-auth error; bounds fresh-host refresh retries.
|
||||
self._auth_retry_streak = 0
|
||||
# Consecutive connection-refused connect failures; reset by an accepted
|
||||
# upgrade or any non-refused error. Fatal past a bounded streak only
|
||||
@@ -1235,21 +1236,16 @@ class HostProcess:
|
||||
"""
|
||||
if status in _RETRYABLE_UPGRADE_STATUSES or not (400 <= status < 500):
|
||||
return None
|
||||
if status in (401, 403) and self._ever_connected:
|
||||
# A host that already completed an upgrade proved its credentials
|
||||
# and authorization are valid, so a later 401/403 is almost always
|
||||
# a network-path artifact — a dropped VPN whose corporate proxy
|
||||
# answers the upgrade with 401/403 before it reaches the server.
|
||||
# Retry forever (mirrors the login-redirect path) so a live host
|
||||
# with running sessions survives the outage and resumes when the
|
||||
# path recovers, instead of exiting and forcing a manual restart.
|
||||
if status in (401, 403):
|
||||
# Fresh hosts can race OAuth refresh; connected hosts preserve active sessions.
|
||||
self._auth_retry_streak += 1
|
||||
cause = (
|
||||
f"Connection refused (HTTP {status}): the host tunnel was "
|
||||
"rejected after it had already connected."
|
||||
cause = f"Connection refused (HTTP {status}): the host tunnel was rejected."
|
||||
should_retry = self._ever_connected or (
|
||||
self._auth_retry_streak < _MAX_CONSECUTIVE_AUTH_ERRORS
|
||||
)
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if self._auth_retry_streak == 1:
|
||||
if should_retry:
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if should_retry and self._auth_retry_streak == 1:
|
||||
# The warning above lands only in the CLI log file; print once
|
||||
# per outage so a foreground `omnigent host` isn't silent.
|
||||
print(
|
||||
@@ -1259,19 +1255,20 @@ class HostProcess:
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
if should_retry:
|
||||
return None
|
||||
if status == 401:
|
||||
return HostConnectError(
|
||||
"Authentication failed (HTTP 401): the server rejected the "
|
||||
"supplied credentials. "
|
||||
"Authentication failed (HTTP 401): the server rejected the supplied "
|
||||
f"credentials across {_MAX_CONSECUTIVE_AUTH_ERRORS} consecutive attempts. "
|
||||
+ self._credentials_fix_hint()
|
||||
+ " "
|
||||
+ self._login_fix_hint()
|
||||
)
|
||||
if status == 403:
|
||||
return HostConnectError(
|
||||
"Connection refused (HTTP 403): the credentials authenticated, "
|
||||
"but the server did not accept the host tunnel. Either your "
|
||||
"Connection refused (HTTP 403): the server repeatedly rejected the host "
|
||||
"tunnel. Either your "
|
||||
"identity is not authorized to register a host on this server, "
|
||||
"or the server is running a build that predates the host API "
|
||||
"(the /v1/hosts tunnel route). Confirm you have access and that "
|
||||
@@ -2668,6 +2665,11 @@ class HostProcess:
|
||||
# riding out a messy restart isn't killed by
|
||||
# redirects accumulated across unrelated errors.
|
||||
self._login_redirect_streak = 0
|
||||
if not (
|
||||
isinstance(exc, InvalidStatus) and exc.response.status_code in (401, 403)
|
||||
):
|
||||
# Keep the refresh window limited to consecutive auth rejections.
|
||||
self._auth_retry_streak = 0
|
||||
# Refused on loopback is decisive: nothing listens on the
|
||||
# port and no network path can heal it, so bound the
|
||||
# retries. Remote refusals retry forever (outages recover).
|
||||
|
||||
@@ -332,9 +332,11 @@ class AcpExecutor(Executor):
|
||||
self._image_supported: bool = False
|
||||
self._system_prompt_sent: bool = False
|
||||
|
||||
# ACP toolCallId → tool name, so a later tool_call_update can close the
|
||||
# right tool card with the name from the originating tool_call.
|
||||
# ACP toolCallId → tool name / rawInput from the originating tool_call, so
|
||||
# a later tool_call_update can close the right tool card, and a permission
|
||||
# request that names only the id can still say what is about to run.
|
||||
self._tool_names: dict[str, str] = {}
|
||||
self._tool_inputs: dict[str, _AcpJsonObject] = {}
|
||||
|
||||
# Context-window size (tokens) reported via ``usage_update``; surfaced by
|
||||
# :meth:`max_context_tokens` so the UI context meter fills.
|
||||
@@ -798,21 +800,35 @@ class AcpExecutor(Executor):
|
||||
# Permission (session/request_permission) → policy + elicitation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_tool_call(params: _AcpJsonObject) -> tuple[str, _AcpJsonObject]:
|
||||
def _extract_tool_call(self, params: _AcpJsonObject) -> tuple[str, _AcpJsonObject]:
|
||||
"""Pull ``(tool_name, tool_input)`` from a ``session/request_permission``.
|
||||
|
||||
ACP's ``toolCall`` carries a human ``title`` (e.g. ``"shell"``), a
|
||||
``kind`` (e.g. ``"execute"``), and a ``rawInput`` dict. We prefer the
|
||||
title, else the kind. (Vendor-specific ``_meta`` tool names — e.g.
|
||||
Goose's ``_meta.goose.toolCall.toolName`` — are not read here; ``title``
|
||||
is the portable name every ACP agent supplies.)
|
||||
``kind`` (e.g. ``"execute"``), and a ``rawInput`` dict; prefer the title,
|
||||
else the kind. An agent may instead send the request bare, carrying only
|
||||
``toolCallId``, so fall back to what the originating ``tool_call`` update
|
||||
reported for that id — it always arrives first. Without that fallback a
|
||||
bare request degrades to ``"tool"`` with no arguments, so the approval
|
||||
card cannot say what is about to run and no TOOL_CALL policy rule can
|
||||
match it (rules gate on the tool name, then read its arguments).
|
||||
|
||||
(Vendor-specific ``_meta`` tool names — e.g. Goose's
|
||||
``_meta.goose.toolCall.toolName`` — are not read here; ``title`` is the
|
||||
portable name and ``toolCallId`` the portable correlation.)
|
||||
"""
|
||||
tool_call = params.get("toolCall") or {}
|
||||
name = tool_call.get("title") or tool_call.get("kind") or "tool"
|
||||
call_id = tool_call.get("toolCallId")
|
||||
call_id = call_id if isinstance(call_id, str) else None
|
||||
name = (
|
||||
tool_call.get("title")
|
||||
or tool_call.get("kind")
|
||||
or (self._tool_names.get(call_id) if call_id else None)
|
||||
or "tool"
|
||||
)
|
||||
args = tool_call.get("rawInput")
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
cached = self._tool_inputs.get(call_id) if call_id else None
|
||||
args = cached if isinstance(cached, dict) else {}
|
||||
return str(name), args
|
||||
|
||||
async def _decide_permission(self, params: _AcpJsonObject) -> bool:
|
||||
@@ -1069,6 +1085,7 @@ class AcpExecutor(Executor):
|
||||
args = raw_input if isinstance(raw_input, dict) else {}
|
||||
if isinstance(call_id, str) and call_id:
|
||||
self._tool_names[call_id] = str(name)
|
||||
self._tool_inputs[call_id] = args
|
||||
events.append(
|
||||
ToolCallRequest(name=str(name), args=args, metadata={"call_id": call_id})
|
||||
)
|
||||
@@ -1080,6 +1097,7 @@ class AcpExecutor(Executor):
|
||||
_TOOL_STATUS_FAILED,
|
||||
):
|
||||
name = self._tool_names.pop(call_id, "tool")
|
||||
self._tool_inputs.pop(call_id, None)
|
||||
events.append(
|
||||
ToolCallComplete(
|
||||
name=name,
|
||||
|
||||
@@ -131,7 +131,12 @@ def _normalize_responses_items_for_chat(
|
||||
for item in items:
|
||||
if item.get("type") == "message":
|
||||
raw_content = item.get("content")
|
||||
if isinstance(raw_content, list):
|
||||
if item.get("role") == "assistant" and isinstance(raw_content, str):
|
||||
# The chat converter iterates assistant content expecting
|
||||
# blocks, so a plain string is walked character by character and
|
||||
# each one indexed. User strings take a different branch there.
|
||||
item = {**item, "content": [{"type": "output_text", "text": raw_content}]}
|
||||
elif isinstance(raw_content, list):
|
||||
normalized_content = _normalize_content_blocks_for_chat(raw_content)
|
||||
if normalized_content is not raw_content:
|
||||
item = {**item, "content": normalized_content}
|
||||
|
||||
@@ -712,7 +712,7 @@ class SandboxExecTransport(SandboxLifecycle):
|
||||
return self.run(
|
||||
sandbox_id,
|
||||
f"setsid nohup sh -c {shlex.quote(supervise_host_command(command))} "
|
||||
f"> {log_path} 2>&1 < /dev/null & echo launched",
|
||||
f">> {log_path} 2>&1 < /dev/null & echo launched",
|
||||
)
|
||||
|
||||
def put(self, sandbox_id: str, local_path: Path, remote_path: str) -> None:
|
||||
|
||||
@@ -1704,6 +1704,9 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
|
||||
_request_timeout=_POD_READY_REQUEST_TIMEOUT_S,
|
||||
),
|
||||
),
|
||||
# TODO(v0.29): remove this entry once all runners have rolled
|
||||
# past v0.28 — bare Pods are no longer created. Keep in sync
|
||||
# with the pods:create/delete TODO in role.yaml.
|
||||
# Fall back to deleting a bare Pod left by the pre-Job
|
||||
# launcher. Child Pods are named <job>-<rand5> so this only
|
||||
# targets pre-migration bare Pods whose name IS sandbox_id.
|
||||
|
||||
@@ -429,7 +429,7 @@ class OpenShellSandboxLauncher(SandboxLauncher):
|
||||
supervisor along with the host, which no in-sandbox loop can survive.
|
||||
"""
|
||||
script = supervise_host_command(command)
|
||||
bg_command = f"{script} > {log_path} 2>&1 < /dev/null"
|
||||
bg_command = f"{script} >> {log_path} 2>&1 < /dev/null"
|
||||
self._openshell().exec_background(
|
||||
sandbox_id, ["bash", "-lc", bg_command], timeout=_FOREGROUND_TIMEOUT_S
|
||||
)
|
||||
|
||||
@@ -12,11 +12,12 @@ from omnigent.onboarding.databricks_config import normalize_workspace_url
|
||||
from omnigent.onboarding.ucode_state import read_ucode_state
|
||||
|
||||
_UCODE_AGENT_NAMES: tuple[str, ...] = ("claude", "codex", "pi")
|
||||
# Pin to the ``main`` branch (not a SHA) so setup always tracks the latest
|
||||
# ucode. A branch ref is mutable, so uvx would otherwise serve a stale cached
|
||||
# build of an older ``main`` commit; ``--refresh-package ucode`` defeats that
|
||||
# cache and forces re-resolution of the branch HEAD on every run.
|
||||
_UCODE_GIT_REF = "main"
|
||||
# Pin ucode to a fixed commit so setup is reproducible, rather than tracking
|
||||
# ucode's ``main`` HEAD (a mutable ref that can move under us between runs and
|
||||
# break setup unexpectedly). A full SHA is immutable, so uvx caches the built
|
||||
# wheel by ref and reuses it across runs — no ``--refresh-package`` needed to
|
||||
# defeat a mutable branch's stale cache.
|
||||
_UCODE_GIT_REF = "94271a78c7139220b7333bcae91e522f95ef3af3"
|
||||
_UCODE_UVX_SOURCE = f"git+https://github.com/databricks/ucode@{_UCODE_GIT_REF}"
|
||||
|
||||
|
||||
@@ -50,8 +51,8 @@ def build_ucode_configure_command(
|
||||
"""Build the ``ucode configure`` command Omnigent runs.
|
||||
|
||||
:param ucode_command: Command prefix that invokes ucode, e.g.
|
||||
``("/usr/bin/ucode",)`` or ``("uvx", "--refresh-package", "ucode",
|
||||
"--from", "git+https://github.com/databricks/ucode@main", "ucode")``.
|
||||
``("/usr/bin/ucode",)`` or ``("uvx", "--from",
|
||||
"git+https://github.com/databricks/ucode@<sha>", "ucode")``.
|
||||
:param workspace_urls: Workspace URLs to configure. Must be non-empty.
|
||||
:param agents: ucode agent names to configure non-interactively,
|
||||
e.g. ``("claude", "codex", "pi")``.
|
||||
@@ -126,22 +127,22 @@ def ucode_workspace_exists(workspace_url: str) -> bool:
|
||||
def find_ucode_command() -> list[str]:
|
||||
"""Return a command prefix that invokes ``ucode``.
|
||||
|
||||
Prefers an ephemeral ``uvx`` run pinned to ucode's ``main`` branch so
|
||||
setup always uses the latest ucode rather than whatever (possibly stale)
|
||||
``ucode`` the user installed long ago. A locally-installed binary is used
|
||||
only as a last resort when ``uvx`` is unavailable. This ordering matters:
|
||||
an old persistently-installed ``ucode`` predates options like
|
||||
Prefers an ephemeral ``uvx`` run pinned to a fixed ucode commit, so setup
|
||||
uses a known-good ucode rather than whatever (possibly stale) ``ucode`` the
|
||||
user installed long ago. A locally-installed binary is used only as a last
|
||||
resort when ``uvx`` is unavailable. This ordering matters: an old
|
||||
persistently-installed ``ucode`` predates options like
|
||||
``configure --workspaces`` and would otherwise win and break setup.
|
||||
|
||||
:returns: Command prefix, e.g.
|
||||
``["uvx", "--refresh-package", "ucode", "--from",
|
||||
"git+https://github.com/databricks/ucode@main", "ucode"]`` or, when
|
||||
``["uvx", "--from",
|
||||
"git+https://github.com/databricks/ucode@<sha>", "ucode"]`` or, when
|
||||
``uvx`` is absent, ``["/usr/bin/ucode"]``.
|
||||
:raises click.ClickException: If neither ``uvx`` nor ``ucode`` is on PATH.
|
||||
"""
|
||||
uvx = shutil.which("uvx")
|
||||
if uvx is not None:
|
||||
return [uvx, "--refresh-package", "ucode", "--from", _UCODE_UVX_SOURCE, "ucode"]
|
||||
return [uvx, "--from", _UCODE_UVX_SOURCE, "ucode"]
|
||||
|
||||
ucode = shutil.which("ucode")
|
||||
if ucode is None:
|
||||
|
||||
@@ -158,9 +158,9 @@ from omnigent.server.schemas import (
|
||||
)
|
||||
from omnigent.spec.skill_sources import SkillSourceContext, resolve_harness_skills
|
||||
from omnigent.spec.types import AgentSpec, LocalToolInfo, SkillSpec
|
||||
from omnigent.terminals.close_codes import WS_CLOSE_TERMINAL_NOT_FOUND
|
||||
from omnigent.terminals.control_bridge import bridge_tmux_control_to_websocket
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
bridge_tmux_pty_to_websocket,
|
||||
)
|
||||
from omnigent.tools.builtins.load_skill import (
|
||||
|
||||
@@ -1785,12 +1785,13 @@ def create_app(
|
||||
host_version = host_versions.get(conn.host_id)
|
||||
if conn.runner_id is None:
|
||||
# No runner binding: an in-process executor (or a session
|
||||
# not yet dispatched) is reachable — EXCEPT an unbound fork
|
||||
# of a session that had a working directory, which must
|
||||
# rebind a host + directory first. Reporting it offline
|
||||
# routes the first message into the directory picker instead
|
||||
# of dropping it against a runner that can't start.
|
||||
runner_online = not conn.needs_workspace
|
||||
# not yet dispatched) is reachable — EXCEPT sessions that have
|
||||
# no executor to reach and must launch a runner on a host
|
||||
# first: an unbound fork (needs a workspace) or an imported
|
||||
# transcript (no live executor anywhere). Reporting those
|
||||
# offline routes the first message into the resume picker
|
||||
# instead of dropping it against a runner that can't start.
|
||||
runner_online = not (conn.needs_workspace or conn.imported)
|
||||
else:
|
||||
# Strict: reachable only if the runner tunnel is up. No
|
||||
# host-relaunch optimism — host state lives in host_online.
|
||||
|
||||
@@ -89,11 +89,13 @@ from omnigent.server.auth import LEVEL_OWNER, LEVEL_READ, AuthProvider
|
||||
from omnigent.server.routes._auth_helpers import require_access
|
||||
from omnigent.stores import ConversationStore
|
||||
from omnigent.stores.permission_store import PermissionStore
|
||||
from omnigent.terminals.control_bridge import bridge_tmux_control_to_websocket
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_INTERNAL_ERROR,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
WS_CLOSE_WRONG_REPLICA,
|
||||
)
|
||||
from omnigent.terminals.control_bridge import bridge_tmux_control_to_websocket
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
bridge_tmux_pty_to_websocket,
|
||||
)
|
||||
|
||||
|
||||
@@ -210,6 +210,12 @@ class SessionConnectivity:
|
||||
dot off while ``runner_id``/``host_id`` are still ``None`` so
|
||||
the UI prompts for a host + directory before the clone can run,
|
||||
rather than treating it as an in-process session.
|
||||
:param imported: ``True`` when this session was imported from a local
|
||||
harness transcript (the ``omnigent.import.source`` label is set).
|
||||
Like ``needs_workspace``, forces the online dot off while unbound:
|
||||
an imported transcript has no live executor anywhere, so it must
|
||||
launch a runner on a host before it can run — reporting it offline
|
||||
routes the first message into the resume picker.
|
||||
:param runner_last_seen: Epoch seconds the bound runner's tunnel was
|
||||
last observed alive, written by the replica holding the tunnel.
|
||||
``None`` when never observed (or cleared on graceful disconnect).
|
||||
@@ -221,6 +227,7 @@ class SessionConnectivity:
|
||||
runner_id: str | None
|
||||
host_id: str | None
|
||||
needs_workspace: bool
|
||||
imported: bool = False
|
||||
runner_last_seen: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1136,10 +1136,10 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
SqlConversationMetadata.id.in_(unique_ids),
|
||||
)
|
||||
).all()
|
||||
# Fork-source label is in the AP DB.
|
||||
# Connectivity markers are in the AP DB. Both signal on presence:
|
||||
# the fork-source label forces an unbound clone to pick a workspace;
|
||||
# the import-source label marks a transcript with no live executor.
|
||||
with self._conv_session("get_session_connectivity") as ap_sess:
|
||||
# One pass over the fork-source connectivity marker, which
|
||||
# signals on presence (its value is the source id).
|
||||
label_rows = ap_sess.execute(
|
||||
select(
|
||||
SqlConversationLabel.conversation_id,
|
||||
@@ -1148,17 +1148,21 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
).where(
|
||||
SqlConversationLabel.workspace_id == current_workspace_id(),
|
||||
SqlConversationLabel.conversation_id.in_(unique_ids),
|
||||
SqlConversationLabel.key.in_([FORK_SOURCE_LABEL_KEY]),
|
||||
SqlConversationLabel.key.in_([FORK_SOURCE_LABEL_KEY, IMPORT_SOURCE_LABEL_KEY]),
|
||||
)
|
||||
).all()
|
||||
needs_workspace_ids = {
|
||||
row.conversation_id for row in label_rows if row.key == FORK_SOURCE_LABEL_KEY
|
||||
}
|
||||
imported_ids = {
|
||||
row.conversation_id for row in label_rows if row.key == IMPORT_SOURCE_LABEL_KEY
|
||||
}
|
||||
return {
|
||||
row.id: SessionConnectivity(
|
||||
runner_id=row.runner_id,
|
||||
host_id=row.host_id,
|
||||
needs_workspace=row.id in needs_workspace_ids,
|
||||
imported=row.id in imported_ids,
|
||||
runner_last_seen=row.runner_last_seen,
|
||||
)
|
||||
for row in meta_rows
|
||||
|
||||
@@ -9,7 +9,43 @@ See ``designs/OMNIGENT_TERMINAL_BRIDGE.md`` for the design and the
|
||||
:mod:`omnigent.inner.terminal` for the underlying tmux machinery.
|
||||
"""
|
||||
|
||||
from omnigent.terminals.registry import TerminalListEntry, TerminalRegistry
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from omnigent.terminals.registry import TerminalListEntry, TerminalRegistry
|
||||
|
||||
# Resolved on first access (PEP 562) so importing a leaf module such as
|
||||
# ``omnigent.terminals.close_codes`` does not build the tmux registry —
|
||||
# ``registry`` reaches into ``omnigent.inner.terminal``, ~100ms of import
|
||||
# a CLI client reading a WebSocket close code has no use for.
|
||||
_REGISTRY_EXPORTS: frozenset[str] = frozenset({"TerminalListEntry", "TerminalRegistry"})
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
"""
|
||||
Import :mod:`omnigent.terminals.registry` on first attribute access.
|
||||
|
||||
:param name: A public attribute, e.g. ``"TerminalRegistry"``.
|
||||
:returns: The requested object.
|
||||
:raises AttributeError: If *name* is not exported.
|
||||
"""
|
||||
if name not in _REGISTRY_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
from omnigent.terminals import registry
|
||||
|
||||
value = getattr(registry, name)
|
||||
globals()[name] = value # cache so later reads skip __getattr__
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""
|
||||
List the package's public names without importing the registry.
|
||||
|
||||
:returns: Sorted :data:`__all__`.
|
||||
"""
|
||||
return sorted(__all__)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TerminalListEntry",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Application-level WebSocket close codes for the terminal bridges.
|
||||
|
||||
A leaf module on purpose: these codes are the wire contract between the
|
||||
server's ``/attach`` route, the runner, the native CLI client, and the
|
||||
browser, so all four need them without importing the bridge that serves
|
||||
the socket. :mod:`omnigent.terminals.ws_bridge` pulls in FastAPI and the
|
||||
tmux machinery, which a CLI client reading a close code has no use for.
|
||||
|
||||
Keep this module dependency-free. ``web/src/components/blocks/
|
||||
TerminalSession.ts`` mirrors these values on the browser side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
# RFC 6455 reserves the 4xxx band for application use. The 44xx band
|
||||
# mirrors HTTP 4xx, as 4500 mirrors 5xx.
|
||||
|
||||
# 4404 tells the client's reconnect loop to stop — sent on a
|
||||
# pre-attach lookup miss and on PTY EOF when the tmux session is
|
||||
# genuinely gone (Claude exited / the session was killed).
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND: Final[int] = 4404
|
||||
|
||||
# 4405 means the user *detached* from tmux: the ``tmux attach`` child
|
||||
# exited (PTY EOF) but the session is still alive. The client must NOT
|
||||
# treat this as a terminal-gone exit: a detach misread as 4404 would
|
||||
# tear the whole session (and runner) down.
|
||||
WS_CLOSE_TERMINAL_DETACHED: Final[int] = 4405
|
||||
|
||||
# 4400 is the WS analogue of the HTTP 400 ``wrong_replica``: the runner
|
||||
# tunnel is bound but not on this replica (the ``?omnigent_slice_key=``
|
||||
# reached a replica that doesn't hold the tunnel — the key doesn't match
|
||||
# where it lives). Unlike 4500 (a genuine failure), the request is valid
|
||||
# and just misrouted: the client re-dials keyless and reaches the replica
|
||||
# the tunnel actually lives on. Mirrors the fetch path's keyless
|
||||
# re-address on a ``wrong_replica`` 400.
|
||||
WS_CLOSE_WRONG_REPLICA: Final[int] = 4400
|
||||
|
||||
WS_CLOSE_INTERNAL_ERROR: Final[int] = 4500
|
||||
|
||||
__all__ = [
|
||||
"WS_CLOSE_INTERNAL_ERROR",
|
||||
"WS_CLOSE_TERMINAL_DETACHED",
|
||||
"WS_CLOSE_TERMINAL_NOT_FOUND",
|
||||
"WS_CLOSE_WRONG_REPLICA",
|
||||
]
|
||||
@@ -68,10 +68,12 @@ from fastapi import WebSocket, WebSocketDisconnect
|
||||
# backlog forms, and the forwarder collapses thousands of tiny per-line frames
|
||||
# into a few large ones. ``_coalesce_limit_after_input`` keeps the frame right
|
||||
# after a keystroke small so the echo stays on xterm's synchronous paint path.
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_INTERNAL_ERROR,
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
_coalesce_limit_after_input,
|
||||
_forward_pty_to_ws,
|
||||
_monotonic,
|
||||
|
||||
@@ -54,6 +54,12 @@ if sys.platform != "win32":
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_INTERNAL_ERROR,
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# 4 KiB matches tmux's own copy/redraw paths and is a good fit for
|
||||
@@ -75,26 +81,6 @@ _PANE_LIVENESS_CHECK_CACHE_S: Final[float] = 0.1 # 100ms cache to avoid per-key
|
||||
_TMUX_ATTACH_WAIT_GRACE_S: Final[float] = 0.5
|
||||
_TMUX_ATTACH_WAIT_POLL_S: Final[float] = 0.02
|
||||
|
||||
# Application-level WebSocket close codes (RFC 6455 reserves 4xxx).
|
||||
# 4404 tells the client's reconnect loop to stop — sent on a
|
||||
# pre-attach lookup miss and on PTY EOF when the tmux session is
|
||||
# genuinely gone (Claude exited / the session was killed).
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND: Final[int] = 4404
|
||||
# 4405 means the user *detached* from tmux: the ``tmux attach`` child
|
||||
# exited (PTY EOF) but the session is still alive. The client must NOT
|
||||
# treat this as a terminal-gone exit: a detach misread as 4404 would
|
||||
# tear the whole session (and runner) down.
|
||||
WS_CLOSE_TERMINAL_DETACHED: Final[int] = 4405
|
||||
# 4400 is the WS analogue of the HTTP 400 ``wrong_replica`` (the 44xx band
|
||||
# mirrors HTTP 4xx, as 4500 mirrors 5xx): the runner tunnel is bound but not on
|
||||
# this replica (the ``?omnigent_slice_key=`` reached a replica that doesn't hold
|
||||
# the tunnel — the key doesn't match where it lives). Unlike 4500 (a genuine
|
||||
# failure), the request is valid and just misrouted: the client re-dials keyless
|
||||
# and reaches the replica the tunnel actually lives on. Mirrors the fetch path's
|
||||
# keyless re-address on a ``wrong_replica`` 400.
|
||||
WS_CLOSE_WRONG_REPLICA: Final[int] = 4400
|
||||
WS_CLOSE_INTERNAL_ERROR: Final[int] = 4500
|
||||
|
||||
# A ``tmux has-session`` liveness probe is local and near-instant; cap
|
||||
# it so a wedged tmux server can't stall the bridge's teardown.
|
||||
_TMUX_HAS_SESSION_TIMEOUT_S: Final[float] = 2.0
|
||||
|
||||
@@ -1826,6 +1826,46 @@ def test_ensure_backend_databricks_preflight_skips_when_authenticated(
|
||||
assert result == "https://myapp-1234.aws.databricksapps.com"
|
||||
|
||||
|
||||
def test_databricks_preflight_silent_sdk_refresh_skips_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A fresh SDK token recovers an expired Databricks Apps session silently."""
|
||||
import httpx
|
||||
|
||||
responses = iter([_databricks_probe_response(302), _databricks_probe_response(200)])
|
||||
requests: list[dict[str, object]] = []
|
||||
stored: list[tuple[str, str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.chat._remote_headers",
|
||||
lambda server_url=None, *, host_id=None: {"Authorization": "Bearer expired"},
|
||||
)
|
||||
|
||||
def _get(url: str, **kwargs: object) -> object:
|
||||
requests.append(kwargs)
|
||||
return next(responses)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
monkeypatch.setattr(cli, "_databricks_workspace_token", lambda workspace: "fresh-token")
|
||||
monkeypatch.setattr(cli, "_databricks_login", lambda *args, **kwargs: pytest.fail("login"))
|
||||
monkeypatch.setattr("omnigent.cli_auth.load_databricks_org_id", lambda server: "123")
|
||||
|
||||
def _store(
|
||||
server: str,
|
||||
workspace: str,
|
||||
user_id: str | None = None,
|
||||
org_id: str | None = None,
|
||||
) -> None:
|
||||
stored.append((server, workspace, org_id))
|
||||
|
||||
monkeypatch.setattr("omnigent.cli_auth.store_databricks_auth", _store)
|
||||
|
||||
cli._ensure_databricks_server_auth(_HOST_DATABRICKS_SERVER, non_interactive=True)
|
||||
|
||||
assert requests[1]["headers"] == {"Authorization": "Bearer fresh-token"}
|
||||
assert requests[1]["params"] == {"o": "123"}
|
||||
assert stored == [(_HOST_DATABRICKS_SERVER, "https://example.databricks.com", "123")]
|
||||
|
||||
|
||||
# ── Foreground ``host`` auth pre-flight ─────────────────────────────
|
||||
#
|
||||
# ``host`` runs the same Databricks sign-in pre-flight ``run`` does before
|
||||
|
||||
@@ -66,7 +66,9 @@ def test_import_command_loads_local_session_and_posts_normalized_items(tmp_path:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "import" in _CLICK_SUBCOMMANDS
|
||||
assert "conv_imported" in result.output
|
||||
# The output surfaces the session's browser URL, not the bare id, so the
|
||||
# user can open the import straight into the web (resume picker).
|
||||
assert f"{_BASE}/c/conv_imported" in result.output
|
||||
request = route.calls.last.request
|
||||
payload = json.loads(request.content)
|
||||
assert payload == {
|
||||
|
||||
@@ -185,3 +185,16 @@ def test_build_routing_defaults_without_a_routing_block() -> None:
|
||||
client, settings = _build_routing({}, None)
|
||||
assert client is None
|
||||
assert settings == RoutingSettings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cfg", "expected_timeout"),
|
||||
[
|
||||
({"execution_timeout": 86_400}, 86_400),
|
||||
({}, 7_200),
|
||||
],
|
||||
)
|
||||
def test_resolve_execution_timeout(cfg: dict[str, int], expected_timeout: int) -> None:
|
||||
from deploy.docker.entrypoint import _resolve_execution_timeout
|
||||
|
||||
assert _resolve_execution_timeout(cfg) == expected_timeout
|
||||
|
||||
@@ -302,7 +302,8 @@ def test_cli_imports_claude_chat_into_live_server(live_server: str, tmp_path: Pa
|
||||
env=env,
|
||||
)
|
||||
|
||||
match = re.search(r"Imported \d+ item\(s\) into (\S+)\.", result.stdout)
|
||||
# Output is the session's browser URL (…/c/<id>); pull the id back out.
|
||||
match = re.search(r"Imported \d+ item\(s\) into \S+/c/(\S+)", result.stdout)
|
||||
assert match is not None, result.stdout
|
||||
session_id = match.group(1)
|
||||
session = httpx.get(
|
||||
@@ -374,7 +375,7 @@ def test_cli_imports_recent_claude_chats_as_batch(live_server: str, tmp_path: Pa
|
||||
)
|
||||
|
||||
imported = re.findall(
|
||||
r"Imported \d+ item\(s\) from (\S+) into (\S+)\.",
|
||||
r"Imported \d+ item\(s\) from (\S+) into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert [source_id for source_id, _ in imported] == list(source_session_ids[1:])
|
||||
@@ -464,7 +465,7 @@ def test_cli_imports_recent_codex_chats_as_batch(live_server: str, tmp_path: Pat
|
||||
)
|
||||
|
||||
imported = re.findall(
|
||||
r"Imported \d+ item\(s\) from (\S+) into (\S+)\.",
|
||||
r"Imported \d+ item\(s\) from (\S+) into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert [source_id for source_id, _ in imported] == list(source_session_ids[1:])
|
||||
@@ -516,7 +517,7 @@ def test_cli_force_replaces_imported_chat(
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
first_match = re.search(r"into (\S+)\.", first.stdout)
|
||||
first_match = re.search(r"into \S+/c/(\S+)", first.stdout)
|
||||
assert first_match is not None, first.stdout
|
||||
|
||||
_write_force_import_fixture(tmp_path, harness, "new prompt")
|
||||
@@ -528,7 +529,7 @@ def test_cli_force_replaces_imported_chat(
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
replaced_match = re.search(r"into (\S+)\.", replaced.stdout)
|
||||
replaced_match = re.search(r"into \S+/c/(\S+)", replaced.stdout)
|
||||
assert replaced_match is not None, replaced.stdout
|
||||
assert replaced_match.group(1) == first_match.group(1)
|
||||
|
||||
@@ -586,7 +587,7 @@ def test_cli_imports_jsonl_harness_chat_end_to_end(
|
||||
)
|
||||
|
||||
match = re.search(
|
||||
rf"Imported 2 item\(s\) from {re.escape(source_session_id)} into (\S+)\.",
|
||||
rf"Imported 2 item\(s\) from {re.escape(source_session_id)} into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert match is not None, result.stdout
|
||||
@@ -653,7 +654,7 @@ def test_cli_imports_opencode_export_end_to_end(
|
||||
)
|
||||
|
||||
match = re.search(
|
||||
rf"Imported 4 item\(s\) from {source_session_id} into (\S+)\.",
|
||||
rf"Imported 4 item\(s\) from {source_session_id} into \S+/c/(\S+)",
|
||||
result.stdout,
|
||||
)
|
||||
assert match is not None, result.stdout
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""E2E: the in-chat Plan tracker renders, expands, tracks live updates, clears.
|
||||
|
||||
The task/plan tracker (``ChatPlanAccordion``) is pinned above the conversation
|
||||
and fed by the harness-agnostic ``session.todos`` contract — the claude-native
|
||||
forwarder posts it from ``TodoWrite``, the codex-native forwarder from plan
|
||||
updates. These tests drive that list straight through the Sessions events route
|
||||
(the same path the forwarders post to), so they're deterministic: no live agent
|
||||
turn is needed to produce a plan.
|
||||
|
||||
Mirrors ``chat/test_mcp_startup_indicator.py`` — seed before load to prove the
|
||||
snapshot path, then re-publish the idempotent full-state list until the live SSE
|
||||
subscription catches it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
_TRACKER = '[data-testid="plan-tracker"]'
|
||||
|
||||
_PLAN = [
|
||||
{"content": "Read the code", "status": "completed", "activeForm": "Reading the code"},
|
||||
{"content": "Write the tracker", "status": "in_progress", "activeForm": "Writing the tracker"},
|
||||
{"content": "Ship it", "status": "pending", "activeForm": "Shipping it"},
|
||||
]
|
||||
|
||||
|
||||
def _publish_todos(
|
||||
base_url: str,
|
||||
session_id: str,
|
||||
todos: list[dict[str, str]],
|
||||
) -> None:
|
||||
"""Publish the full todo list through the events route.
|
||||
|
||||
:param base_url: Base URL of the local e2e server.
|
||||
:param session_id: Session/conversation id.
|
||||
:param todos: Full list; each item is ``{"content", "status", "activeForm"}``.
|
||||
An empty list clears the tracker.
|
||||
:returns: None.
|
||||
"""
|
||||
resp = httpx.post(
|
||||
f"{base_url}/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_session_todos", "data": {"todos": todos}},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _publish_until(
|
||||
base_url: str,
|
||||
session_id: str,
|
||||
todos: list[dict[str, str]],
|
||||
expectation: Callable[[], None],
|
||||
) -> None:
|
||||
"""Re-publish the idempotent full list until the tracker reflects it.
|
||||
|
||||
The session stream is snapshot-plus-live-tail with no replay buffer, so a
|
||||
list published in the window between the page's snapshot load and its live
|
||||
SSE subscription is dropped. The list is full-state and idempotent, so
|
||||
re-publishing until the assertion passes closes the connect race without
|
||||
weakening the check.
|
||||
|
||||
:param base_url: Base URL of the local e2e server.
|
||||
:param session_id: Session/conversation id.
|
||||
:param todos: Full list to publish each attempt.
|
||||
:param expectation: Playwright ``expect`` assertion for the state the
|
||||
published list should drive; polled between re-publishes.
|
||||
:returns: None.
|
||||
"""
|
||||
deadline = time.monotonic() + 30.0
|
||||
while True:
|
||||
_publish_todos(base_url, session_id, todos)
|
||||
try:
|
||||
expectation()
|
||||
return
|
||||
except AssertionError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
|
||||
|
||||
def test_plan_tracker_renders_expands_updates_and_clears(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The Plan card seeds from the snapshot, expands on click, tracks a live
|
||||
completion, and disappears when the list is cleared.
|
||||
|
||||
:param page: Playwright page fixture.
|
||||
:param seeded_session: ``(base_url, session_id)`` from the local server.
|
||||
:returns: None.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
tracker = page.locator(_TRACKER)
|
||||
|
||||
# 1. The plan exists BEFORE the page opens: the snapshot cache must seed the
|
||||
# card on load (the refresh / reconnect channel).
|
||||
_publish_todos(base_url, session_id, _PLAN)
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
expect(tracker).to_be_visible(timeout=15_000)
|
||||
expect(tracker).to_contain_text("Plan")
|
||||
# 1 completed of 3 total.
|
||||
expect(tracker).to_contain_text("(1/3)")
|
||||
|
||||
# 2. Collapsed by default: the task list is hidden until the user expands.
|
||||
in_progress = tracker.get_by_text("Write the tracker")
|
||||
expect(in_progress).to_be_hidden()
|
||||
|
||||
# 3. Clicking the summary opens the disclosure and reveals the tasks.
|
||||
tracker.locator("summary").click()
|
||||
expect(in_progress).to_be_visible()
|
||||
expect(tracker).to_contain_text("Read the code")
|
||||
expect(tracker).to_contain_text("Ship it")
|
||||
|
||||
# 4. Live update: the agent completes the in-progress task and the count
|
||||
# advances without a reload. First live-tail-dependent step, so
|
||||
# re-publish the idempotent list until the SSE subscription receives it.
|
||||
completed_plan = [
|
||||
{"content": "Read the code", "status": "completed", "activeForm": "Reading the code"},
|
||||
{
|
||||
"content": "Write the tracker",
|
||||
"status": "completed",
|
||||
"activeForm": "Writing the tracker",
|
||||
},
|
||||
{"content": "Ship it", "status": "pending", "activeForm": "Shipping it"},
|
||||
]
|
||||
_publish_until(
|
||||
base_url,
|
||||
session_id,
|
||||
completed_plan,
|
||||
lambda: expect(tracker).to_contain_text("(2/3)", timeout=3_000),
|
||||
)
|
||||
|
||||
# 5. An empty list clears the tracker entirely — a session with no plan
|
||||
# shows no card.
|
||||
_publish_until(
|
||||
base_url,
|
||||
session_id,
|
||||
[],
|
||||
lambda: expect(tracker).to_have_count(0, timeout=3_000),
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""E2E: resume a real imported / host-less session onto a chosen online host.
|
||||
|
||||
An imported session has no host binding (``host_id`` null) and no runner, so it
|
||||
used to read as a normal reachable session (or, with no executor, a terminal
|
||||
reconnect dead-end). Now the server reports an unbound imported transcript as
|
||||
``runner_online: false`` (it has no live executor anywhere), and the open view
|
||||
routes to the in-app "Resume on a machine" picker — the same bind + launch path
|
||||
(``POST /v1/hosts/{id}/runners``) the fork-resume dialog uses.
|
||||
|
||||
This drives the REAL liveness path: it imports a genuine transcript through the
|
||||
public ``POST /v1/imports`` API and never stubs ``/health`` or the session
|
||||
snapshot. The only stubs are the host-side wire the harness can't provide (it
|
||||
spawns no ``omnigent host`` daemon): the hosts list reports one online host, the
|
||||
filesystem pre-flight lists empty, and the runner launch records its body.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
|
||||
_HOST_ID = "host_imported_e2e"
|
||||
_WS_DIR = "/home/e2e/imported-repo"
|
||||
|
||||
|
||||
@pytest.mark.flaky(reruns=2, reruns_delay=5)
|
||||
def test_imported_session_resumes_onto_chosen_local_host(
|
||||
page: Page,
|
||||
live_server: str,
|
||||
) -> None:
|
||||
"""A real imported session offers the resume picker and launches a runner.
|
||||
|
||||
Failure modes this guards:
|
||||
|
||||
- The server stops reporting an unbound import as ``runner_online: false``
|
||||
(the ``imported`` connectivity marker), so it reads as reachable and the
|
||||
picker never appears.
|
||||
- The client re-applies the cold-boot startup grace to imports, so the
|
||||
picker is masked behind "Connecting…" instead of showing at once.
|
||||
- The offline routing regresses to the terminal reconnect dead-end instead
|
||||
of the picker.
|
||||
- The bind wire shape drifts (wrong session_id / workspace on the launch).
|
||||
|
||||
:param page: Playwright page fixture (fresh context per test).
|
||||
:param live_server: Base URL of the spawned server.
|
||||
"""
|
||||
base_url = live_server
|
||||
|
||||
runner_bodies: list[dict[str, Any]] = []
|
||||
|
||||
def handle_hosts(route: Route) -> None:
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"hosts": [
|
||||
{
|
||||
"host_id": _HOST_ID,
|
||||
"name": "e2e-laptop",
|
||||
"owner": "e2e",
|
||||
"status": "online",
|
||||
"sandbox_provider": None,
|
||||
"configured_harnesses": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def handle_filesystem(route: Route) -> None:
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"object": "list", "data": [], "has_more": False}),
|
||||
)
|
||||
|
||||
def handle_runners(route: Route) -> None:
|
||||
runner_bodies.append(route.request.post_data_json)
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"runner_id": "runner_imported_e2e", "status": "launching"}),
|
||||
)
|
||||
|
||||
page.route("**/v1/hosts", handle_hosts)
|
||||
page.route(f"**/v1/hosts/{_HOST_ID}/filesystem/**", handle_filesystem)
|
||||
page.route(f"**/v1/hosts/{_HOST_ID}/runners", handle_runners)
|
||||
|
||||
# Import a genuine transcript through the public API. The route stamps the
|
||||
# ``omnigent.import.source`` label and leaves the session host-less — the
|
||||
# exact state a real ``omnigent import`` produces.
|
||||
resp = httpx.post(
|
||||
f"{base_url}/v1/imports",
|
||||
json={
|
||||
"source": "claude",
|
||||
"external_session_id": f"e2e-import-{uuid4().hex}",
|
||||
"workspace": _WS_DIR,
|
||||
"items": [
|
||||
{
|
||||
"type": "message",
|
||||
"response_id": "resp_1",
|
||||
"data": {
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello from an import"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
session_id = resp.json()["session_id"]
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
# Real liveness resolves the import to local_stranded (server reports
|
||||
# runner_online=false; the client skips the cold-boot grace for imports),
|
||||
# so the disconnected banner shows. Clicking it must open the resume
|
||||
# picker, NOT the terminal reconnect dialog.
|
||||
indicator = page.get_by_test_id("disconnected-indicator")
|
||||
expect(indicator).to_be_visible(timeout=30_000)
|
||||
indicator.click()
|
||||
|
||||
dialog = page.get_by_test_id("resume-dir-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.get_by_test_id("reconnect-session-dialog")).not_to_be_visible()
|
||||
|
||||
# Prefill: the session's own workspace, host defaulted to the sole online
|
||||
# machine → the bind button is enabled without any further input.
|
||||
bind = page.get_by_test_id("resume-dir-bind-button")
|
||||
expect(bind).to_be_enabled(timeout=10_000)
|
||||
bind.click()
|
||||
|
||||
deadline = time.monotonic() + 30.0
|
||||
while not runner_bodies and time.monotonic() < deadline:
|
||||
time.sleep(0.2)
|
||||
assert len(runner_bodies) == 1, "expected exactly one runner launch"
|
||||
launch = runner_bodies[0]
|
||||
assert launch["session_id"] == session_id, launch
|
||||
assert launch["workspace"] == _WS_DIR, launch
|
||||
+14
-17
@@ -3773,43 +3773,41 @@ async def test_connected_host_auth_rejection_prints_notice_once(
|
||||
assert err.count(f"HTTP {status}") == 1
|
||||
|
||||
|
||||
async def test_fresh_host_still_fails_loud_on_auth_rejection(
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
async def test_fresh_host_retries_auth_rejection_before_failing_loud(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status: int,
|
||||
) -> None:
|
||||
"""A never-connected host still fails loud on 401/403.
|
||||
"""A never-connected host retries auth errors before failing loud.
|
||||
|
||||
The connected-host retry keys on a prior successful upgrade. A host
|
||||
that has NEVER connected and is rejected with 401/403 is genuinely
|
||||
unauthenticated / unauthorized — it must still raise HostConnectError
|
||||
on the first attempt (→ exit 1 with the fix printed), not loop.
|
||||
Databricks OAuth can refresh after daemon start but before a later
|
||||
tunnel upgrade. The host gets a few attempts, then still raises a
|
||||
clear credential failure instead of looping forever.
|
||||
"""
|
||||
spy = _ConnectSpy([_invalid_status(403)])
|
||||
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
|
||||
spy = _ConnectSpy([_invalid_status(status)])
|
||||
_patch_connect(monkeypatch, spy)
|
||||
host = _host()
|
||||
|
||||
with pytest.raises(HostConnectError):
|
||||
await host.run()
|
||||
|
||||
# Exactly one attempt → no silent retry for the fresh-host case.
|
||||
assert spy.call_count == 1
|
||||
assert spy.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,expected",
|
||||
[
|
||||
(401, "HTTP 401"),
|
||||
(403, "HTTP 403"),
|
||||
(404, "permanent"),
|
||||
],
|
||||
)
|
||||
async def test_run_fails_loud_on_permanent_4xx(
|
||||
monkeypatch: pytest.MonkeyPatch, status: int, expected: str
|
||||
) -> None:
|
||||
"""A permanent 4xx upgrade rejection fails loud on the first attempt.
|
||||
"""A non-auth permanent 4xx upgrade rejection fails loud immediately.
|
||||
|
||||
401/403/other-4xx mean unauthenticated / unauthorized / wrong-or-old
|
||||
server — reconnecting can never succeed, so run() must raise
|
||||
HostConnectError immediately rather than backing off.
|
||||
A wrong or old server cannot recover through reconnecting, so run()
|
||||
must raise HostConnectError rather than backing off.
|
||||
"""
|
||||
spy = _ConnectSpy([_invalid_status(status)])
|
||||
_patch_connect(monkeypatch, spy)
|
||||
@@ -3820,8 +3818,6 @@ async def test_run_fails_loud_on_permanent_4xx(
|
||||
|
||||
# Message identifies the specific permanent failure.
|
||||
assert expected in str(excinfo.value)
|
||||
# Exactly one attempt → no silent reconnect/backoff. If >1, the 4xx
|
||||
# was misclassified as transient and the loop kept retrying.
|
||||
assert spy.call_count == 1
|
||||
|
||||
|
||||
@@ -3838,6 +3834,7 @@ async def test_auth_rejection_suggests_omnigent_login(
|
||||
server URL, so the user can copy-paste it.
|
||||
"""
|
||||
server_url = "https://app.example.databricks.com"
|
||||
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
|
||||
spy = _ConnectSpy([_invalid_status(status)])
|
||||
_patch_connect(monkeypatch, spy)
|
||||
host = _host(server_url=server_url)
|
||||
|
||||
@@ -127,7 +127,8 @@ async def test_session_new_client_mode_generates_and_sends_id() -> None:
|
||||
|
||||
|
||||
def test_extract_tool_call_prefers_title() -> None:
|
||||
name, args = AcpExecutor._extract_tool_call(
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
name, args = ex._extract_tool_call(
|
||||
{"toolCall": {"title": "shell", "kind": "execute", "rawInput": {"command": "ls"}}}
|
||||
)
|
||||
assert name == "shell"
|
||||
@@ -135,11 +136,60 @@ def test_extract_tool_call_prefers_title() -> None:
|
||||
|
||||
|
||||
def test_extract_tool_call_falls_back_to_kind() -> None:
|
||||
name, args = AcpExecutor._extract_tool_call({"toolCall": {"kind": "read"}})
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
name, args = ex._extract_tool_call({"toolCall": {"kind": "read"}})
|
||||
assert name == "read"
|
||||
assert args == {}
|
||||
|
||||
|
||||
def test_extract_tool_call_recovers_a_bare_permission_request() -> None:
|
||||
"""A request naming only ``toolCallId`` resolves via the originating tool_call.
|
||||
|
||||
An agent may ask permission without repeating the tool: Devin sends no
|
||||
``title`` / ``kind`` / ``rawInput``, only the id it already announced. The
|
||||
``tool_call`` update always arrives first, so its name and arguments are
|
||||
still on hand.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update(
|
||||
{
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "toolu_01",
|
||||
"title": "Ran command",
|
||||
"kind": "execute",
|
||||
"rawInput": {"command": "rm -rf build"},
|
||||
}
|
||||
)
|
||||
name, args = ex._extract_tool_call(
|
||||
{
|
||||
"toolCall": {
|
||||
"toolCallId": "toolu_01",
|
||||
"_meta": {"vendor/editableCommand": "rm -rf build"},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert name == "Ran command"
|
||||
assert args == {"command": "rm -rf build"}
|
||||
|
||||
|
||||
def test_extract_tool_call_prefers_the_request_over_the_cache() -> None:
|
||||
"""A request that carries its own title/rawInput wins; the cache is a fallback."""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update(
|
||||
{"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "stale", "rawInput": {"a": 1}}
|
||||
)
|
||||
name, args = ex._extract_tool_call(
|
||||
{"toolCall": {"toolCallId": "c1", "title": "shell", "rawInput": {"command": "ls"}}}
|
||||
)
|
||||
assert (name, args) == ("shell", {"command": "ls"})
|
||||
|
||||
|
||||
def test_extract_tool_call_unknown_id_degrades_to_tool() -> None:
|
||||
"""An id we never saw announced still yields the safe generic fallback."""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
assert ex._extract_tool_call({"toolCall": {"toolCallId": "never-announced"}}) == ("tool", {})
|
||||
|
||||
|
||||
def test_permission_outcome_allow_prefers_once() -> None:
|
||||
params = {
|
||||
"options": [
|
||||
@@ -211,6 +261,31 @@ def test_tool_call_and_update_emit_cards() -> None:
|
||||
assert "c1" not in ex._tool_names # popped
|
||||
|
||||
|
||||
def test_tool_call_caches_release_on_completion() -> None:
|
||||
"""Both id-keyed caches drop the entry when the call closes.
|
||||
|
||||
They exist only to bridge a tool_call to its permission request and closing
|
||||
update, so a long session must not accumulate one entry per tool call.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update(
|
||||
{
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "c1",
|
||||
"title": "shell",
|
||||
"rawInput": {"command": "ls"},
|
||||
}
|
||||
)
|
||||
assert ex._tool_names == {"c1": "shell"}
|
||||
assert ex._tool_inputs == {"c1": {"command": "ls"}}
|
||||
|
||||
ex._handle_session_update(
|
||||
{"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed"}
|
||||
)
|
||||
assert ex._tool_names == {}
|
||||
assert ex._tool_inputs == {}
|
||||
|
||||
|
||||
def test_tool_call_update_failed_maps_to_error() -> None:
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
ex._handle_session_update({"sessionUpdate": "tool_call", "toolCallId": "c2", "title": "t"})
|
||||
@@ -324,6 +399,63 @@ async def test_decide_permission_ask_without_handler_fails_closed() -> None:
|
||||
assert await ex._decide_permission({"toolCall": {"title": "shell"}}) is False
|
||||
|
||||
|
||||
def _seed_tool_call(ex: AcpExecutor) -> dict[str, object]:
|
||||
"""Announce a ``tool_call``, then return the bare permission params for it.
|
||||
|
||||
Mirrors the real frame order for an agent that asks permission without
|
||||
repeating the tool: ``tool_call`` (with the name + command) → then
|
||||
``session/request_permission`` carrying only the id.
|
||||
"""
|
||||
ex._handle_session_update(
|
||||
{
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "toolu_01",
|
||||
"title": "Ran command",
|
||||
"kind": "execute",
|
||||
"rawInput": {"command": "rm -rf build"},
|
||||
}
|
||||
)
|
||||
return {"toolCall": {"toolCallId": "toolu_01"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_permission_policy_sees_the_real_tool_call() -> None:
|
||||
"""The TOOL_CALL policy is evaluated against the resolved name + arguments.
|
||||
|
||||
Rules gate on the tool name and then read its arguments (the destructive-shell
|
||||
builtin reads ``arguments["command"]``), so a bare request evaluated as
|
||||
``{"name": "tool", "arguments": {}}`` matches nothing — a "deny ``rm -rf``"
|
||||
policy would sit silent while the command ran.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
params = _seed_tool_call(ex)
|
||||
|
||||
class _V:
|
||||
action = "POLICY_ACTION_DENY"
|
||||
|
||||
ex._policy_evaluator = AsyncMock(return_value=_V())
|
||||
assert await ex._decide_permission(params) is False
|
||||
ex._policy_evaluator.assert_awaited_once_with(
|
||||
"PHASE_TOOL_CALL",
|
||||
{"name": "Ran command", "arguments": {"command": "rm -rf build"}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_permission_card_names_the_tool() -> None:
|
||||
"""The approval card describes the call instead of an unnamed "tool".
|
||||
|
||||
The elicitation handler renders ``<tool_name>(<args>)``, so these two values
|
||||
are literally what the user reads before approving.
|
||||
"""
|
||||
ex = AcpExecutor(AcpAgentConfig(command="x"))
|
||||
params = _seed_tool_call(ex)
|
||||
ex._elicitation_handler = AsyncMock(return_value=True)
|
||||
|
||||
assert await ex._decide_permission(params) is True
|
||||
ex._elicitation_handler.assert_awaited_once_with("Ran command", {"command": "rm -rf build"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# warm model switch (session/set_config_option)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2074,6 +2074,43 @@ def test_normalize_content_blocks_strips_filename_from_input_image() -> None:
|
||||
assert result is not blocks
|
||||
|
||||
|
||||
def test_normalize_responses_items_wraps_string_assistant_content() -> None:
|
||||
"""String content must become blocks before the chat converter sees it.
|
||||
|
||||
A plain string is legal Responses-API content, but ``items_to_messages``
|
||||
iterates content expecting blocks — so it walks the string character by
|
||||
character and indexes each one, raising ``string indices must be integers,
|
||||
not 'str'``. Since history is replayed, one such item breaks every later
|
||||
turn in the conversation.
|
||||
"""
|
||||
items = [
|
||||
{"type": "message", "role": "assistant", "content": "I'll explore the repo."},
|
||||
{"type": "message", "role": "user", "content": "go ahead"},
|
||||
]
|
||||
|
||||
result = _normalize_responses_items_for_chat(items)
|
||||
|
||||
assert result[0]["content"] == [{"type": "output_text", "text": "I'll explore the repo."}]
|
||||
# User strings reach a different converter branch that accepts them, and
|
||||
# callers rely on them staying strings.
|
||||
assert result[1]["content"] == "go ahead"
|
||||
|
||||
|
||||
def test_normalize_responses_items_leaves_block_content_alone() -> None:
|
||||
"""Content that is already a block list is untouched."""
|
||||
items = [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "hi"}],
|
||||
}
|
||||
]
|
||||
|
||||
result = _normalize_responses_items_for_chat(items)
|
||||
|
||||
assert result[0]["content"] == [{"type": "output_text", "text": "hi"}]
|
||||
|
||||
|
||||
def test_normalize_content_blocks_preserves_input_image_detail_for_http_url() -> None:
|
||||
"""Supported ``input_image.detail`` survives metadata sanitization for URLs."""
|
||||
blocks = [
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_run_background_wraps_command_in_sh_c() -> None:
|
||||
[cmd] = launcher.commands
|
||||
assert cmd == (
|
||||
f"setsid nohup sh -c {shlex.quote(supervise_host_command(_HOST_LAUNCH))} "
|
||||
"> /tmp/omnigent-host.log 2>&1 < /dev/null & echo launched"
|
||||
">> /tmp/omnigent-host.log 2>&1 < /dev/null & echo launched"
|
||||
)
|
||||
# The env prefix stays attached to the launch inside the quoted script, so
|
||||
# the inner shell re-applies it on every supervised restart attempt.
|
||||
|
||||
@@ -232,7 +232,7 @@ def test_run_background_uses_exec_background(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert name == "sb-1"
|
||||
assert command[:2] == ["bash", "-lc"]
|
||||
assert "omnigent host --server https://s" in command[2]
|
||||
assert "> /tmp/host.log 2>&1 < /dev/null" in command[2]
|
||||
assert ">> /tmp/host.log 2>&1 < /dev/null" in command[2]
|
||||
assert fake.exec_calls == []
|
||||
|
||||
|
||||
|
||||
@@ -20,21 +20,20 @@ from omnigent.onboarding.ucode_setup import (
|
||||
)
|
||||
|
||||
|
||||
def test_find_ucode_command_prefers_uvx_from_main() -> None:
|
||||
"""Prefers an ephemeral uvx run pinned to main, even if ucode is installed.
|
||||
def test_find_ucode_command_prefers_uvx_pinned_commit() -> None:
|
||||
"""Prefers an ephemeral uvx run pinned to a fixed commit, even if ucode is
|
||||
installed.
|
||||
|
||||
The locally-installed binary may be stale (predating
|
||||
``configure --workspaces``), so uvx-from-main must win when both are
|
||||
present. ``--refresh-package ucode`` forces re-resolution of the mutable
|
||||
``main`` ref so the cache can't serve an old commit.
|
||||
``configure --workspaces``), so uvx-from-pinned-commit must win when both
|
||||
are present. The ref is a full SHA so setup resolves a reproducible ucode
|
||||
rather than a moving ``main`` HEAD.
|
||||
"""
|
||||
with patch("shutil.which", return_value="/usr/bin/uvx"):
|
||||
assert find_ucode_command() == [
|
||||
"/usr/bin/uvx",
|
||||
"--refresh-package",
|
||||
"ucode",
|
||||
"--from",
|
||||
"git+https://github.com/databricks/ucode@main",
|
||||
"git+https://github.com/databricks/ucode@94271a78c7139220b7333bcae91e522f95ef3af3",
|
||||
"ucode",
|
||||
]
|
||||
|
||||
@@ -88,10 +87,8 @@ def test_build_ucode_configure_command_supports_uvx_prefix() -> None:
|
||||
command = build_ucode_configure_command(
|
||||
(
|
||||
"/usr/bin/uvx",
|
||||
"--refresh-package",
|
||||
"ucode",
|
||||
"--from",
|
||||
"git+https://github.com/databricks/ucode@main",
|
||||
"git+https://github.com/databricks/ucode@94271a78c7139220b7333bcae91e522f95ef3af3",
|
||||
"ucode",
|
||||
),
|
||||
workspace_urls=("https://one.example.databricks.com",),
|
||||
@@ -99,10 +96,8 @@ def test_build_ucode_configure_command_supports_uvx_prefix() -> None:
|
||||
|
||||
assert command == [
|
||||
"/usr/bin/uvx",
|
||||
"--refresh-package",
|
||||
"ucode",
|
||||
"--from",
|
||||
"git+https://github.com/databricks/ucode@main",
|
||||
"git+https://github.com/databricks/ucode@94271a78c7139220b7333bcae91e522f95ef3af3",
|
||||
"ucode",
|
||||
"configure",
|
||||
"--workspaces",
|
||||
|
||||
@@ -6752,6 +6752,59 @@ async def test_post_external_session_todos_rejects_non_list_todos(
|
||||
assert "external_session_todos" in resp.text
|
||||
|
||||
|
||||
async def test_post_external_session_todos_filters_malformed_items(
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Individual malformed todo items are dropped before caching / broadcast.
|
||||
|
||||
The top-level payload is a valid list (so this is not the 400-rejection
|
||||
path), but ``_handle_external_session_todos`` keeps only items with a
|
||||
string ``content``, a ``status`` in {pending, in_progress, completed},
|
||||
and a string ``activeForm``. This mirrors the same filter ``sse.ts``
|
||||
applies on the live path, so a buggy forwarder version can't poison the
|
||||
snapshot or the in-chat Plan tracker with half-formed entries.
|
||||
"""
|
||||
from omnigent.server.routes import sessions as sessions_module
|
||||
|
||||
published: list[tuple[str, dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.server.routes.sessions.session_stream.publish",
|
||||
lambda sid, ev: published.append((sid, ev)),
|
||||
)
|
||||
agent = await create_test_agent(client)
|
||||
session = await _create_session(client, agent["id"])
|
||||
sessions_module._session_todos_cache.pop(session["id"], None)
|
||||
|
||||
good = {"content": "Real task", "status": "in_progress", "activeForm": "Doing it"}
|
||||
todos = [
|
||||
good,
|
||||
{"content": "Bad status", "status": "not-a-status", "activeForm": "x"},
|
||||
{"content": 123, "status": "pending", "activeForm": "x"}, # non-str content
|
||||
{"content": "No active form", "status": "completed", "activeForm": None}, # non-str
|
||||
"not-a-dict",
|
||||
{"status": "pending", "activeForm": "x"}, # missing content
|
||||
]
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session['id']}/events",
|
||||
json={"type": "external_session_todos", "data": {"todos": todos}},
|
||||
)
|
||||
assert resp.status_code in (200, 202), resp.text
|
||||
|
||||
# Only the well-formed item survives — on both the live SSE channel
|
||||
# and the cached snapshot the tracker reads on bind.
|
||||
todo_events = [ev for _sid, ev in published if ev.get("type") == "session.todos"]
|
||||
assert len(todo_events) == 1
|
||||
assert todo_events[0]["todos"] == [good]
|
||||
|
||||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||||
assert snapshot["todos"] == [good]
|
||||
finally:
|
||||
sessions_module._session_todos_cache.pop(session["id"], None)
|
||||
|
||||
|
||||
async def test_post_external_mcp_startup_publishes_session_mcp_startup(
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -803,6 +803,61 @@ async def test_health_unbound_fork_of_coding_session_reads_offline(
|
||||
assert sessions[chat_fork.id]["runner_online"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_unbound_imported_session_reads_offline(
|
||||
db_uri: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
An unbound imported transcript reads offline; a plain session online.
|
||||
|
||||
Both are unbound (no runner, no host). The ``omnigent.import.source``
|
||||
label is the difference: an import has no live executor anywhere, so it
|
||||
must launch a runner on a host first and reads ``runner_online: false``
|
||||
(routing the open view to the resume picker). A plain unbound session
|
||||
resumes in-process and stays online. Regression guard for the
|
||||
``imported`` branch of ``_bulk_session_liveness``.
|
||||
"""
|
||||
from omnigent.server.app import create_app
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||||
SqlAlchemyConversationStore,
|
||||
)
|
||||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||||
from omnigent.stores.host_store import HostStore
|
||||
|
||||
conversation_store = SqlAlchemyConversationStore(db_uri)
|
||||
host_store = HostStore(db_uri)
|
||||
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
||||
|
||||
imported = conversation_store.create_conversation()
|
||||
conversation_store.set_labels(imported.id, {"omnigent.import.source": "claude"})
|
||||
plain = conversation_store.create_conversation()
|
||||
|
||||
app = create_app(
|
||||
agent_store=SqlAlchemyAgentStore(db_uri),
|
||||
file_store=SqlAlchemyFileStore(db_uri),
|
||||
conversation_store=conversation_store,
|
||||
artifact_store=artifact_store,
|
||||
host_store=host_store,
|
||||
agent_cache=AgentCache(
|
||||
artifact_store=artifact_store,
|
||||
cache_dir=tmp_path / "cache",
|
||||
),
|
||||
)
|
||||
ids = f"{imported.id},{plain.id}"
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
resp = await c.get(f"/health?session_ids={ids}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
sessions = resp.json()["sessions"]
|
||||
# Imported → offline (needs a host). A True here means the unbound branch
|
||||
# ignored the import marker (the pre-fix behavior).
|
||||
assert sessions[imported.id]["runner_online"] is False
|
||||
# Plain unbound session → reachable in-process.
|
||||
assert sessions[plain.id]["runner_online"] is True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SeedStores:
|
||||
"""
|
||||
|
||||
@@ -4475,6 +4475,28 @@ def test_get_session_connectivity_reports_needs_workspace_for_fork(
|
||||
assert result[plain.id].needs_workspace is False
|
||||
|
||||
|
||||
def test_get_session_connectivity_reports_imported(
|
||||
conversation_store: SqlAlchemyConversationStore,
|
||||
) -> None:
|
||||
"""
|
||||
The import-source label surfaces as ``imported=True``.
|
||||
|
||||
An imported transcript carries the ``omnigent.import.source`` label and
|
||||
has no live executor, so ``get_session_connectivity`` must report
|
||||
``imported=True`` — the flag that makes ``_bulk_session_liveness`` mark
|
||||
the unbound import offline so the open view offers the resume picker
|
||||
instead of treating it as a reachable in-process session.
|
||||
"""
|
||||
imported = conversation_store.create_conversation()
|
||||
conversation_store.set_labels(imported.id, {"omnigent.import.source": "claude"})
|
||||
plain = conversation_store.create_conversation()
|
||||
|
||||
result = conversation_store.get_session_connectivity([imported.id, plain.id])
|
||||
|
||||
assert result[imported.id].imported is True
|
||||
assert result[plain.id].imported is False
|
||||
|
||||
|
||||
def test_get_session_connectivity_empty_input_skips_query(
|
||||
conversation_store: SqlAlchemyConversationStore,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Guard: the terminal close codes stay reachable without FastAPI.
|
||||
|
||||
The 4xxx close codes are the wire contract between the server's
|
||||
``/attach`` route, the runner, the native CLI client, and the browser.
|
||||
They used to live in :mod:`omnigent.terminals.ws_bridge`, so the CLI's
|
||||
``omnigent claude`` launch imported FastAPI (~120ms) and the tmux
|
||||
registry (~100ms) just to compare an integer.
|
||||
|
||||
These tests pin the codes to their published values — a change here is a
|
||||
protocol break that also needs the browser mirror updated — and pin the
|
||||
import boundary that keeps them cheap to read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from omnigent.terminals import close_codes
|
||||
|
||||
# Mirrored in ``web/src/components/blocks/TerminalSession.ts``.
|
||||
_PUBLISHED_CODES = {
|
||||
"WS_CLOSE_WRONG_REPLICA": 4400,
|
||||
"WS_CLOSE_TERMINAL_NOT_FOUND": 4404,
|
||||
"WS_CLOSE_TERMINAL_DETACHED": 4405,
|
||||
"WS_CLOSE_INTERNAL_ERROR": 4500,
|
||||
}
|
||||
|
||||
_MUST_NOT_LOAD = (
|
||||
"fastapi",
|
||||
"omnigent.inner.terminal",
|
||||
"omnigent.terminals.registry",
|
||||
"omnigent.terminals.ws_bridge",
|
||||
"starlette",
|
||||
)
|
||||
|
||||
|
||||
def test_published_close_codes_are_stable() -> None:
|
||||
"""These integers are on the wire — they cannot drift silently."""
|
||||
actual = {name: getattr(close_codes, name) for name in _PUBLISHED_CODES}
|
||||
assert actual == _PUBLISHED_CODES
|
||||
|
||||
|
||||
def test_all_lists_every_code() -> None:
|
||||
"""``__all__`` must not fall behind the module's contents."""
|
||||
assert sorted(close_codes.__all__) == sorted(_PUBLISHED_CODES)
|
||||
|
||||
|
||||
def test_reading_a_close_code_does_not_import_the_bridge() -> None:
|
||||
"""A CLI client must not pay for FastAPI to compare an integer."""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from omnigent.terminals.close_codes import WS_CLOSE_TERMINAL_NOT_FOUND\n"
|
||||
"import sys\n"
|
||||
f"print(sorted(m for m in {_MUST_NOT_LOAD!r} if m in sys.modules))",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert proc.stdout.strip() == "[]", (
|
||||
f"close_codes pulled in heavy modules: {proc.stdout.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def test_native_client_does_not_import_fastapi() -> None:
|
||||
"""``omnigent claude``'s module graph must stay FastAPI-free."""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import omnigent.claude_native\nimport sys\n"
|
||||
"print(sorted(m for m in ('fastapi', 'starlette') if m in sys.modules))",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert proc.stdout.strip() == "[]", (
|
||||
f"claude_native imports a server framework: {proc.stdout.strip()}"
|
||||
)
|
||||
@@ -29,8 +29,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import omnigent.terminals.ws_bridge as ws_bridge
|
||||
from omnigent.terminals.close_codes import WS_CLOSE_TERMINAL_DETACHED
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
_check_pane_dead_definitive,
|
||||
_forward_pty_to_ws,
|
||||
_reap_tmux_attach_child,
|
||||
|
||||
@@ -35,7 +35,7 @@ from omnigent.databricks_model_discovery import DatabricksClaudeCatalog
|
||||
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN
|
||||
from omnigent.runtime import tool_result_replay as trc
|
||||
from omnigent.spec import load_omnigent_yaml
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
|
||||
@@ -234,6 +234,16 @@ describe("useSessionLiveness — derivation truth table", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("imported session skips the grace → local_stranded even when fresh", () => {
|
||||
// An import has no runner booting, so the cold-boot grace must not apply:
|
||||
// a fresh import reads local_stranded at once (offering the resume
|
||||
// picker) instead of "Connecting…" for the whole grace window. Same
|
||||
// inputs as the just-created case above, which is `starting`.
|
||||
expect(
|
||||
derive(false, null, conv({ host_id: null, created_at: freshCreatedAt(), imported: true })),
|
||||
).toEqual({ kind: "local_stranded" });
|
||||
});
|
||||
|
||||
it("starting wins over host_offline for a fresh host-bound session", () => {
|
||||
// The grace precedes the host_offline check: a new host-bound session
|
||||
// whose host hasn't registered yet must not flash "host offline" either.
|
||||
@@ -339,6 +349,7 @@ describe("useSessionLiveness — derivation truth table", () => {
|
||||
created_at: 123,
|
||||
host_resumable: false,
|
||||
kind: undefined,
|
||||
imported: false,
|
||||
});
|
||||
// hostResumable flows through so an off-sidebar resumable host can
|
||||
// classify host_asleep rather than dead-ending on host_offline.
|
||||
|
||||
@@ -47,8 +47,19 @@ export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "c
|
||||
* reconnect modal). Absent ⇒ treated as ``"default"``.
|
||||
*/
|
||||
kind?: "default" | "sub_agent";
|
||||
/**
|
||||
* Whether this session was imported from a local harness transcript (the
|
||||
* `omnigent.import.source` label). An import has no runner booting, so it
|
||||
* must skip the cold-boot startup grace — otherwise it shows "Connecting…"
|
||||
* for {@link STARTING_GRACE_S} before settling to `local_stranded`, when it
|
||||
* should offer the resume picker immediately. Absent ⇒ treated `false`.
|
||||
*/
|
||||
imported?: boolean;
|
||||
};
|
||||
|
||||
/** Label marking a session imported from a local harness transcript. */
|
||||
export const IMPORT_SOURCE_LABEL_KEY = "omnigent.import.source";
|
||||
|
||||
/**
|
||||
* Build a {@link LivenessRow} from the single-session snapshot
|
||||
* ({@link Session}) for callers whose sidebar row (`Conversation`) is
|
||||
@@ -61,7 +72,10 @@ export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "c
|
||||
*/
|
||||
export function livenessRowFromSession(
|
||||
session:
|
||||
| Pick<Session, "hostId" | "permissionLevel" | "createdAt" | "hostResumable" | "kind">
|
||||
| Pick<
|
||||
Session,
|
||||
"hostId" | "permissionLevel" | "createdAt" | "hostResumable" | "kind" | "labels"
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
): LivenessRow | null {
|
||||
@@ -72,6 +86,7 @@ export function livenessRowFromSession(
|
||||
created_at: session.createdAt,
|
||||
host_resumable: session.hostResumable ?? false,
|
||||
kind: session.kind,
|
||||
imported: Boolean(session.labels?.[IMPORT_SOURCE_LABEL_KEY]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,6 +256,7 @@ export function useSessionLiveness(
|
||||
const createdAt = conv?.created_at;
|
||||
if (
|
||||
!runnerEverOnline &&
|
||||
!conv?.imported &&
|
||||
typeof createdAt === "number" &&
|
||||
createdAt > 0 &&
|
||||
Date.now() / 1000 - createdAt < STARTING_GRACE_S
|
||||
|
||||
@@ -573,8 +573,8 @@ export interface SessionAgentChangedEvent {
|
||||
* Each todo item has:
|
||||
* - `content`: the task description string
|
||||
* - `status`: `"pending"` | `"in_progress"` | `"completed"`
|
||||
* - `activeForm`: present-continuous form of the task (e.g. `"Running tests"`).
|
||||
* Shown by the TodoPanel under in-progress items when distinct from `content`.
|
||||
* - `activeForm`: present-continuous form of the task (e.g. `"Running tests"`),
|
||||
* the present-continuous label for an in-progress item when distinct from `content`.
|
||||
*/
|
||||
export interface SessionTodosEvent {
|
||||
type: "session_todos";
|
||||
|
||||
@@ -13,7 +13,6 @@ const RAIL_TABS: readonly RightRailTab[] = [
|
||||
"changes",
|
||||
"subagents",
|
||||
"terminals",
|
||||
"todos",
|
||||
"browser",
|
||||
];
|
||||
|
||||
@@ -22,7 +21,7 @@ export interface SessionWorkspaceState {
|
||||
open?: boolean;
|
||||
/** User-chosen rail width (px) for this session. */
|
||||
widthPx?: number;
|
||||
/** The selected rail tab (Files / Changes / Agents / Shells / Tasks). */
|
||||
/** The selected rail tab (Files / Changes / Agents / Shells). */
|
||||
rightRailTab?: RightRailTab;
|
||||
/** Ordered list of open file tabs. */
|
||||
openFiles?: string[];
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
stripGatedSubagentRoutingChips,
|
||||
stripPendingElicitations,
|
||||
subAgentComposerLabel,
|
||||
unboundSessionResumableInApp,
|
||||
WORKING_MESSAGES,
|
||||
workingIndicatorLabel,
|
||||
} from "./ChatPage";
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -22,7 +22,17 @@ const THUMB_PX = 56;
|
||||
const TRACK_TOP_PX = 64;
|
||||
const TRACK_BOTTOM_PX = 12;
|
||||
|
||||
export function TranscriptScrollbar({ scroller }: { scroller: Scroller | null }) {
|
||||
export function TranscriptScrollbar({
|
||||
scroller,
|
||||
// Top inset of the track. Defaults to clearing the floating ChatHeader; when
|
||||
// the Plan accordion is pinned above, the scroll container already starts
|
||||
// below the header, so the caller passes a small inset instead — otherwise
|
||||
// the thumb can't reach the top of the (already-cleared) viewport.
|
||||
topInset = TRACK_TOP_PX,
|
||||
}: {
|
||||
scroller: Scroller | null;
|
||||
topInset?: number;
|
||||
}) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [scrollable, setScrollable] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -34,8 +44,8 @@ export function TranscriptScrollbar({ scroller }: { scroller: Scroller | null })
|
||||
|
||||
/** Usable thumb travel: the track minus the thumb's own height. */
|
||||
const travelOf = useCallback(
|
||||
(node: HTMLElement) => node.clientHeight - TRACK_TOP_PX - TRACK_BOTTOM_PX - THUMB_PX,
|
||||
[],
|
||||
(node: HTMLElement) => node.clientHeight - topInset - TRACK_BOTTOM_PX - THUMB_PX,
|
||||
[topInset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,7 +117,7 @@ export function TranscriptScrollbar({ scroller }: { scroller: Scroller | null })
|
||||
aria-hidden
|
||||
data-testid="transcript-scrollbar"
|
||||
className="pointer-events-none absolute right-1 z-10 w-3"
|
||||
style={{ top: TRACK_TOP_PX, bottom: TRACK_BOTTOM_PX }}
|
||||
style={{ top: topInset, bottom: TRACK_BOTTOM_PX }}
|
||||
>
|
||||
<div
|
||||
data-testid="transcript-scrollbar-thumb"
|
||||
|
||||
@@ -91,7 +91,6 @@ vi.mock("./FileViewer", () => ({
|
||||
vi.mock("./InlineTerminalsSection", () => ({
|
||||
InlineTerminalsSection: () => <div data-testid="inline-terminals-section" />,
|
||||
}));
|
||||
vi.mock("./TodoPanel", () => ({ TodoPanel: () => <div data-testid="todo-panel" /> }));
|
||||
vi.mock("./FilesPanelDrawer", () => ({
|
||||
FilesPanelDrawer: () => <div data-testid="files-panel-drawer" />,
|
||||
}));
|
||||
|
||||
@@ -171,9 +171,6 @@ vi.mock("@/components/blocks/TerminalView", () => ({
|
||||
<div data-testid="terminal-view-stub">{terminalId}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./TodoPanel", () => ({
|
||||
TodoPanel: () => <div data-testid="todo-panel" />,
|
||||
}));
|
||||
vi.mock("./FilesPanelDrawer", () => ({
|
||||
FilesPanelDrawer: ({ open, flatView }: { open: boolean; flatView: boolean }) => (
|
||||
<div
|
||||
@@ -518,12 +515,9 @@ beforeEach(() => {
|
||||
// choice carries across sessions. Clear it so a stored preference from one
|
||||
// test can't change another test's default scope.
|
||||
localStorage.clear();
|
||||
// The Tasks tab/drawer gates on chatStore.todos; reset so a populated
|
||||
// todo list from one test doesn't leak into the next.
|
||||
// Reset terminal-first startup signals so one test's terminalPending /
|
||||
// failed status can't leak into another's terminalStartingUp.
|
||||
useChatStore.setState({
|
||||
todos: [],
|
||||
terminalPending: false,
|
||||
sessionStatus: "idle",
|
||||
status: "idle",
|
||||
@@ -2141,10 +2135,10 @@ describe("Right workspace card visibility", () => {
|
||||
});
|
||||
|
||||
it("keeps the card mounted with Agents as the only tab for a minimal agent", () => {
|
||||
// A no-os_env agent (available: false) with no shells and no todos
|
||||
// A no-os_env agent (available: false) with no shells
|
||||
// still has the unconditional Agents tab (the panel lists at least
|
||||
// the main agent), so the card mounts, the Agents tab is selected
|
||||
// by the fallback, and Files/Shells/Tasks are absent. An unmounted
|
||||
// by the fallback, and Files/Shells are absent. An unmounted
|
||||
// card here means the always-visible Agents rule regressed.
|
||||
useEnvironmentMock.mockReturnValue({
|
||||
data: { available: false, root: null, home: null },
|
||||
@@ -2557,7 +2551,7 @@ describe("AppShell URL sync — file param", () => {
|
||||
});
|
||||
|
||||
it("restores the file viewer into the desktop rail on a ?file= reload", () => {
|
||||
// Regression (E2E reload-persistence): the Subagents/Terminals/Todos
|
||||
// Regression (E2E reload-persistence): the Subagents/Terminals
|
||||
// panels are checked before the file viewer in the rail content
|
||||
// precedence. A ?file= reload must pull the rail to Files so the inline
|
||||
// viewer renders instead of another panel shadowing it.
|
||||
@@ -2932,25 +2926,17 @@ describe("Mobile session menu", () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
useChatStore.setState({
|
||||
todos: [
|
||||
{ content: "do a thing", status: "completed", activeForm: "doing a thing" },
|
||||
{ content: "do another", status: "pending", activeForm: "doing another" },
|
||||
],
|
||||
});
|
||||
|
||||
renderShell("/c/conv_native");
|
||||
openSessionMenu();
|
||||
|
||||
// Mirror of the desktop rail's tab strip for a native-wrapper session:
|
||||
// Files · Agents · Tasks. Shells is absent because the only terminal is
|
||||
// Files · Agents. Shells is absent because the only terminal is
|
||||
// the vendor pane (the pill's Terminal view — excluded from the shell
|
||||
// inventory) and the mocked agent declares no terminals. An unexpected
|
||||
// Shells entry means the vendor pane leaked into the inventory.
|
||||
expect(screen.getByRole("menuitem", { name: /^Files$/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /Shells/i })).toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Agents/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: /Tasks/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Terminals entry in terminal-first SDK sessions (no native wrapper)", () => {
|
||||
@@ -3101,67 +3087,8 @@ describe("Mobile session menu", () => {
|
||||
expect(drawer).toHaveAttribute("data-flat-view", "true");
|
||||
});
|
||||
|
||||
it("opens the Tasks drawer for a claude-native session with todos", () => {
|
||||
useEnvironmentMock.mockReturnValue({
|
||||
data: { available: true, root: null },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWorkspaceEnvironment>);
|
||||
mockConversations([
|
||||
{
|
||||
id: "conv_native",
|
||||
permission_level: null,
|
||||
labels: { "omnigent.wrapper": "claude-code-native-ui" },
|
||||
},
|
||||
]);
|
||||
useChatStore.setState({
|
||||
todos: [{ content: "build the thing", status: "in_progress", activeForm: "building" }],
|
||||
});
|
||||
|
||||
renderShell("/c/conv_native");
|
||||
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "closed");
|
||||
expect(screen.queryByTestId("todo-panel")).toBeNull();
|
||||
|
||||
openSessionMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Tasks/i }));
|
||||
|
||||
// Failure: openTodosPanel didn't set todosPanelOpen, or the Tasks entry
|
||||
// was gated out despite isClaudeNative + a non-empty todo list.
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "open");
|
||||
expect(screen.getByTestId("todo-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["codex-native", "conv_codex", "codex-native-ui"],
|
||||
["pi-native", "conv_pi", "pi-native-ui"],
|
||||
])("opens the Tasks drawer for a %s session with todos", (_harness, id, wrapper) => {
|
||||
useEnvironmentMock.mockReturnValue({
|
||||
data: { available: true, root: null },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWorkspaceEnvironment>);
|
||||
mockConversations([
|
||||
{
|
||||
id,
|
||||
permission_level: null,
|
||||
labels: { "omnigent.wrapper": wrapper },
|
||||
},
|
||||
]);
|
||||
useChatStore.setState({
|
||||
todos: [{ content: "Locate CLI parser", status: "in_progress", activeForm: "Locating" }],
|
||||
});
|
||||
|
||||
renderShell(`/c/${id}`);
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "closed");
|
||||
|
||||
openSessionMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Tasks/i }));
|
||||
|
||||
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "open");
|
||||
expect(screen.getByTestId("todo-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the FAB with only the Agents entry for a minimal agent", () => {
|
||||
// available:false → no files; no shells, no todos, no debug. The
|
||||
// available:false → no files; no shells, no debug. The
|
||||
// Agents entry is unconditional (badge = 1, the main agent), so the
|
||||
// FAB still renders with exactly that entry. A missing FAB means
|
||||
// the always-visible Agents rule regressed on mobile.
|
||||
|
||||
@@ -91,7 +91,6 @@ import {
|
||||
type TerminalFirstContextValue,
|
||||
} from "./TerminalFirstContext";
|
||||
import { TerminalsPanel } from "./TerminalsPanel";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
import { PermissionsModal } from "@/components/PermissionsModal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -307,7 +306,6 @@ export function AppShell() {
|
||||
// on a phone they open as full-screen overlays from the session-menu FAB.
|
||||
const [subagentsPanelOpen, setSubagentsPanelOpen] = useState(false);
|
||||
const [shellsPanelOpen, setShellsPanelOpen] = useState(false);
|
||||
const [todosPanelOpen, setTodosPanelOpen] = useState(false);
|
||||
// The right "Workspace" rail (WorkspacePanel) remembers its open/closed
|
||||
// state per session. A brand-new session (no saved `open`) follows the
|
||||
// Appearance "Workspace panel" default; reopening a session restores how
|
||||
@@ -418,15 +416,11 @@ export function AppShell() {
|
||||
const sessionLabels = { ...activeConv?.labels, ...activeSession?.labels };
|
||||
const terminalFirst = sessionLabels["omnigent.ui"] === "terminal";
|
||||
const isClaudeNative = sessionLabels["omnigent.wrapper"] === "claude-code-native-ui";
|
||||
const todos = useChatStore((s) => s.todos);
|
||||
// The session.todos contract is harness-agnostic; show Tasks when it has data.
|
||||
const todosSupported = todos.length > 0;
|
||||
// Native-CLI wrapper of either family. Keys harness behavior gates
|
||||
// (composer slash commands, `/model`); terminal-first SDK sessions
|
||||
// (embedded Omnigent REPL terminal) have NO wrapper label and must
|
||||
// keep regular chat behavior. See TerminalFirstContext.tsx.
|
||||
const isNativeWrapper = isNativeWrapperLabel(sessionLabels["omnigent.wrapper"]);
|
||||
const todosCompleted = todos.filter((t) => t.status === "completed").length;
|
||||
// Used for the header "Back to parent" link, which is hidden on
|
||||
// top-level sessions. The Subagents tab itself is always visible —
|
||||
// it lists the root's children plus a "main" entry, so the user
|
||||
@@ -638,24 +632,23 @@ export function AppShell() {
|
||||
// ``railTerminals`` starts empty while the agent loads, so native
|
||||
// sessions don't flash the tab.
|
||||
terminals: !hideTerminalsTab && railTerminals.length > 0,
|
||||
todos: todosSupported && todos.length > 0,
|
||||
}) as const,
|
||||
[showFilesPanel, hideTerminalsTab, railTerminals.length, todosSupported, todos.length],
|
||||
[showFilesPanel, hideTerminalsTab, railTerminals.length],
|
||||
);
|
||||
// Whether the rail has anything at all to show. When false the workspace
|
||||
// card doesn't mount and the header hides its collapse toggle — a
|
||||
// no-filesystem agent with no terminals/sub-agents/todos would otherwise
|
||||
// no-filesystem agent with no terminals/sub-agents would otherwise
|
||||
// render an empty white card with no way to dismiss it.
|
||||
const hasRailContent = Object.values(railTabsAvailable).some(Boolean);
|
||||
// Keep the selected tab valid. When the current tab disappears — files
|
||||
// panel turns off, or the Shells tab hides (native wrapper / no shell
|
||||
// and no shell access) — fall back to the first still-visible tab in
|
||||
// display order (Files · Changes · Agents · Shells · Tasks · Browser). Picking
|
||||
// display order (Files · Changes · Agents · Shells · Browser). Picking
|
||||
// the first available (rather than ping-ponging between two effects) keeps
|
||||
// this convergent even when several tabs vanish at once.
|
||||
useEffect(() => {
|
||||
if (railTabsAvailable[rightRailTab]) return;
|
||||
const next = (["files", "changes", "subagents", "terminals", "todos", "browser"] as const).find(
|
||||
const next = (["files", "changes", "subagents", "terminals", "browser"] as const).find(
|
||||
(t) => railTabsAvailable[t],
|
||||
);
|
||||
if (next) setRightRailTab(next);
|
||||
@@ -819,7 +812,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false);
|
||||
setSubagentsPanelOpen(false);
|
||||
setShellsPanelOpen(false);
|
||||
setTodosPanelOpen(false);
|
||||
setFilesPanelShowHidden(true);
|
||||
if (!conversationId) {
|
||||
// No session → no rail; false (not the open default) so rail-gated
|
||||
@@ -870,7 +862,7 @@ export function AppShell() {
|
||||
return false;
|
||||
});
|
||||
// A selected file must be visible in the rail. The Files and Changes tabs
|
||||
// both surface the inline viewer; the Agents/Todos/Terminals tabs don't, so
|
||||
// both surface the inline viewer; the Agents/Terminals tabs don't, so
|
||||
// pull the rail to Files unless it's already on a files scope.
|
||||
if (nextSelected && nextTab !== "files" && nextTab !== "changes") {
|
||||
nextTab = "files";
|
||||
@@ -942,13 +934,10 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer so the viewer is unobscured
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
// Pull the rail to the Files tab when parked on a tab where the viewer
|
||||
// won't render (Terminals, Subagents, Todos). The Files tab surfaces the
|
||||
// won't render (Terminals, Subagents). The Files tab surfaces the
|
||||
// FileViewer inline, so leave it undisturbed.
|
||||
setRightRailTab((prev) =>
|
||||
prev === "terminals" || prev === "subagents" || prev === "todos" ? "files" : prev,
|
||||
);
|
||||
setRightRailTab((prev) => (prev === "terminals" || prev === "subagents" ? "files" : prev));
|
||||
// Reveal the rail so the viewer is actually visible — a session the user
|
||||
// collapsed (or one that started collapsed via the Appearance default)
|
||||
// would otherwise route the file into an invisible panel. Persist
|
||||
@@ -1255,7 +1244,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setPanelInitialKey(key);
|
||||
}
|
||||
|
||||
@@ -1275,7 +1263,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false);
|
||||
setSubagentsPanelOpen(false);
|
||||
setShellsPanelOpen(false);
|
||||
setTodosPanelOpen(false);
|
||||
setRightPanelOpen(true);
|
||||
if (conversationId) writeSessionWorkspaceState(conversationId, { open: true });
|
||||
},
|
||||
@@ -1323,7 +1310,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setExecutionLogsKey(key);
|
||||
}
|
||||
|
||||
@@ -1336,7 +1322,6 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setFilesDrawerFlatView(flatView);
|
||||
setFilesPanelOpen(true);
|
||||
}
|
||||
@@ -1352,7 +1337,6 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setSubagentsPanelOpen(true);
|
||||
}
|
||||
|
||||
@@ -1366,23 +1350,9 @@ export function AppShell() {
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setTodosPanelOpen(false); // close mobile tasks drawer
|
||||
setShellsPanelOpen(true);
|
||||
}
|
||||
|
||||
// Mobile FAB → "Tasks" opens the todo list (the desktop rail's Tasks tab)
|
||||
// as a full-screen drawer.
|
||||
function openTodosPanel() {
|
||||
setSelectedFilePath(null); // close file viewer
|
||||
clearFileViewerUrl();
|
||||
setPanelInitialKey(null); // close terminals panel
|
||||
setExecutionLogsKey(null); // close execution-logs panel
|
||||
setFilesPanelOpen(false); // close files drawer
|
||||
setSubagentsPanelOpen(false); // close mobile agents drawer
|
||||
setShellsPanelOpen(false); // close mobile shells drawer
|
||||
setTodosPanelOpen(true);
|
||||
}
|
||||
|
||||
function openMainExecutionLog() {
|
||||
// Mobile FAB → "Execution logs" jumps straight to the main thread.
|
||||
// Children are reachable via the panel's tab switcher.
|
||||
@@ -1689,7 +1659,6 @@ export function AppShell() {
|
||||
filesPanelOpen,
|
||||
subagentsPanelOpen,
|
||||
shellsPanelOpen,
|
||||
todosPanelOpen,
|
||||
hideTerminalsTab,
|
||||
// Mobile: reachable when a shell exists OR the agent
|
||||
// declares shell access (so the drawer's "+ New shell" row
|
||||
@@ -1698,9 +1667,6 @@ export function AppShell() {
|
||||
showShellsTab:
|
||||
!hideTerminalsTab && (railTerminals.length > 0 || agentSupportsShells),
|
||||
terminalsLength: railTerminals.length,
|
||||
todosSupported,
|
||||
todosCompleted,
|
||||
todosTotal: todos.length,
|
||||
debugMode,
|
||||
changedCount,
|
||||
subagentsWorking,
|
||||
@@ -1709,7 +1675,6 @@ export function AppShell() {
|
||||
onOpenChanges: openChangesPanel,
|
||||
onOpenShells: openShellsPanel,
|
||||
onOpenSubagents: openSubagentsPanel,
|
||||
onOpenTodos: openTodosPanel,
|
||||
onOpenMainExecutionLog: openMainExecutionLog,
|
||||
}}
|
||||
/>
|
||||
@@ -1756,9 +1721,6 @@ export function AppShell() {
|
||||
terminalsLength={railTerminals.length}
|
||||
subagentsWorking={subagentsWorking}
|
||||
agentCount={agentCount}
|
||||
todosSupported={todosSupported}
|
||||
todosCompleted={todosCompleted}
|
||||
todosTotal={todos.length}
|
||||
rootSessionId={rootSessionId}
|
||||
selectedFilePath={selectedFilePath}
|
||||
openFiles={openFiles}
|
||||
@@ -1849,16 +1811,6 @@ export function AppShell() {
|
||||
/>
|
||||
</MobilePanelDrawer>
|
||||
)}
|
||||
{conversationId && (
|
||||
<MobilePanelDrawer
|
||||
open={todosPanelOpen}
|
||||
title="Tasks"
|
||||
onClose={() => setTodosPanelOpen(false)}
|
||||
testId="todos-panel-drawer"
|
||||
>
|
||||
<TodoPanel frameless />
|
||||
</MobilePanelDrawer>
|
||||
)}
|
||||
{/* Mobile-only push panel — on desktop the viewer lives inside the inline aside. */}
|
||||
{conversationId && selectedFilePath !== null && (
|
||||
<div className="md:hidden">
|
||||
|
||||
@@ -36,13 +36,9 @@ const mobileMenu = {
|
||||
filesPanelOpen: false,
|
||||
subagentsPanelOpen: false,
|
||||
shellsPanelOpen: false,
|
||||
todosPanelOpen: false,
|
||||
hideTerminalsTab: false,
|
||||
showShellsTab: false,
|
||||
terminalsLength: 0,
|
||||
todosSupported: false,
|
||||
todosCompleted: 0,
|
||||
todosTotal: 0,
|
||||
debugMode: false,
|
||||
changedCount: 0,
|
||||
subagentsWorking: 0,
|
||||
@@ -51,7 +47,6 @@ const mobileMenu = {
|
||||
onOpenChanges: () => {},
|
||||
onOpenShells: () => {},
|
||||
onOpenSubagents: () => {},
|
||||
onOpenTodos: () => {},
|
||||
onOpenMainExecutionLog: () => {},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
GitCompareIcon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListTodoIcon,
|
||||
PanelLeftIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightIcon,
|
||||
@@ -53,20 +52,12 @@ interface MobileSessionMenuProps {
|
||||
subagentsPanelOpen: boolean;
|
||||
/** True while the mobile shells drawer is open. */
|
||||
shellsPanelOpen: boolean;
|
||||
/** True while the mobile tasks drawer is open. */
|
||||
todosPanelOpen: boolean;
|
||||
/** Hide the Shells entry (claude-native sub-agents only). */
|
||||
hideTerminalsTab: boolean;
|
||||
/** Whether the Shells entry is available. */
|
||||
showShellsTab: boolean;
|
||||
/** Number of open terminals (entry badge). */
|
||||
terminalsLength: number;
|
||||
/** Whether the session publishes a todo list (gates the Tasks entry). */
|
||||
todosSupported: boolean;
|
||||
/** Completed todo count (Tasks entry badge numerator). */
|
||||
todosCompleted: number;
|
||||
/** Total todo count (Tasks entry badge denominator + visibility). */
|
||||
todosTotal: number;
|
||||
/** Debug mode — surfaces the Logs entry. */
|
||||
debugMode: boolean;
|
||||
/** Changed-file count (Files entry badge). */
|
||||
@@ -86,8 +77,6 @@ interface MobileSessionMenuProps {
|
||||
onOpenShells: () => void;
|
||||
/** Open the mobile agents drawer. */
|
||||
onOpenSubagents: () => void;
|
||||
/** Open the mobile tasks drawer. */
|
||||
onOpenTodos: () => void;
|
||||
/** Open the main execution-log push panel. */
|
||||
onOpenMainExecutionLog: () => void;
|
||||
}
|
||||
@@ -151,7 +140,7 @@ interface ChatHeaderProps {
|
||||
showFilesPanel: boolean;
|
||||
/**
|
||||
* Whether the right workspace rail has at least one available tab
|
||||
* (files, terminals, sub-agents, or todos). Gates the desktop
|
||||
* (files, terminals, or sub-agents). Gates the desktop
|
||||
* collapse toggle — with no rail content the panel doesn't mount
|
||||
* (see AppShell), so a toggle would flip an invisible card.
|
||||
*/
|
||||
@@ -449,7 +438,6 @@ export function ChatHeader({
|
||||
!mobileMenu.filesPanelOpen &&
|
||||
!mobileMenu.subagentsPanelOpen &&
|
||||
!mobileMenu.shellsPanelOpen &&
|
||||
!mobileMenu.todosPanelOpen &&
|
||||
(hasRailContent || mobileMenu.debugMode) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -532,18 +520,6 @@ export function ChatHeader({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{mobileMenu.todosSupported && mobileMenu.todosTotal > 0 && (
|
||||
<DropdownMenuItem
|
||||
onSelect={mobileMenu.onOpenTodos}
|
||||
className="gap-2.5 px-2.5 py-2 text-ui"
|
||||
>
|
||||
<ListTodoIcon className="size-4" />
|
||||
Tasks
|
||||
<span className={cn(TAB_BADGE_BASE, "ml-auto bg-muted text-muted-foreground")}>
|
||||
{mobileMenu.todosCompleted}/{mobileMenu.todosTotal}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{mobileMenu.debugMode && (
|
||||
<DropdownMenuItem
|
||||
onSelect={mobileMenu.onOpenMainExecutionLog}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Tests for ChatPlanAccordion — the pinned "Plan (N/M)" tracker above the
|
||||
// chat thread. We mock the store so the summary count logic and self-hide are
|
||||
// exercised in isolation; TodoPanel (the expanded list) is covered separately.
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
const h = vi.hoisted(() => ({ todos: [] as TodoItem[] }));
|
||||
vi.mock("@/store/chatStore", () => ({
|
||||
useChatStore: (selector: (s: { todos: TodoItem[] }) => unknown) => selector({ todos: h.todos }),
|
||||
}));
|
||||
|
||||
import { ChatPlanAccordion } from "./ChatPlanAccordion";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
h.todos = [];
|
||||
});
|
||||
|
||||
describe("ChatPlanAccordion", () => {
|
||||
it("renders nothing when there are no tasks", () => {
|
||||
// WHY: the accordion must occupy no space for sessions with no plan — it
|
||||
// returns null so it never shrinks the chat scroll area needlessly.
|
||||
h.todos = [];
|
||||
const { container } = render(<ChatPlanAccordion />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Plan with completed/total counts", () => {
|
||||
// WHY: the always-visible summary is the label the issue specifies —
|
||||
// "Plan (N/M)" where N is completed and M is the total task count.
|
||||
h.todos = [
|
||||
{ content: "a", status: "completed", activeForm: "a" },
|
||||
{ content: "b", status: "in_progress", activeForm: "b" },
|
||||
{ content: "c", status: "pending", activeForm: "c" },
|
||||
];
|
||||
render(<ChatPlanAccordion />);
|
||||
expect(screen.getByText("Plan")).toBeInTheDocument();
|
||||
expect(screen.getByText("(1/3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the tasks in the expandable body", () => {
|
||||
// WHY: the expanded section reuses TodoPanel, so each task's content shows.
|
||||
h.todos = [
|
||||
{ content: "Write tests", status: "pending", activeForm: "Writing tests" },
|
||||
{ content: "Ship it", status: "completed", activeForm: "Shipping it" },
|
||||
];
|
||||
render(<ChatPlanAccordion />);
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ship it")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChevronDownIcon, ListTodoIcon } from "lucide-react";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
|
||||
/**
|
||||
* Collapsible "Plan (N/M)" tracker pinned to the top of the chat thread.
|
||||
*
|
||||
* A bounded, centered card (not a full-width pane) with an accent-tinted
|
||||
* header bar: the always-visible summary shows completed/total, and expanding
|
||||
* reveals the same task list as the workspace Tasks tab (TodoPanel). A flex
|
||||
* sibling of the conversation viewport (not an overlay) so it shrinks the
|
||||
* scroll area rather than covering messages. Self-hides when the session has
|
||||
* no tasks, matching the Tasks tab's `todosSupported` gate.
|
||||
*/
|
||||
export function ChatPlanAccordion({ className }: { className?: string }) {
|
||||
const todos = useChatStore((s) => s.todos);
|
||||
if (todos.length === 0) return null;
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
|
||||
return (
|
||||
// Full-width layout row with side gutters; the card centers on the chat
|
||||
// column so it lines up with the message thread below.
|
||||
<div className={cn("shrink-0 px-3 md:px-4", className)}>
|
||||
<details
|
||||
data-testid="plan-tracker"
|
||||
className="group mx-auto max-w-3xl overflow-hidden rounded-xl border border-accent-foreground/20 bg-card shadow-sm mb-1"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none select-none items-center gap-2 bg-accent px-3 py-2 text-ui font-medium text-accent-foreground [&::-webkit-details-marker]:hidden">
|
||||
<ListTodoIcon className="size-4 shrink-0" />
|
||||
<span>Plan</span>
|
||||
<span
|
||||
className="opacity-70"
|
||||
aria-label={`${completed} of ${todos.length} tasks completed`}
|
||||
>
|
||||
({completed}/{todos.length})
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-auto size-4 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
{/* Cap the expanded list at 150px so a long plan can't swallow the
|
||||
chat; it scrolls internally past the cap. */}
|
||||
<div className="max-h-[150px] overflow-y-auto border-t border-border">
|
||||
<TodoPanel frameless />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AlertTriangleIcon,
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CloudOffIcon,
|
||||
@@ -806,6 +807,8 @@ function FileViewerBody({
|
||||
key: string;
|
||||
/** Accessible name for the inline icon button. */
|
||||
label: string;
|
||||
/** Text label for the inline icon button. */
|
||||
textLabel?: string;
|
||||
/** Tooltip + dropdown row text; falls back to `label` when omitted. */
|
||||
tooltip?: string;
|
||||
icon: ReactNode;
|
||||
@@ -876,6 +879,7 @@ function FileViewerBody({
|
||||
toolbarActions.push({
|
||||
key: "md-view-mode",
|
||||
label: `View mode: ${activeMode.label}`,
|
||||
textLabel: activeMode.label,
|
||||
tooltip: "View mode",
|
||||
icon: activeMode.icon,
|
||||
options: modeOptions,
|
||||
@@ -1091,11 +1095,13 @@ function FileViewerBody({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size={action.textLabel ? "sm" : "icon-sm"}
|
||||
aria-label={action.label}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.icon}
|
||||
{action.textLabel}
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -1107,7 +1113,7 @@ function FileViewerBody({
|
||||
{action.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.key}
|
||||
className={cn("whitespace-nowrap", option.active && "bg-muted dark:bg-muted/50")}
|
||||
className={cn("whitespace-nowrap")}
|
||||
onSelect={interactive ? option.onSelect : undefined}
|
||||
>
|
||||
{option.icon}
|
||||
|
||||
@@ -249,3 +249,100 @@ describe("ResumeWithDirectoryDialog", () => {
|
||||
expect(screen.queryByTestId("resume-dir-bind-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function renderHostless(prefill?: {
|
||||
hostId?: string | null;
|
||||
workspace?: string | null;
|
||||
gitBranch?: string | null;
|
||||
}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ResumeWithDirectoryDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
sessionId="conv_imported"
|
||||
// Host-less: no fork source.
|
||||
sourceSessionId={null}
|
||||
prefill={prefill}
|
||||
serverUrl="http://localhost:5173"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ResumeWithDirectoryDialog (host-less session)", () => {
|
||||
it("prefills from the session's own fields and binds without fetching a source", async () => {
|
||||
useHostsMock.mockReturnValue({
|
||||
data: [
|
||||
{ host_id: "host_a", name: "laptop", owner: "me", status: "online" },
|
||||
{ host_id: "host_b", name: "desktop", owner: "me", status: "online" },
|
||||
],
|
||||
} as unknown as ReturnType<typeof useHosts>);
|
||||
|
||||
// Prefer the session's recorded host when it's online; prefill the dir.
|
||||
renderHostless({ hostId: "host_b", workspace: "/Users/alice/imported" });
|
||||
|
||||
const bindBtn = await screen.findByTestId("resume-dir-bind-button");
|
||||
await waitFor(() => expect((bindBtn as HTMLButtonElement).disabled).toBe(false));
|
||||
await waitFor(() =>
|
||||
expect((screen.getByTestId("mock-workspace-input") as HTMLInputElement).value).toBe(
|
||||
"/Users/alice/imported",
|
||||
),
|
||||
);
|
||||
fireEvent.click(bindBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(launchRunnerMock).toHaveBeenCalledWith(
|
||||
"host_b",
|
||||
"conv_imported",
|
||||
"/Users/alice/imported",
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
// No fork source → the source session is never fetched.
|
||||
expect(getSessionMock).not.toHaveBeenCalled();
|
||||
// No source means no mismatch/CLI-fallback machinery.
|
||||
expect(screen.queryByTestId("resume-dir-mismatch-warning")).toBeNull();
|
||||
expect(screen.queryByTestId("resume-dir-cli-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults the host to the caller's current online machine when the recorded host is gone", async () => {
|
||||
// Recorded host offline (or absent from the list) → fall back to the
|
||||
// most-recent online machine (first in the list).
|
||||
useHostsMock.mockReturnValue({
|
||||
data: [
|
||||
{ host_id: "host_current", name: "laptop", owner: "me", status: "online" },
|
||||
{ host_id: "host_old", name: "old", owner: "me", status: "offline" },
|
||||
],
|
||||
} as unknown as ReturnType<typeof useHosts>);
|
||||
|
||||
renderHostless({ hostId: "host_old", workspace: "/repo" });
|
||||
|
||||
const bindBtn = await screen.findByTestId("resume-dir-bind-button");
|
||||
await waitFor(() => expect((bindBtn as HTMLButtonElement).disabled).toBe(false));
|
||||
fireEvent.click(bindBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(launchRunnerMock).toHaveBeenCalledWith(
|
||||
"host_current",
|
||||
"conv_imported",
|
||||
"/repo",
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the no-online-hosts message (no CLI fallback) when none are online", async () => {
|
||||
useHostsMock.mockReturnValue({
|
||||
data: [{ host_id: "host_old", name: "old", owner: "me", status: "offline" }],
|
||||
} as unknown as ReturnType<typeof useHosts>);
|
||||
|
||||
renderHostless({ workspace: "/repo" });
|
||||
|
||||
expect(await screen.findByTestId("resume-dir-no-hosts")).toBeTruthy();
|
||||
expect(screen.queryByTestId("resume-dir-cli-fallback")).toBeNull();
|
||||
expect(screen.queryByTestId("resume-dir-host-select")).toBeNull();
|
||||
expect(launchRunnerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,31 +34,34 @@ import { useRecentWorkspaces } from "@/hooks/useRecentWorkspaces";
|
||||
import { getSessionSlim, launchRunner } from "@/lib/sessionsApi";
|
||||
|
||||
/**
|
||||
* Dialog surfaced when the user tries to chat with an unbound *coding*
|
||||
* clone (a fork of a session that had a working directory — it carries
|
||||
* the ``omnigent.fork.source_id`` label). Unlike ``ResumeChatDialog``
|
||||
* (which only prints a CLI command), this binds the clone to a host +
|
||||
* directory in-app via ``POST /v1/hosts/{id}/runners`` (``launchRunner``)
|
||||
* and lets the runner start, after which ChatPage replays the queued
|
||||
* message.
|
||||
* Dialog that binds an *unbound* session to a host + directory in-app via
|
||||
* ``POST /v1/hosts/{id}/runners`` (``launchRunner``) and lets the runner
|
||||
* start, after which ChatPage replays any queued message. Unlike
|
||||
* ``ResumeChatDialog`` (which only prints a CLI command), this resumes the
|
||||
* session from the browser. Serves two unbound cases:
|
||||
*
|
||||
* The picker prefills from the *source* session (same-user CUJ 1):
|
||||
* the source's host is the default, its workspace the default directory,
|
||||
* and — when the source used a git worktree — a branch is suggested so
|
||||
* the clone diverges onto its own worktree rather than fighting the
|
||||
* original over the same files.
|
||||
* - **Fork clone** (``sourceSessionId`` set): a fork of a session that had a
|
||||
* working directory (``omnigent.fork.source_id`` label). Prefills from the
|
||||
* *source* session — its host is the default, its workspace the default
|
||||
* directory, and when it used a git worktree a branch is suggested so the
|
||||
* clone diverges onto its own worktree. When the source's host is offline
|
||||
* there's nothing to launch on, so it falls back to the CLI reconnect
|
||||
* command — the escape hatch ``ResumeChatDialog`` shows.
|
||||
*
|
||||
* When the source's host is offline there is no runner to launch, so the
|
||||
* dialog falls back to the CLI reconnect command (``omnigent connect``)
|
||||
* — the same escape hatch ``ResumeChatDialog`` shows.
|
||||
* - **Host-less session** (no ``sourceSessionId``): an imported session with
|
||||
* no host/runner of its own. Prefills from ``prefill`` (the session's own
|
||||
* recorded workspace/host) and defaults the host to the caller's current
|
||||
* online machine, so the common case is one click.
|
||||
*
|
||||
* @param open - Whether the dialog is visible.
|
||||
* @param onOpenChange - Radix-controlled visibility setter.
|
||||
* @param sessionId - The unbound clone to bind, e.g. ``"conv_clone"``.
|
||||
* @param sourceSessionId - The source the clone was forked from
|
||||
* (``omnigent.fork.source_id``); read for host/dir/branch prefill.
|
||||
* @param sessionId - The unbound session to bind, e.g. ``"conv_abc"``.
|
||||
* @param sourceSessionId - Fork source (``omnigent.fork.source_id``) read for
|
||||
* host/dir/branch prefill; ``null``/absent for a host-less session.
|
||||
* @param prefill - Defaults for the host-less case (the session's own
|
||||
* host/workspace/branch). Ignored when ``sourceSessionId`` is set.
|
||||
* @param serverUrl - Origin for the CLI fallback command.
|
||||
* @param wrapper - The clone's ``omnigent.wrapper`` label (CLI fallback).
|
||||
* @param wrapper - The session's ``omnigent.wrapper`` label (CLI fallback).
|
||||
* @param onBound - Called after a successful bind so the caller can
|
||||
* replay the message the user was trying to send.
|
||||
*/
|
||||
@@ -67,6 +70,7 @@ export function ResumeWithDirectoryDialog({
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
sourceSessionId,
|
||||
prefill,
|
||||
serverUrl,
|
||||
wrapper,
|
||||
onBound,
|
||||
@@ -74,19 +78,24 @@ export function ResumeWithDirectoryDialog({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string;
|
||||
sourceSessionId: string;
|
||||
sourceSessionId?: string | null;
|
||||
prefill?: { hostId?: string | null; workspace?: string | null; gitBranch?: string | null };
|
||||
serverUrl: string;
|
||||
wrapper?: string | null;
|
||||
onBound?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// A fork clone prefills from its source session; a host-less session has no
|
||||
// source and prefills from its own recorded fields instead.
|
||||
const hasSource = sourceSessionId != null && sourceSessionId !== "";
|
||||
|
||||
// Source session prefill (host/workspace/git_branch). Only fetch while
|
||||
// the dialog is open.
|
||||
// the dialog is open and we actually have a source.
|
||||
const { data: source, isLoading: sourceLoading } = useQuery({
|
||||
queryKey: ["session", sourceSessionId],
|
||||
queryFn: () => getSessionSlim(sourceSessionId),
|
||||
enabled: open,
|
||||
queryFn: () => getSessionSlim(sourceSessionId as string),
|
||||
enabled: open && hasSource,
|
||||
});
|
||||
const { data: hosts } = useHosts({ enabled: open });
|
||||
|
||||
@@ -98,6 +107,19 @@ export function ResumeWithDirectoryDialog({
|
||||
const sourceHostOnline = sourceHost?.status === "online";
|
||||
const onlineHosts = useMemo(() => (hosts ?? []).filter((h) => h.status === "online"), [hosts]);
|
||||
|
||||
// Unified prefill: a fork reads its source session; a host-less session
|
||||
// reads the ``prefill`` its own snapshot supplied.
|
||||
const prefillWorkspace = hasSource ? source?.workspace : prefill?.workspace;
|
||||
const prefillBranch = hasSource ? source?.gitBranch : prefill?.gitBranch;
|
||||
// Default host: the source's host (fork, only if online — otherwise the CLI
|
||||
// fallback fires) or, for a host-less session, its own recorded host when
|
||||
// still online, else the caller's current (most-recent) online machine.
|
||||
const defaultHostId = useMemo(() => {
|
||||
if (hasSource) return sourceHostOnline ? sourceHostId : null;
|
||||
const preferred = onlineHosts.find((h) => h.host_id === prefill?.hostId);
|
||||
return (preferred ?? onlineHosts[0])?.host_id ?? null;
|
||||
}, [hasSource, sourceHostOnline, sourceHostId, onlineHosts, prefill?.hostId]);
|
||||
|
||||
const [selectedHostId, setSelectedHostId] = useState<string | null>(null);
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [branchName, setBranchName] = useState("");
|
||||
@@ -109,27 +131,27 @@ export function ResumeWithDirectoryDialog({
|
||||
|
||||
const { recent, addRecent } = useRecentWorkspaces(selectedHostId);
|
||||
|
||||
// Prefill host = source host (when it is online) once both load.
|
||||
// Prefill the host selection once the hosts (and source, if any) load.
|
||||
useEffect(() => {
|
||||
if (open && selectedHostId === null && sourceHostId && sourceHostOnline) {
|
||||
setSelectedHostId(sourceHostId);
|
||||
if (open && selectedHostId === null && defaultHostId) {
|
||||
setSelectedHostId(defaultHostId);
|
||||
}
|
||||
}, [open, selectedHostId, sourceHostId, sourceHostOnline]);
|
||||
}, [open, selectedHostId, defaultHostId]);
|
||||
|
||||
// Prefill the directory with the source's workspace.
|
||||
// Prefill the directory with the session's recorded workspace.
|
||||
useEffect(() => {
|
||||
if (open && workspace === "" && source?.workspace) {
|
||||
setWorkspace(source.workspace);
|
||||
if (open && workspace === "" && prefillWorkspace) {
|
||||
setWorkspace(prefillWorkspace);
|
||||
}
|
||||
}, [open, workspace, source?.workspace]);
|
||||
}, [open, workspace, prefillWorkspace]);
|
||||
|
||||
// When the source used a worktree, default the base ref to that branch
|
||||
// so the clone branches off where the original left work.
|
||||
useEffect(() => {
|
||||
if (open && baseBranch === "" && source?.gitBranch) {
|
||||
setBaseBranch(source.gitBranch);
|
||||
if (open && baseBranch === "" && prefillBranch) {
|
||||
setBaseBranch(prefillBranch);
|
||||
}
|
||||
}, [open, baseBranch, source?.gitBranch]);
|
||||
}, [open, baseBranch, prefillBranch]);
|
||||
|
||||
// Reset transient state when the dialog closes.
|
||||
function handleOpenChange(next: boolean): void {
|
||||
@@ -227,21 +249,32 @@ export function ResumeWithDirectoryDialog({
|
||||
// flashing the CLI fallback for an online source host would be wrong.
|
||||
const hostsLoaded = hosts !== undefined;
|
||||
const showCliFallback = !sourceLoading && hostsLoaded && source != null && !sourceHostOnline;
|
||||
const loading = (hasSource && sourceLoading) || !hostsLoaded;
|
||||
// Host-less sessions have no source host to reconnect, so there's no CLI
|
||||
// fallback: when the caller owns no online machine, say so plainly.
|
||||
const noOnlineHosts = !hasSource && hostsLoaded && onlineHosts.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent data-testid="resume-dir-dialog" className="flex flex-col gap-4 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Resume this session</DialogTitle>
|
||||
<DialogTitle>{hasSource ? "Resume this session" : "Resume on a machine"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This clone hasn't picked a working directory yet. Choose a host and directory to
|
||||
continue the conversation against your files.
|
||||
{hasSource
|
||||
? "This clone hasn't picked a working directory yet. Choose a host and directory to continue the conversation against your files."
|
||||
: "This session isn't running on any machine. Pick one of your machines and a directory to continue the conversation against your files."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{sourceLoading || !hostsLoaded ? (
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="resume-dir-loading">
|
||||
Loading the original session's directory…
|
||||
{hasSource ? "Loading the original session's directory…" : "Loading your machines…"}
|
||||
</p>
|
||||
) : noOnlineHosts ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="resume-dir-no-hosts">
|
||||
None of your machines are online. Start one with{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono">omnigent host</code> from your
|
||||
terminal, then reopen this dialog.
|
||||
</p>
|
||||
) : showCliFallback ? (
|
||||
<div className="flex flex-col gap-2" data-testid="resume-dir-cli-fallback">
|
||||
|
||||
@@ -31,9 +31,6 @@ vi.mock("./InlineTerminalsSection", () => ({
|
||||
vi.mock("./SubagentsPanel", () => ({
|
||||
SubagentsPanel: () => <div data-testid="subagents-stub" />,
|
||||
}));
|
||||
vi.mock("./TodoPanel", () => ({
|
||||
TodoPanel: () => <div data-testid="todos-stub" />,
|
||||
}));
|
||||
vi.mock("@/components/BrowserPane/BrowserPane", () => ({
|
||||
BrowserPane: ({ conversationId }: { conversationId: string }) => (
|
||||
<div data-testid="browser-pane-stub">{conversationId}</div>
|
||||
@@ -115,9 +112,6 @@ function renderWorkspace(
|
||||
terminalsLength={0}
|
||||
subagentsWorking={0}
|
||||
agentCount={1}
|
||||
todosSupported={false}
|
||||
todosCompleted={0}
|
||||
todosTotal={0}
|
||||
rootSessionId={null}
|
||||
selectedFilePath={overrides.selectedFilePath ?? null}
|
||||
openFiles={overrides.openFiles ?? []}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
FolderTreeIcon,
|
||||
FileDiffIcon,
|
||||
GlobeIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
MaximizeIcon,
|
||||
MinimizeIcon,
|
||||
@@ -40,7 +39,6 @@ import { FileViewer } from "./FileViewer";
|
||||
import type { ChangedSort } from "./FlatFileList";
|
||||
import { InlineTerminalsSection } from "./InlineTerminalsSection";
|
||||
import { SubagentsPanel } from "./SubagentsPanel";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
import { useTerminalStatuses } from "./useTerminalStatuses";
|
||||
import { type RightRailTab, TAB_BADGE_BASE } from "./railTabs";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -262,7 +260,7 @@ function NewTabMenu({
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileTabsStrip — open file tabs rendered in the top rail tab strip, as peers
|
||||
// of the fixed Files/Terminals/Agents/Tasks tabs. Each tab is a cell with the
|
||||
// of the fixed Files/Terminals/Agents tabs. Each tab is a cell with the
|
||||
// file's basename and an "x" close button. Clicking the cell activates the
|
||||
// tab (opening its viewer); clicking the x closes it. No own scroll container
|
||||
// or flex-1: the parent strip's overflow-x-auto scrolls the whole row.
|
||||
@@ -544,12 +542,6 @@ interface WorkspacePanelProps {
|
||||
* badge denominator) — starts at 1 for a lone agent.
|
||||
*/
|
||||
agentCount: number;
|
||||
/** Whether the session publishes a todo list (gates the Tasks tab). */
|
||||
todosSupported: boolean;
|
||||
/** Number of completed todos (Tasks tab badge numerator). */
|
||||
todosCompleted: number;
|
||||
/** Total todo count (Tasks tab badge denominator + visibility gate). */
|
||||
todosTotal: number;
|
||||
/**
|
||||
* The "root" session id for the Agents tab — the active session's
|
||||
* parent when inside a child, else the active id. May be null while
|
||||
@@ -603,7 +595,7 @@ interface WorkspacePanelProps {
|
||||
* WorkspacePanel — the desktop right "Workspace" rail, rendered as a
|
||||
* floating card (bg-card, rounded, bordered, shadowed) sitting below the
|
||||
* full-width chat header band. Internally tabbed between Files, Changes,
|
||||
* Terminals, Agents and Tasks so each can claim the full rail height
|
||||
* Terminals and Agents so each can claim the full rail height
|
||||
* instead of competing for a vertically-split slot.
|
||||
*
|
||||
* Desktop-only (``hidden md:flex``): on mobile the rail's contents are
|
||||
@@ -628,9 +620,6 @@ export function WorkspacePanel({
|
||||
terminalsLength,
|
||||
subagentsWorking,
|
||||
agentCount,
|
||||
todosSupported,
|
||||
todosCompleted,
|
||||
todosTotal,
|
||||
rootSessionId,
|
||||
selectedFilePath,
|
||||
openFiles,
|
||||
@@ -702,7 +691,7 @@ export function WorkspacePanel({
|
||||
className="absolute inset-y-0 left-0 z-10 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors"
|
||||
/>
|
||||
)}
|
||||
{/* Tab strip, in display order Files · Changes · Agents · Shells · Tasks.
|
||||
{/* Tab strip, in display order Files · Changes · Agents · Shells.
|
||||
Files (full folder tree) and Changes (changed-files-only list) are
|
||||
two peer tabs — same gate (an on-disk workspace), same FilesPanel,
|
||||
each pinned to one scope. Agents is always present (the Agents panel
|
||||
@@ -796,21 +785,6 @@ export function WorkspacePanel({
|
||||
</TabsTrigger>
|
||||
</WorkspaceTabTooltip>
|
||||
)}
|
||||
{todosSupported && todosTotal > 0 && (
|
||||
<WorkspaceTabTooltip label="Tasks">
|
||||
<TabsTrigger
|
||||
value="todos"
|
||||
aria-label={`Tasks ${todosCompleted} of ${todosTotal} completed`}
|
||||
className="size-6 shrink-0 p-0 hover:border-1 hover:border-muted rounded-md!"
|
||||
>
|
||||
<ListTodoIcon />
|
||||
<span className="sr-only">Tasks</span>
|
||||
<span className="sr-only">
|
||||
{todosCompleted}/{todosTotal}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</WorkspaceTabTooltip>
|
||||
)}
|
||||
{showBrowserTab && (
|
||||
<WorkspaceTabTooltip label="Browser">
|
||||
<TabsTrigger
|
||||
@@ -930,8 +904,6 @@ export function WorkspacePanel({
|
||||
<BrowserPane conversationId={conversationId} className="min-h-0 flex-1" />
|
||||
) : rightRailTab === "subagents" && rootSessionId ? (
|
||||
<SubagentsPanel conversationId={conversationId} rootSessionId={rootSessionId} />
|
||||
) : rightRailTab === "todos" && todosSupported ? (
|
||||
<TodoPanel frameless />
|
||||
) : rightRailTab === "terminals" && showShellsTab ? (
|
||||
<InlineTerminalsSection conversationId={conversationId} onExpand={openTerminalTab} />
|
||||
) : (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
/** The selectable tabs in the right workspace rail, in display order. */
|
||||
export type RightRailTab = "files" | "changes" | "subagents" | "terminals" | "todos" | "browser";
|
||||
export type RightRailTab = "files" | "changes" | "subagents" | "terminals" | "browser";
|
||||
|
||||
/**
|
||||
* Count/status badge geometry. Fixed height with min-width == height keeps a
|
||||
|
||||
Reference in New Issue
Block a user