Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions workspace/backend/app/routers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,34 @@ class SendEventRequest(BaseModel):
# Poll-cache invalidation
# ---------------------------------------------------------------------------

def _poll_filter_hash(
workspace_id: str,
target: str = "",
channel: str = "",
event_type: str = "",
conversation: str = "",
sort: str = "asc",
limit=50,
exclude_message_types: str = "",
) -> str:
"""Canonical per-filter hash, shared by poll_events (head/at-head key
construction) and _poll_cache_keys_for (invalidation) so the two can
never drift apart.

exclude_message_types joins the hash only when set — filters without it
keep the legacy 7-field hash, which the enumerated invalidation patterns
(adapter polls, which never pass the param) rely on.
"""
parts = [
workspace_id, target or "", channel or "",
event_type or "", conversation or "",
sort or "asc", str(limit),
]
if exclude_message_types:
parts.append(exclude_message_types)
return hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()


def _poll_cache_keys_for(workspace_id: str, event_type: str = ""):
"""Return the (head_tracker_key, at_head_key) pairs that should be
invalidated when a new event is persisted in *workspace_id*.
Expand Down Expand Up @@ -97,8 +125,8 @@ def _poll_cache_keys_for(workspace_id: str, event_type: str = ""):
(workspace_id, "", "", "", "", sort, str(limit))
)

for parts in common_filters:
fh = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()
for ws, tgt, ch, typ, conv, sort, limit in common_filters:
fh = _poll_filter_hash(ws, tgt, ch, typ, conv, sort, limit)
keys.append(("v1events:head:" + fh, "v1events:athead:" + fh))

return keys
Expand Down Expand Up @@ -326,6 +354,7 @@ def poll_events(
conversation: Optional[str] = Query(None, description="Filter to DM conversation between two agents (comma-separated addresses)"),
search: Optional[str] = Query(None, description="Search message content (case-insensitive)"),
member: Optional[str] = Query(None, description="Filter to channels where this agent is a member"),
exclude_message_types: Optional[str] = Query(None, description="Comma-separated payload.message_type values to exclude (e.g. 'thinking,status,todos'). Events without a message_type are always kept."),
sort: Optional[str] = Query(None, description="Sort order: 'asc' (default) or 'desc'"),
limit: int = Query(50, ge=1, le=500, description="Max events to return"),
db: Session = Depends(get_db),
Expand Down Expand Up @@ -378,18 +407,19 @@ def poll_events(
after or "", before or "",
sort or "asc", str(limit),
]
if exclude_message_types:
key_parts.append(exclude_message_types)
cache_key = "v1events:full:" + hashlib.sha1(
"|".join(key_parts).encode("utf-8")
).hexdigest()

# Per-filter head cursor marker (what the newest event id was for
# this filter the last time we saw any events). Cursor-free.
filter_parts = [
filter_hash = _poll_filter_hash(
workspace_id, target or "", channel or "",
type or "", conversation or "",
sort or "asc", str(limit),
]
filter_hash = hashlib.sha1("|".join(filter_parts).encode("utf-8")).hexdigest()
sort or "asc", limit, exclude_message_types or "",
)
head_tracker_key = "v1events:head:" + filter_hash

import json as _json
Expand Down Expand Up @@ -486,6 +516,16 @@ def poll_events(
if type:
query = query.where(EventRecord.type.startswith(type))

if exclude_message_types:
excluded = [t.strip() for t in exclude_message_types.split(",") if t.strip()]
if excluded:
# payload->>'message_type' — NULL (missing key / non-message event)
# must be kept, so the NOT IN check alone is not enough.
message_type = EventRecord.payload["message_type"].as_string()
query = query.where(
or_(message_type.is_(None), message_type.notin_(excluded))
)

if target_agents:
# Return only events routed to this agent — mirrors the adapter's
# client-side `target_agents` filter so agents stop pulling the whole
Expand Down
124 changes: 124 additions & 0 deletions workspace/backend/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,130 @@ def test_poll_invalid_network(self, client):
assert resp.status_code == 404


class TestPollExcludeMessageTypes:
"""GET /v1/events?exclude_message_types= — filter out intermediate agent
output (thinking/status/todos) so the limit window only counts real
messages."""

def _post_message(self, client, workspace, content, message_type=None):
channel_name = workspace["channel"]["name"]
payload = {"content": content}
if message_type is not None:
payload["message_type"] = message_type
resp = client.post("/v1/events", json={
"type": "workspace.message.posted",
"source": "openagents:agent-alpha",
"target": f"channel/{channel_name}",
"payload": payload,
"network": workspace["id"],
}, headers={"X-Workspace-Token": workspace["token"]})
assert resp.status_code == 200
return resp.json()["data"]["id"]

def test_excludes_listed_message_types(self, client, workspace):
"""thinking/status/todos are filtered out; chat and untyped kept."""
self._post_message(client, workspace, "real question", "chat")
self._post_message(client, workspace, "step 1", "thinking")
self._post_message(client, workspace, "running tool", "status")
self._post_message(client, workspace, "todo list", "todos")
self._post_message(client, workspace, "no explicit type") # message_type absent

resp = client.get("/v1/events", params={
"network": workspace["id"],
"type": "workspace.message",
"exclude_message_types": "thinking,status,todos",
}, headers={"X-Workspace-Token": workspace["token"]})
assert resp.status_code == 200
events = resp.json()["data"]["events"]
contents = [e["payload"].get("content") for e in events]
assert "real question" in contents
assert "no explicit type" in contents
assert "step 1" not in contents
assert "running tool" not in contents
assert "todo list" not in contents

def test_limit_window_counts_only_kept_messages(self, client, workspace):
"""A burst of status events must not push the user's message out of
the first history page (the reported refresh-loses-question bug)."""
self._post_message(client, workspace, "user question", "chat")
for i in range(10):
self._post_message(client, workspace, f"status {i}", "status")

# Without exclusion, limit=5 newest-first returns only status events.
resp = client.get("/v1/events", params={
"network": workspace["id"],
"type": "workspace.message",
"sort": "desc",
"limit": 5,
}, headers={"X-Workspace-Token": workspace["token"]})
contents = [e["payload"].get("content") for e in resp.json()["data"]["events"]]
assert "user question" not in contents

# With exclusion, the user's message is inside the window.
resp = client.get("/v1/events", params={
"network": workspace["id"],
"type": "workspace.message",
"sort": "desc",
"limit": 5,
"exclude_message_types": "thinking,status,todos",
}, headers={"X-Workspace-Token": workspace["token"]})
contents = [e["payload"].get("content") for e in resp.json()["data"]["events"]]
assert "user question" in contents

def test_blank_exclude_param_is_noop(self, client, workspace):
"""Empty/whitespace-only exclude list applies no filter."""
self._post_message(client, workspace, "hello", "status")
resp = client.get("/v1/events", params={
"network": workspace["id"],
"type": "workspace.message",
"exclude_message_types": " , ",
}, headers={"X-Workspace-Token": workspace["token"]})
assert resp.status_code == 200
contents = [e["payload"].get("content") for e in resp.json()["data"]["events"]]
assert "hello" in contents


class TestPollCacheKeyConsistency:
"""The head/at-head keys poll_events constructs must stay invalidatable
by _poll_cache_keys_for — regression tests for the exclude_message_types
param silently changing every filter hash."""

def test_adapter_poll_hash_is_in_invalidation_set(self):
"""Filters without exclude_message_types must hash exactly like the
enumerated invalidation patterns, or agents keep getting stale
cached-empty responses after new events are posted."""
from app.routers.events import _poll_cache_keys_for, _poll_filter_hash

ws = "ws-cache-test"
invalidated = {k for pair in _poll_cache_keys_for(ws, "workspace.message.posted") for k in pair}

# The main adapter poll pattern (workspace-client.js pollPending).
fh = _poll_filter_hash(ws, "", "", "workspace.message.posted", "", "asc", 500)
assert "v1events:head:" + fh in invalidated
assert "v1events:athead:" + fh in invalidated

# getHeadEventId pattern.
fh = _poll_filter_hash(ws, "", "", "workspace.message.posted", "", "desc", 1)
assert "v1events:head:" + fh in invalidated

# Untyped all-events pattern.
fh = _poll_filter_hash(ws, "", "", "", "", "asc", 50)
assert "v1events:head:" + fh in invalidated

def test_exclude_param_changes_hash(self):
"""Polls that do use exclude_message_types must get their own key —
sharing the plain filter's key would poison its cached results."""
from app.routers.events import _poll_filter_hash

ws = "ws-cache-test"
plain = _poll_filter_hash(ws, "", "general", "workspace.message", "", "desc", 50)
excluded = _poll_filter_hash(
ws, "", "general", "workspace.message", "", "desc", 50,
exclude_message_types="thinking,status,todos",
)
assert plain != excluded


class TestPollTargetAgents:
"""GET /v1/events?target_agents= — server-side per-agent filtering.

Expand Down
11 changes: 11 additions & 0 deletions workspace/frontend/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import {
import type { WorkspaceAgent, KnowledgeEntry } from '@/lib/types';
import { AgentAvatar } from '@/components/agents/agent-avatar';
import { BookOpen } from 'lucide-react';
import { toast } from 'sonner';

// Keep in sync with the backend's MAX_FILE_SIZE (app/config.py); nginx's
// /v1/files client_max_body_size allows extra headroom for multipart
// overhead. Oversized files would be rejected server-side anyway, so
// reject them here with immediate feedback instead.
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB

export interface PendingFile {
file: File;
Expand Down Expand Up @@ -116,6 +123,10 @@ export function ChatInput({ onSend, disabled, className, agents = [], knowledge
const addFiles = React.useCallback((files: FileList | File[]) => {
const newFiles: PendingFile[] = [];
for (const file of Array.from(files)) {
if (file.size > MAX_FILE_SIZE) {
toast.error(`"${file.name}" is too large (max 50MB)`);
continue;
}
if (isImageFile(file)) {
const reader = new FileReader();
reader.onload = (e) => {
Expand Down
18 changes: 16 additions & 2 deletions workspace/frontend/components/chat/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useMessagePolling } from '@/hooks/use-polling';
import { useComposingSignal } from '@/hooks/use-composing-signal';
import { workspaceApi } from '@/lib/api';
import { capture } from '@/lib/analytics';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
Expand Down Expand Up @@ -447,14 +448,27 @@ export function ChatView() {
attachment_count: attachments?.length ?? 0,
});
forceRefresh();
} catch {
// Error is visible via missing message
} catch (err) {
// Remove optimistic messages on error
setOptimisticMessages((prev) =>
prev.filter(
(m) => m.messageId !== userOptimisticMsg.messageId && m.messageId !== loadingOptimisticMsg.messageId
)
);
// Surface the failure — silently dropping the message makes it look
// like it was sent and then vanished.
const detail = err instanceof Error && err.message ? ` (${err.message})` : '';
toast.error(
files.length > 0
? `Failed to send message — attachments could not be uploaded${detail}. Please re-attach and try again.`
: `Failed to send message${detail}. Please try again.`
);
// Restore the typed text as this thread's draft, unless the user has
// already started a new draft in the meantime.
if (content && !draftsRef.current[currentSessionId]) {
draftsRef.current[currentSessionId] = content;
if (prevSessionIdRef.current === currentSessionId) setCurrentDraft(content);
}
}
},
[currentSessionId, currentUser.id, currentUser.name, forceRefresh, agents]
Expand Down
Loading
Loading