mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 07:42:11 +00:00
feat(open-swe): let any org member post to a thread, with attribution (#1594)
* feat(dashboard): let any org member post to a thread, with attribution Posting to an Agents chat thread from the web UI was restricted to the thread owner. Open it to any authenticated org member (login is already org-gated by OAuth) on both write paths — the queued follow-up (send_dashboard_message) and the idle-thread run.start (_enrich_run_start_command). Non-owner messages are prefixed with the poster's verified GitHub login (@login:) so the agent and owner can tell who sent them. Thread management (cancel/delete/resolve) stays owner-only, and the UI now shows the composer to non-owners. * fix(dashboard): keep non-run.start commands owner-only Non-owner posting is allowed only via the attributed run.start path. Other write commands (e.g. input.respond) carry unattributed user input, so the commands proxy keeps them owner-only instead of readable-by-any-org-member. * docs(e2e): drop per-test details from the E2E README
This commit is contained in:
parent
450a25d33f
commit
6117780982
5 changed files with 279 additions and 92 deletions
|
|
@ -248,6 +248,18 @@ def _assert_thread_owner(metadata: dict[str, Any], login: str, email: str | None
|
||||||
raise HTTPException(404, "thread not found")
|
raise HTTPException(404, "thread not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _attribution_prefix(metadata: dict[str, Any], login: str, email: str | None) -> str:
|
||||||
|
"""Attribution prefix for a message; empty when the poster owns the thread.
|
||||||
|
|
||||||
|
Teammates can post into any surfaced-source thread (read access is already
|
||||||
|
org-gated). Their messages are tagged with the verified session login so the
|
||||||
|
agent and the thread owner can tell who sent them.
|
||||||
|
"""
|
||||||
|
if _user_owns_thread(metadata, login, email):
|
||||||
|
return ""
|
||||||
|
return f"@{login}: "
|
||||||
|
|
||||||
|
|
||||||
def _thread_is_readable(metadata: dict[str, Any]) -> bool:
|
def _thread_is_readable(metadata: dict[str, Any]) -> bool:
|
||||||
"""Any surfaced-source thread is readable by authenticated users.
|
"""Any surfaced-source thread is readable by authenticated users.
|
||||||
|
|
||||||
|
|
@ -1006,6 +1018,28 @@ def _command_message_content(params: dict[str, Any]) -> Any:
|
||||||
return last.get("content") if isinstance(last, dict) else None
|
return last.get("content") if isinstance(last, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_command_last_message_content(params: dict[str, Any], content: Any) -> None:
|
||||||
|
run_input = params.get("input")
|
||||||
|
if not isinstance(run_input, dict):
|
||||||
|
return
|
||||||
|
messages = run_input.get("messages")
|
||||||
|
if not isinstance(messages, list) or not messages:
|
||||||
|
return
|
||||||
|
last = messages[-1]
|
||||||
|
if isinstance(last, dict):
|
||||||
|
last["content"] = content
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_message_content(content: Any, prefix: str) -> Any:
|
||||||
|
if not prefix:
|
||||||
|
return content
|
||||||
|
if isinstance(content, str):
|
||||||
|
return f"{prefix}{content}"
|
||||||
|
if isinstance(content, list):
|
||||||
|
return [{"type": "text", "text": prefix.rstrip()}, *content]
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
def _command_prompt_text(content: Any) -> str:
|
def _command_prompt_text(content: Any) -> str:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return content.strip()
|
return content.strip()
|
||||||
|
|
@ -1062,6 +1096,7 @@ async def _enrich_run_start_command(
|
||||||
metadata: dict[str, Any],
|
metadata: dict[str, Any],
|
||||||
thread_busy: bool = False,
|
thread_busy: bool = False,
|
||||||
creating: bool = False,
|
creating: bool = False,
|
||||||
|
email: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if command.get("method") != "run.start":
|
if command.get("method") != "run.start":
|
||||||
return command
|
return command
|
||||||
|
|
@ -1114,6 +1149,9 @@ async def _enrich_run_start_command(
|
||||||
overrides["agent_effort"] = chosen_effort
|
overrides["agent_effort"] = chosen_effort
|
||||||
else:
|
else:
|
||||||
_validate_command_images(content, model_id=chosen_model or _metadata_model_id(metadata))
|
_validate_command_images(content, model_id=chosen_model or _metadata_model_id(metadata))
|
||||||
|
prefix = _attribution_prefix(metadata, login, email)
|
||||||
|
if prefix:
|
||||||
|
_set_command_last_message_content(params, _prefix_message_content(content, prefix))
|
||||||
metadata_update: dict[str, Any] = {}
|
metadata_update: dict[str, Any] = {}
|
||||||
if chosen_model and chosen_effort:
|
if chosen_model and chosen_effort:
|
||||||
overrides["agent_model_id"] = chosen_model
|
overrides["agent_model_id"] = chosen_model
|
||||||
|
|
@ -1159,9 +1197,9 @@ async def send_dashboard_message(
|
||||||
raise HTTPException(404, "thread not found") from exc
|
raise HTTPException(404, "thread not found") from exc
|
||||||
|
|
||||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
_assert_thread_owner(metadata, login, email)
|
_assert_thread_readable(metadata)
|
||||||
|
|
||||||
prompt = body.content.strip()
|
prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}"
|
||||||
now_ms = _now_ms()
|
now_ms = _now_ms()
|
||||||
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
|
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
|
||||||
metadata_update: dict[str, Any] = {"source": _DASHBOARD_SOURCE, "updated_at_ms": now_ms}
|
metadata_update: dict[str, Any] = {"source": _DASHBOARD_SOURCE, "updated_at_ms": now_ms}
|
||||||
|
|
@ -1426,9 +1464,11 @@ async def proxy_dashboard_thread_commands(
|
||||||
# The dashboard mints the thread id client-side and submits straight away,
|
# The dashboard mints the thread id client-side and submits straight away,
|
||||||
# so the very first ``run.start`` may target a thread that doesn't exist
|
# so the very first ``run.start`` may target a thread that doesn't exist
|
||||||
# yet. That command lazily creates + stamps + owns the thread (in
|
# yet. That command lazily creates + stamps + owns the thread (in
|
||||||
# ``_enrich_run_start_command``); any other command against a missing
|
# ``_enrich_run_start_command``); any other command against a missing thread
|
||||||
# thread — or a command from a non-owner against an existing thread — is a
|
# is a 404. On an existing thread, ``run.start`` (the posting path) is open
|
||||||
# 404.
|
# to any org member and attributed in ``_enrich_run_start_command``; every
|
||||||
|
# other write command carries unattributed input (e.g. ``input.respond``),
|
||||||
|
# so it stays owner-only.
|
||||||
method = parsed.get("method")
|
method = parsed.get("method")
|
||||||
try:
|
try:
|
||||||
thread = await langgraph_client().threads.get(thread_id)
|
thread = await langgraph_client().threads.get(thread_id)
|
||||||
|
|
@ -1444,7 +1484,10 @@ async def proxy_dashboard_thread_commands(
|
||||||
thread_busy = False
|
thread_busy = False
|
||||||
else:
|
else:
|
||||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
_assert_thread_owner(metadata, login, email)
|
if method == "run.start":
|
||||||
|
_assert_thread_readable(metadata)
|
||||||
|
else:
|
||||||
|
_assert_thread_owner(metadata, login, email)
|
||||||
metadata_run_status = metadata.get("latest_run_status")
|
metadata_run_status = metadata.get("latest_run_status")
|
||||||
thread_busy = _thread_is_busy(thread) or metadata_run_status in {"pending", "running"}
|
thread_busy = _thread_is_busy(thread) or metadata_run_status in {"pending", "running"}
|
||||||
|
|
||||||
|
|
@ -1458,6 +1501,7 @@ async def proxy_dashboard_thread_commands(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
thread_busy=thread_busy,
|
thread_busy=thread_busy,
|
||||||
creating=creating,
|
creating=creating,
|
||||||
|
email=email,
|
||||||
)
|
)
|
||||||
outgoing = json.dumps(enriched).encode()
|
outgoing = json.dumps(enriched).encode()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,29 +42,15 @@ so what Playwright asserts on is exactly what the real agent produced.
|
||||||
- `static/{slack,github}.html` — the mock Slack/GitHub UIs (external SaaS we can't
|
- `static/{slack,github}.html` — the mock Slack/GitHub UIs (external SaaS we can't
|
||||||
run locally). The dashboard is **not** mocked — it's the real `ui/` app.
|
run locally). The dashboard is **not** mocked — it's the real `ui/` app.
|
||||||
- `global-setup.ts` — builds the real `ui/` SPA (once) so the harness can serve it.
|
- `global-setup.ts` — builds the real `ui/` SPA (once) so the harness can serve it.
|
||||||
- `tests/full_flow.spec.ts` — Slack → implement → PR → reply.
|
|
||||||
- `tests/dashboard.spec.ts` — the Slack → web handoff (below).
|
|
||||||
|
|
||||||
## Slack → web handoff (dashboard.spec.ts) — the REAL ui/ app
|
## The dashboard — the real `ui/` app
|
||||||
|
|
||||||
After the Slack run, the bot posts an "Open in Web" link
|
The dashboard is **not** mocked. The bot's "Open in Web" link
|
||||||
(`DASHBOARD_BASE_URL/agents/{thread_id}`). The test clicks that real link, which
|
(`DASHBOARD_BASE_URL/agents/{thread_id}`) loads the **actual built `ui/` React
|
||||||
loads the **actual built `ui/` React app** — served same-origin from the harness
|
app** — served same-origin from the harness so the session cookie and
|
||||||
so the session cookie and `/dashboard/api/*` calls work without CORS. The signed
|
`/dashboard/api/*` calls work without CORS. The signed session cookie is real
|
||||||
session cookie is real (minted via `/control/login`), so the per-user
|
(minted via `/control/login`), so per-user authorization is genuine; the only
|
||||||
authorization is genuine:
|
extra fake is the OAuth-token store (an external credential).
|
||||||
|
|
||||||
- **Same user** (session email = the Slack triggerer = thread owner): the real
|
|
||||||
`AgentThreadView` shows the transcript (incl. the PR link), the `AgentPromptBar`
|
|
||||||
composer is present, and submitting a follow-up streams a new agent reply into
|
|
||||||
the same thread.
|
|
||||||
- **Different user** (any other org login): the same transcript renders, but the
|
|
||||||
real UI shows **no composer** (`AgentThreadView` gates it on `thread.isOwner`).
|
|
||||||
|
|
||||||
Ownership is by `github_login` / `triggering_user_email` on the thread metadata;
|
|
||||||
`GET /dashboard/api/threads/{id}` returns `isOwner`, which the real UI uses to
|
|
||||||
gate the composer. The only extra fake here is the OAuth-token store (an external
|
|
||||||
credential); the authorization logic itself is real.
|
|
||||||
|
|
||||||
The UI is built by `global-setup.ts` with `VITE_DASHBOARD_API_BASE_URL` pointed at
|
The UI is built by `global-setup.ts` with `VITE_DASHBOARD_API_BASE_URL` pointed at
|
||||||
the harness. It builds once; set `E2E_FORCE_UI_BUILD=1` to rebuild (e.g. after a
|
the harness. It builds once; set `E2E_FORCE_UI_BUILD=1` to rebuild (e.g. after a
|
||||||
|
|
|
||||||
|
|
@ -56,15 +56,29 @@ test.describe("Slack → web handoff (real dashboard UI)", () => {
|
||||||
await expect(page.getByRole("link", { name: "Add greet() helper" }).first()).toBeVisible();
|
await expect(page.getByRole("link", { name: "Add greet() helper" }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a DIFFERENT user sees the thread read-only (no composer)", async ({ page }) => {
|
test("a DIFFERENT user can post, and their message is attributed", async ({ page }) => {
|
||||||
await loginAs(page, OTHER_USER);
|
await loginAs(page, OTHER_USER);
|
||||||
await openThreadViaSlackLink(page);
|
await openThreadViaSlackLink(page);
|
||||||
|
|
||||||
// The same thread + transcript is visible…
|
// The same thread + transcript is visible…
|
||||||
await expectTranscriptVisible(page);
|
await expectTranscriptVisible(page);
|
||||||
// …but a non-owner gets no composer.
|
|
||||||
await expect(page.getByPlaceholder("Add a follow up")).toHaveCount(0);
|
// …and a non-owner now gets a composer too (owner-only restriction removed).
|
||||||
await expect(page.getByPlaceholder("Send the first message")).toHaveCount(0);
|
const composer = page.getByPlaceholder(/Add a follow up|Send the first message/);
|
||||||
await expect(page.getByLabel("Send message")).toHaveCount(0);
|
await expect(composer).toBeVisible();
|
||||||
|
|
||||||
|
// Posting starts a new run — the agent's follow-up reply streams in.
|
||||||
|
await composer.fill("Can you also add a docstring?");
|
||||||
|
await composer.press("Enter");
|
||||||
|
await expect(page.getByText(/anything else you'd like changed/)).toBeVisible();
|
||||||
|
|
||||||
|
// The non-owner's message is tagged server-side with their GitHub login, so
|
||||||
|
// the owner can tell who sent it. Visible once the transcript re-hydrates.
|
||||||
|
await expect(async () => {
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByText(new RegExp(`@${OTHER_USER.login}`)).first(),
|
||||||
|
).toBeVisible({ timeout: 8000 });
|
||||||
|
}).toPass({ timeout: 60000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -324,26 +324,83 @@ async def test_proxy_commands_lazily_creates_missing_thread_only_for_run_start(
|
||||||
assert exc_info.value.status_code == 404
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_proxy_commands_run_start_by_non_owner_is_rejected(monkeypatch) -> None:
|
async def test_enrich_run_start_command_attributes_non_owner_message(monkeypatch) -> None:
|
||||||
class OwnedThreads:
|
class FakeThreads:
|
||||||
async def get(self, thread_id: str) -> dict[str, object]:
|
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||||
return {
|
pass
|
||||||
"thread_id": thread_id,
|
|
||||||
"metadata": {"source": "dashboard", "github_login": "owner"},
|
|
||||||
}
|
|
||||||
|
|
||||||
class OwnedClient:
|
class FakeClient:
|
||||||
threads = OwnedThreads()
|
threads = FakeThreads()
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: OwnedClient())
|
async def fake_get_profile(login: str) -> dict[str, object]:
|
||||||
|
return {}
|
||||||
|
|
||||||
# An existing thread owned by someone else is never lazily re-created — a
|
async def fake_ensure_token(login: str) -> None:
|
||||||
# run.start from a non-owner is a 404, not a takeover.
|
pass
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
await thread_api.proxy_dashboard_thread_commands(
|
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
|
||||||
"tid", "intruder", b'{"method": "run.start"}'
|
return f"{login}@example.com"
|
||||||
)
|
|
||||||
assert exc_info.value.status_code == 404
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||||
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||||
|
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||||
|
|
||||||
|
command = {
|
||||||
|
"method": "run.start",
|
||||||
|
"params": {"input": {"messages": [{"role": "user", "content": "fix the bug"}]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched = await thread_api._enrich_run_start_command(
|
||||||
|
"tid",
|
||||||
|
"teammate",
|
||||||
|
command,
|
||||||
|
metadata={"source": "dashboard", "github_login": "owner"},
|
||||||
|
email="teammate@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A non-owner's message is forwarded but tagged with their login.
|
||||||
|
last = enriched["params"]["input"]["messages"][-1]
|
||||||
|
assert last["content"] == "@teammate: fix the bug"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_run_start_command_does_not_attribute_owner_message(monkeypatch) -> None:
|
||||||
|
class FakeThreads:
|
||||||
|
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
async def fake_get_profile(login: str) -> dict[str, object]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def fake_ensure_token(login: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
|
||||||
|
return f"{login}@example.com"
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||||
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||||
|
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||||
|
|
||||||
|
command = {
|
||||||
|
"method": "run.start",
|
||||||
|
"params": {"input": {"messages": [{"role": "user", "content": "fix the bug"}]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched = await thread_api._enrich_run_start_command(
|
||||||
|
"tid",
|
||||||
|
"owner",
|
||||||
|
command,
|
||||||
|
metadata={"source": "dashboard", "github_login": "owner"},
|
||||||
|
email="owner@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
last = enriched["params"]["input"]["messages"][-1]
|
||||||
|
assert last["content"] == "fix the bug"
|
||||||
|
|
||||||
|
|
||||||
async def test_enrich_run_start_command_allowlists_client_configurable(monkeypatch) -> None:
|
async def test_enrich_run_start_command_allowlists_client_configurable(monkeypatch) -> None:
|
||||||
|
|
@ -431,8 +488,31 @@ async def test_proxy_commands_rejects_non_object_body(monkeypatch) -> None:
|
||||||
assert exc_info.value.status_code == 400
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
async def test_proxy_endpoints_enforce_thread_ownership(monkeypatch) -> None:
|
async def test_proxy_commands_non_run_start_by_non_owner_is_rejected(monkeypatch) -> None:
|
||||||
"""Write endpoints (commands, run_cancel) still require thread ownership."""
|
"""Non-owners may only post via the attributed run.start path; other write
|
||||||
|
commands (e.g. input.respond) carry unattributed input and stay owner-only."""
|
||||||
|
|
||||||
|
class OwnedThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"metadata": {"source": "dashboard", "github_login": "owner"},
|
||||||
|
}
|
||||||
|
|
||||||
|
class OwnedClient:
|
||||||
|
threads = OwnedThreads()
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: OwnedClient())
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.proxy_dashboard_thread_commands(
|
||||||
|
"tid", "intruder", b'{"method": "input.respond"}'
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_cancel_enforces_thread_ownership(monkeypatch) -> None:
|
||||||
|
"""Cancelling a run still requires thread ownership (it is not "posting")."""
|
||||||
|
|
||||||
class FakeThreads:
|
class FakeThreads:
|
||||||
async def get(self, thread_id: str) -> dict[str, object]:
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
|
@ -447,10 +527,6 @@ async def test_proxy_endpoints_enforce_thread_ownership(monkeypatch) -> None:
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
await thread_api.proxy_dashboard_thread_commands("tid", "intruder", b"{}")
|
|
||||||
assert exc_info.value.status_code == 404
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await thread_api.proxy_dashboard_thread_run_cancel("tid", "run-1", "intruder")
|
await thread_api.proxy_dashboard_thread_run_cancel("tid", "run-1", "intruder")
|
||||||
assert exc_info.value.status_code == 404
|
assert exc_info.value.status_code == 404
|
||||||
|
|
@ -559,6 +635,78 @@ async def test_send_dashboard_message_returns_502_when_activity_unknown(monkeypa
|
||||||
assert exc_info.value.status_code == 502
|
assert exc_info.value.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_dashboard_message_attributes_non_owner(monkeypatch) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class FakeThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"thread_id": "tid",
|
||||||
|
"metadata": {"source": "dashboard", "github_login": "owner"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
async def active(thread_id: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fake_queue(thread_id: str, payload: dict[str, object]) -> bool:
|
||||||
|
captured["payload"] = payload
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
monkeypatch.setattr(thread_api, "get_thread_active_status", active)
|
||||||
|
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue)
|
||||||
|
|
||||||
|
await thread_api.send_dashboard_message(
|
||||||
|
"tid",
|
||||||
|
"teammate",
|
||||||
|
thread_api.ThreadMessageBody(content="ship it"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["payload"]["text"] == "@teammate: ship it"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_dashboard_message_does_not_attribute_owner(monkeypatch) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class FakeThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"thread_id": "tid",
|
||||||
|
"metadata": {"source": "dashboard", "github_login": "owner"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
async def active(thread_id: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fake_queue(thread_id: str, payload: dict[str, object]) -> bool:
|
||||||
|
captured["payload"] = payload
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
monkeypatch.setattr(thread_api, "get_thread_active_status", active)
|
||||||
|
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue)
|
||||||
|
|
||||||
|
await thread_api.send_dashboard_message(
|
||||||
|
"tid",
|
||||||
|
"owner",
|
||||||
|
thread_api.ThreadMessageBody(content="ship it"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["payload"]["text"] == "ship it"
|
||||||
|
|
||||||
|
|
||||||
def test_thread_summary_exposes_resolved_state() -> None:
|
def test_thread_summary_exposes_resolved_state() -> None:
|
||||||
summary = thread_api._thread_summary(
|
summary = thread_api._thread_summary(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
const sendMessage = useSubmitAgentMessage(thread.id)
|
const sendMessage = useSubmitAgentMessage(thread.id)
|
||||||
const stream = useAgentThreadStream()
|
const stream = useAgentThreadStream()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const isReadOnly = thread.isOwner === false
|
|
||||||
|
|
||||||
const { models, defaultSelection } = useModelOptions()
|
const { models, defaultSelection } = useModelOptions()
|
||||||
const threadSelection = useMemo<ModelSelection | null>(() => {
|
const threadSelection = useMemo<ModelSelection | null>(() => {
|
||||||
|
|
@ -78,42 +77,10 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
settingUpSandbox={settingUpSandbox}
|
settingUpSandbox={settingUpSandbox}
|
||||||
contentWidthClass="max-w-3xl"
|
contentWidthClass="max-w-3xl"
|
||||||
/>
|
/>
|
||||||
{!isReadOnly && (
|
<div className="shrink-0 px-4 pb-4">
|
||||||
<div className="shrink-0 px-4 pb-4">
|
<div className="mx-auto w-full min-w-0 max-w-3xl">
|
||||||
<div className="mx-auto w-full min-w-0 max-w-3xl">
|
|
||||||
<AgentPromptBar
|
|
||||||
placeholder="Add a follow up"
|
|
||||||
compact
|
|
||||||
busy={isStreaming}
|
|
||||||
onSubmit={(content, images) =>
|
|
||||||
sendMessage.mutateAsync({
|
|
||||||
content,
|
|
||||||
images,
|
|
||||||
model_id: activeSelection?.modelId ?? null,
|
|
||||||
effort: activeSelection?.effort ?? null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
models={models}
|
|
||||||
selection={activeSelection}
|
|
||||||
onSelectionChange={setSelection}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : isHydrating ? (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
|
||||||
<p className="text-xs text-[var(--ui-text-dim)]">Loading conversation…</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
|
||||||
<p className="text-xs text-[var(--ui-text-dim)]">
|
|
||||||
This thread has no messages yet.
|
|
||||||
</p>
|
|
||||||
{!isReadOnly && (
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<AgentPromptBar
|
<AgentPromptBar
|
||||||
placeholder="Send the first message"
|
placeholder="Add a follow up"
|
||||||
compact
|
compact
|
||||||
busy={isStreaming}
|
busy={isStreaming}
|
||||||
onSubmit={(content, images) =>
|
onSubmit={(content, images) =>
|
||||||
|
|
@ -129,7 +96,35 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
onSelectionChange={setSelection}
|
onSelectionChange={setSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isHydrating ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
||||||
|
<p className="text-xs text-[var(--ui-text-dim)]">Loading conversation…</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
||||||
|
<p className="text-xs text-[var(--ui-text-dim)]">
|
||||||
|
This thread has no messages yet.
|
||||||
|
</p>
|
||||||
|
<div className="w-full max-w-3xl">
|
||||||
|
<AgentPromptBar
|
||||||
|
placeholder="Send the first message"
|
||||||
|
compact
|
||||||
|
busy={isStreaming}
|
||||||
|
onSubmit={(content, images) =>
|
||||||
|
sendMessage.mutateAsync({
|
||||||
|
content,
|
||||||
|
images,
|
||||||
|
model_id: activeSelection?.modelId ?? null,
|
||||||
|
effort: activeSelection?.effort ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
models={models}
|
||||||
|
selection={activeSelection}
|
||||||
|
onSelectionChange={setSelection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue