Compare commits

...

2 Commits

Author SHA1 Message Date
Tomu Hirata 12f14a675f test(configure): stub _ollama_reachable and _claude_login_detected in isolated_config
Two ambient-detection helpers read real machine state regardless of the
HOME / env-var isolation the isolated_config fixture provides:
- _ollama_reachable: TCP-probes localhost:11434; a running Ollama shifts
  harness menu option numbers, making the wizard input sequences wrong.
- _claude_login_detected: on macOS falls back to 'claude auth status'
  which reads the Keychain, so a real Claude subscription appeared even
  with HOME redirected.

Stub both to False in isolated_config so the wizard menus are
deterministic on any developer machine, fixing two pre-existing flaky
failures.
2026-06-30 18:30:36 +09:00
Tomu Hirata d7198c703d fix(cost): attribute sub-agent spend to root owner in daily rollup
Sub-agent conversations are created without a permission grant (the
internal runner POST carries no user context), so get_session_owner(conv.id)
returned None and _record_daily_cost silently dropped their spend from the
per-user daily rollup. This meant relay/SDK sub-agent costs were never
counted against the owner's daily budget, making the per-user daily
cost-budget policy ineffective for spawned agents.

Fix: when the direct owner lookup returns None and the conversation is not
its own root (i.e. it is a sub-agent), fall back to
get_session_owner(conv.root_conversation_id). Every conversation carries
root_conversation_id pointing to the top-level session that was created
with user context and always has an owner grant. This ensures sub-agent
spend is attributed to the same user as the parent.

claude-native was already unaffected because it folds Task sub-agent spend
into the parent's cumulative_cost_usd before reporting, so the parent's
own grant covers it. The gap was relay/SDK sub-agents reporting their own
cost independently on a grantless conversation.
2026-06-30 17:39:32 +09:00
3 changed files with 68 additions and 0 deletions
+14
View File
@@ -2733,6 +2733,16 @@ def _record_daily_cost(
from touching an absent ``user_daily_cost`` table is no longer needed
now that the managed store backs it.)
Sub-agent conversations are created without a permission grant (the
internal runner POST carries no user context), so
``get_session_owner(conv.id)`` returns ``None`` for them. When
that happens, fall back to the spawn-tree root's owner: every
conversation carries ``root_conversation_id`` pointing to the
top-level session that *was* created with user context and therefore
always has an owner grant. This ensures relay / SDK sub-agent spend
is attributed to the same user as the parent rather than silently
dropped from the daily rollup.
:param conv: The conversation row for the session, or ``None``
(a no-op no owner to attribute to).
:param delta_usd: The turn's cost in USD; ``<= 0`` is a no-op.
@@ -2742,6 +2752,10 @@ def _record_daily_cost(
if conv is None or delta_usd <= 0:
return
owner = conversation_store.get_session_owner(conv.id)
if owner is None and conv.root_conversation_id != conv.id:
# Sub-agent: no direct owner grant — fall back to the root session's
# owner so sub-agent spend is attributed rather than silently dropped.
owner = conversation_store.get_session_owner(conv.root_conversation_id)
if owner is None:
return
from omnigent.db.utils import now_epoch
+10
View File
@@ -91,6 +91,16 @@ def isolated_config(tmp_path, monkeypatch):
# Redirect CLI-detected credential homes so a developer's real
# ~/.claude / ~/.codex logins don't leak into ambient detection.
monkeypatch.setenv("HOME", str(tmp_path))
# Stub out the two ambient-detection helpers that read real machine
# state regardless of HOME / env-var isolation:
# - _ollama_reachable: TCP-probes localhost:11434; a running Ollama
# would otherwise add an entry to the harness menu and shift option
# numbers, making input sequences non-deterministic.
# - _claude_login_detected: on macOS falls back to `claude auth status`
# which reads the Keychain (not HOME), so a real Claude subscription
# leaks through even with HOME redirected to tmp_path.
monkeypatch.setattr("omnigent.onboarding.ambient._ollama_reachable", lambda: False)
monkeypatch.setattr("omnigent.onboarding.ambient._claude_login_detected", lambda: False)
return tmp_path
@@ -777,3 +777,47 @@ def test_daily_cost_tracks_display_cost_not_policy_cost(app: FastAPI, stores) ->
# mean the rollup tracked policy_cost_usd and inherited the gate's
# mid-turn inflation — the daily over-report this split prevents.
assert conversation_store.get_daily_cost(ALICE, today) == pytest.approx(0.20)
def test_daily_cost_attributed_via_root_for_sub_agent_without_owner_grant(
app: FastAPI, stores
) -> None:
"""Sub-agent spend is attributed to the root session's owner.
Relay / SDK sub-agents are spawned by the internal runner (no user
context in the POST), so their conversations never receive an owner
permission grant. Previously ``_record_daily_cost`` called
``get_session_owner(child.id)``, got ``None``, and silently dropped the
cost from the daily rollup — the per-user daily budget never saw it.
The fix: fall back to ``get_session_owner(root_conversation_id)`` when
the direct lookup misses. This test creates a parent (owned) + a child
conversation (no grant, but ``root_conversation_id`` → parent), posts
cumulative spend on the child, and asserts the owner's daily total rises.
"""
conversation_store, _agent_store, _permission_store = stores
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
# Parent session — owned by Alice.
parent_id = _seed_session(stores, owner=ALICE, title="parent session")
# Child conversation — simulates a relay sub-agent: no permission grant.
# Passing parent_conversation_id causes create_conversation to inherit
# root_conversation_id from the parent automatically.
child = conversation_store.create_conversation(
title="sub-agent",
agent_id="ag_test",
parent_conversation_id=parent_id,
)
# Sanity: child has no owner grant (the gap being fixed).
assert conversation_store.get_session_owner(child.id) is None
# Post cumulative cost on the child — no auth header (internal runner path).
resp = TestClient(app).post(
f"/v1/sessions/{child.id}/events",
json={"type": "external_session_usage", "data": {"cumulative_cost_usd": 0.75}},
)
assert resp.status_code == 202, resp.text
# Sub-agent spend must appear in Alice's daily rollup via the root fallback.
assert conversation_store.get_daily_cost(ALICE, today) == pytest.approx(0.75)