Skip to content

feat(tui_gateway): client-identity source so desktop/dashboard/mobile aren't all "tui" - #175

Merged
Kyzcreig merged 4 commits into
mainfrom
feat/ws-client-identity
Jul 2, 2026
Merged

Kyzcreig merged 4 commits into
mainfrom
feat/ws-client-identity

Conversation

@Kyzcreig

@Kyzcreig Kyzcreig commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

The desktop app, the in-browser dashboard chat, and the Ink stdio TUI all drive the same tui_gateway JSON-RPC server, which hardcoded platform="tui" on every agent it built. So every desktop-app turn records platform="tui" in blackbox (turns.platform) — the source dimension on the token/cost dashboard can't tell the desktop client from the terminal, and a future mobile app would fold in too.

Verified against a live install: api_server/desktop never appear in any blackbox DB; desktop chat rides /api/ws → tui_gateway.dispatch (same path as Ink over stdio), so it inherits the "tui" label.

Fix

  • A client self-declares a source on session.create/session.resume.
  • Thread it through _make_agent → AIAgent(platform=...) (which already feeds the blackbox on_session_end hook) and store it on the session dict so the deferred/lazy build, /new reset, resume, and branch paths all preserve it (they previously reset to "tui").
  • source is untrusted client input feeding a persisted DB dimension, so it's sanitized to a conservative slug (^[a-z][a-z0-9_-]{0,23}$) with a tui fallback. The label stays open-ended (new clients name themselves; no central enum — per the existing "fixed set of platform names goes stale" note in this file).
  • Desktop app: stamp source:'desktop' once, centrally, in the requestGateway wrapper for the session-originating methods — never overriding an explicit caller source (e.g. the dashboard sidecar's 'tool').

No schema/blackbox change (the platform pipe already existed). Stdio Ink and internal rebuilds keep the historical "tui" default, so nothing regresses. Cache-safe: the label is fixed per session, set before agent creation, never mutated mid-conversation.

No backfill possible: historical desktop turns are indistinguishable from real TUI turns, so the clean split starts at deploy.

Tests

  • tests/tui_gateway/test_client_source_platform.py — sanitizer table (case/trim/reject-hostile) + _make_agent platform derivation (declared → platform; None → tui; hostile → tui).
  • apps/desktop/src/app/gateway/hooks/use-gateway-request.test.tsx — injection contract: stamps create/resume, skips other methods, never overrides an explicit source.
  • Updated test_protocol.py fakes to accept the new _make_agent(source=...) kwarg.
  • 125/125 green across every touched tui_gateway suite (deterministic run); desktop vitest 4/4; tsc --noEmit clean.

… aren't all "tui"

The desktop app, the in-browser dashboard chat, and the Ink stdio TUI all drive
the same tui_gateway JSON-RPC server, which hardcoded platform="tui" on every
agent it built. So every desktop-app turn recorded platform="tui" in blackbox
(turns.platform) — the source dimension on the token/cost dashboard couldn't
tell the desktop client from the terminal, and a future mobile app would fold
in too.

Let a client self-declare a `source` on session.create/resume; thread it
through _make_agent -> AIAgent(platform=...) (which already feeds the blackbox
on_session_end hook), and store it on the session dict so the deferred/lazy
build, /new reset, resume, and branch paths all preserve it. `source` is
untrusted client input feeding a persisted DB dimension, so it is sanitized to
a conservative slug (`^[a-z][a-z0-9_-]{0,23}$`) with a `tui` fallback — the
label stays open-ended (new clients name themselves; no central enum to keep in
sync, per the existing "fixed set of platform names goes stale" note).

Desktop app: stamp source:'desktop' once, centrally, in the requestGateway
wrapper for the session-originating methods (create/resume) — never overriding
an explicit caller source (e.g. the dashboard sidecar's 'tool').

No schema/blackbox change (the platform pipe already existed); stdio Ink and
internal rebuilds keep the historical "tui" default so nothing regresses.
Historical desktop turns are indistinguishable from real TUI turns, so the
clean split starts at deploy (no backfill possible).

Tests: tui_gateway/test_client_source_platform.py (sanitizer table + _make_agent
platform derivation); desktop use-gateway-request.test.tsx (injection contract:
stamps create/resume, skips other methods, never overrides explicit source).
@greptile-apps

greptile-apps Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR adds a source field to session.create/session.resume so that the desktop app, dashboard SPA, and Ink stdio TUI — all sharing the same tui_gateway JSON-RPC server — are individually attributed in the blackbox turns.platform dimension instead of all recording as "tui".

  • tui_gateway/server.py: Introduces _sanitize_client_source (slug regex + isinstance guard) and threads the sanitized value through _make_agent, _init_session, _reset_session_agent, deferred builds, resume, and branch handlers.
  • apps/desktop/…/use-gateway-request.ts: Stamps source:'desktop' on session-originating RPCs only, without overriding an explicit caller-supplied value.
  • Tests: New test_client_source_platform.py covers the sanitizer table (including the boolean/integer edge cases flagged in the previous review) and _make_agent platform derivation; desktop vitest covers the injection contract.

Confidence Score: 5/5

Safe to merge. The change is additive: existing sessions fall back to tui, no schema changes, and the sanitizer is conservative with proper type-guarding.

The sanitizer correctly uses isinstance(raw, str) instead of the old falsy-coercion pattern, closing the boolean edge case flagged in the prior review. All session-lifecycle paths consistently propagate the label. Tests cover the hostile inputs exhaustively.

No files require special attention.

Important Files Changed

Filename Overview
tui_gateway/server.py Core change: adds _sanitize_client_source with a proper isinstance guard, threads source through all session-lifecycle paths (create, deferred build, resume, /new reset, branch). Logic is sound and edge cases are well-covered.
apps/desktop/src/app/gateway/hooks/use-gateway-request.ts Adds CLIENT_SOURCE constant and SESSION_ORIGIN_METHODS set; injects source:'desktop' on create/resume only, preserving explicit caller values. The outbound variable is correctly shared between the primary and recovery request paths.
tests/tui_gateway/test_client_source_platform.py New test file covering the sanitizer table (including True and 0 edge cases from the previous review thread) and _make_agent platform derivation. Test coverage is thorough.
apps/desktop/src/app/gateway/hooks/use-gateway-request.test.tsx New tests for the injection contract: stamps on create/resume, skips other methods, never overrides an explicit caller source. Contract is well-pinned.
tests/tui_gateway/test_protocol.py Existing fakes updated with **_kw to accept the new source= keyword argument — mechanical, correct.
tests/test_tui_gateway_server.py Three monkeypatched _make_agent lambdas updated with **_kw — mechanical, correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client as Client (desktop/dashboard/Ink)
    participant Hook as useGatewayRequest
    participant GW as tui_gateway/server.py
    participant San as _sanitize_client_source
    participant Agent as AIAgent(platform=...)

    Client->>Hook: "session.create({ cols:96 })"
    Hook->>Hook: SESSION_ORIGIN_METHODS has method AND no explicit source?
    Hook->>GW: "session.create({ cols:96, source:"desktop" })"
    GW->>San: _sanitize_client_source("desktop")
    San-->>GW: "desktop"
    GW->>GW: "_sessions[sid]["source"] = "desktop""
    GW->>GW: _schedule_agent_build(sid)
    GW-->>Client: "{ session_id, stored_session_id }"

    Note over GW: Deferred build (lazy)
    GW->>San: _session_source(current) returns desktop
    GW->>Agent: "_make_agent(sid, key, source=desktop)"
    Agent-->>GW: "AIAgent(platform=desktop)"

    Client->>Hook: "session.resume({ session_id:s1 })"
    Hook->>GW: "session.resume({ session_id:s1, source:desktop })"
    GW->>San: _sanitize_client_source(params source or found source)
    San-->>GW: desktop
    GW->>Agent: "_make_agent with source=desktop"
    GW->>GW: "_init_session with source=desktop"

    Client->>GW: /new reset_session_agent
    GW->>San: _session_source(session) returns desktop
    GW->>Agent: "_make_agent with source=desktop"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client as Client (desktop/dashboard/Ink)
    participant Hook as useGatewayRequest
    participant GW as tui_gateway/server.py
    participant San as _sanitize_client_source
    participant Agent as AIAgent(platform=...)

    Client->>Hook: "session.create({ cols:96 })"
    Hook->>Hook: SESSION_ORIGIN_METHODS has method AND no explicit source?
    Hook->>GW: "session.create({ cols:96, source:"desktop" })"
    GW->>San: _sanitize_client_source("desktop")
    San-->>GW: "desktop"
    GW->>GW: "_sessions[sid]["source"] = "desktop""
    GW->>GW: _schedule_agent_build(sid)
    GW-->>Client: "{ session_id, stored_session_id }"

    Note over GW: Deferred build (lazy)
    GW->>San: _session_source(current) returns desktop
    GW->>Agent: "_make_agent(sid, key, source=desktop)"
    Agent-->>GW: "AIAgent(platform=desktop)"

    Client->>Hook: "session.resume({ session_id:s1 })"
    Hook->>GW: "session.resume({ session_id:s1, source:desktop })"
    GW->>San: _sanitize_client_source(params source or found source)
    San-->>GW: desktop
    GW->>Agent: "_make_agent with source=desktop"
    GW->>GW: "_init_session with source=desktop"

    Client->>GW: /new reset_session_agent
    GW->>San: _session_source(session) returns desktop
    GW->>Agent: "_make_agent with source=desktop"
Loading

Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread tui_gateway/server.py Outdated
Kyzcreig added 3 commits July 2, 2026 07:40
The new source kwarg on _make_agent tripped three narrow monkeypatch fakes in
tests/test_tui_gateway_server.py (session_id/session_db-only signatures). Widen
them to **_kw so they tolerate the new kwarg, matching the sibling fakes that
already splat. No behavior change.
_sanitize_client_source used str(raw or ''), so a JSON-RPC boolean true would
coerce to the slug 'true' instead of falling back to 'tui'. Guard with
isinstance(raw, str) up front; add True/0/[] cases to the sanitizer table.
@Kyzcreig
Kyzcreig merged commit f3efaa6 into main Jul 2, 2026
40 checks passed
@Kyzcreig
Kyzcreig deleted the feat/ws-client-identity branch July 2, 2026 14:54
Kyzcreig added a commit that referenced this pull request Jul 4, 2026
* feat(dashboard): web chat client self-declares source='dashboard'

The in-browser dashboard chat (web/), the Electron desktop app, and the Ink TUI
all drive the same tui_gateway JSON-RPC server, which stamps platform='tui' by
default. PR #175 taught the Electron app + backend to thread a client 'source'
so turns are attributed per-client (desktop/dashboard/mobile) on the token
dashboard — but the WEB dashboard client (web/src/lib/gatewayClient.ts) was
never updated, so browser-dashboard turns still recorded as 'tui'.

Override GatewayClient.request to stamp source:'dashboard' on the
session-originating methods (session.create/session.resume) when the caller
hasn't set an explicit source (the sidebar sidecar session deliberately sends
source:'tool'). Mirrors the Electron app's requestGateway injection exactly.
Backend already sanitizes + threads source -> AIAgent(platform=...) (#175); no
backend change needed.

Test: web/src/lib/gatewayClient.test.ts (4) — stamps create/resume, skips other
methods, never overrides an explicit source.

* ci: re-trigger checks (apps/desktop/node_modules ENOTDIR runner flake, not a code issue)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant