Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0c3d050
feat(api): durable agent activity messages
ragesaq Jun 15, 2026
eb02328
feat(web): render agent activity inline with accent, badge, and toggle
ragesaq Jun 15, 2026
36eb310
feat(web): coalesce agent activity into one collapsible preamble block
ragesaq Jun 15, 2026
aeb9498
feat(web): interleave preamble commentary with per-tool collapsed rows
ragesaq Jun 15, 2026
b2299dd
feat(web): label final preamble pill with visibility state (expanded/…
ragesaq Jun 15, 2026
e71088e
feat(web): box preamble and final answer into one cohesive card
ragesaq Jun 15, 2026
c24f2a9
chore(web): rebuild embedded web dist for consolidated agent activity
ragesaq Jun 15, 2026
7321975
feat(web): make agent preamble yellow across stream/collapse phases
ragesaq Jun 15, 2026
e16445d
feat(runtime): channel runtime backbone for composer controls (no cla…
ragesaq Jun 15, 2026
0da51fa
feat(web): composer model picker + context meter wired to runtime end…
ragesaq Jun 15, 2026
3fd0263
feat(web): self-message bubbles, alignment pref, agent-responding status
ragesaq Jun 16, 2026
7e42cf2
chore(web): rebuild embedded web dist
ragesaq Jun 16, 2026
9e263da
Merge remote-tracking branch 'origin/main' into pr/composer-runtime-c…
ragesaq Jun 16, 2026
22d0b89
feat(web): mobile layout for composer runtime controls
ragesaq Jun 16, 2026
4733232
fix(api): enforce turn_id only on activity kinds; cover composer runt…
ragesaq Jun 16, 2026
f0e59ef
docs(api): document turn_id asymmetry; assert 400 body carries sentinel
ragesaq Jun 16, 2026
b28a8c7
test(e2e): anchor message-near-composer to the composer dock
ragesaq Jun 16, 2026
483134d
test(e2e): assert incremental newer-paging by invariant, not exact count
ragesaq Jun 16, 2026
f95564a
fix(web,api): gate context meter on real data; validate picker overrides
ragesaq Jun 16, 2026
8820892
feat: integrate durable agent activity
steipete Jul 1, 2026
f40251a
build: refresh embedded web assets
steipete Jul 1, 2026
9623b29
fix: preserve agent activity invariants
steipete Jul 1, 2026
8f4fece
fix: keep message kind optional in SDK
steipete Jul 1, 2026
a35aa93
fix: isolate agent activity by author
steipete Jul 1, 2026
af57acf
test: isolate agent activity browser coverage
steipete Jul 1, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Added bot-scoped durable agent commentary and tool activity, collapsed into per-turn preambles with independent visibility controls and configurable self-message alignment. Thanks @ragesaq.
- Added persistent thread activity counts and row-click thread opening in channel and DM timelines, with live summary refreshes. Thanks @PollyBot13.
- Added per-user DM closing with durable history, direct-link access, Undo, canonical one-to-one reopening, and automatic resurfacing on new messages. Thanks @PollyBot13.
- Kept self-hosted landing-page App links on the instance-local `/app` route for Tailscale, LAN, and custom hostnames instead of redirecting to the hosted app. Thanks @iXandru for the report and @TurboTheTurtle for the fix.
Expand Down
228 changes: 228 additions & 0 deletions apps/api/internal/httpapi/agent_activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package httpapi

import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)

// TestAgentActivityMessageAuthz is the authorization gate for durable agent
// activity messages (kind = agent_commentary / agent_tool). It mirrors the
// authz matrix that previously guarded the ephemeral agent.progress frame, but
// now the activity rows are durable messages on the normal create path:
//
// - a human session can never create an activity-kind message (403),
// - an activity kind requires a BOT token carrying agent_activity:write; a
// bot:write token MUST NOT inherit it (403, no bundle inheritance),
// - an unknown kind is a 400,
// - a scoped bot can create both activity kinds (success),
// - an ordinary message is unaffected for any caller with messages:write.
func TestAgentActivityMessageAuthz(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
channels, err := st.ListChannels(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]

// A bridge bot WITH the explicit activity scope.
activityBot, activityToken, err := st.CreateBot(ctx, store.CreateBotInput{
WorkspaceID: workspace.ID,
OwnerUserID: owner.ID,
DisplayName: "Activity Bot",
Scopes: []string{"bot:write", store.AgentActivityWriteScope},
CreatedBy: owner.ID,
})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, activityBot.ID, "bot"); err != nil {
t.Fatal(err)
}

// A bridge bot WITHOUT the activity scope (bot:write only). Per the
// no-inheritance rule this must NOT gain agent_activity:write.
writeBot, writeToken, err := st.CreateBot(ctx, store.CreateBotInput{
WorkspaceID: workspace.ID,
OwnerUserID: owner.ID,
DisplayName: "Write Bot",
Scopes: []string{"bot:write"},
CreatedBy: owner.ID,
})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, writeBot.ID, "bot"); err != nil {
t.Fatal(err)
}

server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
endpoint := server.URL + "/api/channels/" + channel.ID + "/messages"

commentary := `{"body":"thinking about it","kind":"agent_commentary","turn_id":"t1"}`
toolFrame := `{"body":"ran bash","kind":"agent_tool","turn_id":"t1"}`
unknownKind := `{"body":"nope","kind":"agent_bogus"}`
ordinary := `{"body":"hello channel"}`

// 1. Human (dev-auth) session cannot create an activity-kind message.
expectStatusAsUser(t, owner.ID, http.MethodPost, endpoint, strings.NewReader(commentary), http.StatusForbidden)

// 2. bot:write WITHOUT agent_activity:write is rejected (no inheritance).
expectStatusWithBearer(t, writeToken.Token, http.MethodPost, endpoint, strings.NewReader(commentary), http.StatusForbidden)

// 3. Unknown kind is a 400, regardless of scope.
expectStatusWithBearer(t, activityToken.Token, http.MethodPost, endpoint, strings.NewReader(unknownKind), http.StatusBadRequest)

// 4. Scoped bot can create both activity kinds.
expectStatusWithBearer(t, activityToken.Token, http.MethodPost, endpoint, strings.NewReader(commentary), http.StatusCreated)
expectStatusWithBearer(t, activityToken.Token, http.MethodPost, endpoint, strings.NewReader(toolFrame), http.StatusCreated)

// 5. Ordinary messages are unaffected for any messages:write caller.
expectStatusAsUser(t, owner.ID, http.MethodPost, endpoint, strings.NewReader(ordinary), http.StatusCreated)
expectStatusWithBearer(t, writeToken.Token, http.MethodPost, endpoint, strings.NewReader(ordinary), http.StatusCreated)
}

// TestAgentActivityMessagePrivacy proves a durable activity message inherits the
// same membership privacy as any channel message: a user who is not a member of
// the workspace cannot read the channel that holds it (the activity row never
// leaks to a non-member).
func TestAgentActivityMessagePrivacy(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
channels, err := st.ListChannels(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]

// Seed a durable activity message in the channel via the store directly.
activity, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "private commentary", Kind: store.MessageKindAgentCommentary, TurnID: "t1"})
if err != nil {
t.Fatal(err)
}

// A user who is NOT a member of the workspace.
stranger, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Stranger", Email: "stranger@example.com"})
if err != nil {
t.Fatal(err)
}

server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)

// The non-member cannot list the channel's messages or fetch the activity
// row by id: the store's membership check denies access (surfaced as a 4xx),
// so the activity body never reaches a non-member.
expectStatusNot2xxAsUser(t, stranger.ID, http.MethodGet, server.URL+"/api/channels/"+channel.ID+"/messages")
expectStatusNot2xxAsUser(t, stranger.ID, http.MethodGet, server.URL+"/api/messages/"+activity.ID)
// The owner (a member) can read it.
expectStatusAsUser(t, owner.ID, http.MethodGet, server.URL+"/api/messages/"+activity.ID, nil, http.StatusOK)
}

// expectStatusNot2xxAsUser asserts a dev-auth request as userID is rejected with
// a non-2xx status (the exact 400-vs-403 mapping is an internal detail; the
// privacy invariant is simply that the caller is denied).
func expectStatusNot2xxAsUser(t *testing.T, userID, method, endpoint string) {
t.Helper()
req, err := http.NewRequest(method, endpoint, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-ClickClack-User", userID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode < 400 {
t.Fatalf("%s %s as %s: expected a denial (>=400), got %s", method, endpoint, userID, resp.Status)
}
}

// TestAgentActivityScopeExcludedFromBotBundles asserts the new scope is NOT part
// of any bot:* bundle, so existing deployments that minted bot:read/write/admin
// tokens gain no new capability.
func TestAgentActivityScopeExcludedFromBotBundles(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
for _, bundle := range []string{"bot:read", "bot:write", "bot:admin"} {
_, token, err := st.CreateBot(ctx, store.CreateBotInput{
WorkspaceID: workspace.ID,
OwnerUserID: owner.ID,
DisplayName: "Bundle Bot " + bundle,
Scopes: []string{bundle},
CreatedBy: owner.ID,
})
if err != nil {
t.Fatalf("create bot for %s: %v", bundle, err)
}
for _, scope := range token.Scopes {
if scope == store.AgentActivityWriteScope {
t.Fatalf("%s bundle unexpectedly granted %s", bundle, store.AgentActivityWriteScope)
}
}
}
}
12 changes: 10 additions & 2 deletions apps/api/internal/httpapi/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,8 @@ func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) {
Body string `json:"body"`
QuotedMessageID string `json:"quoted_message_id"`
Nonce string `json:"nonce"`
Kind string `json:"kind"`
TurnID string `json:"turn_id"`
}
if err := readJSON(w, r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
Expand All @@ -930,13 +932,19 @@ func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, err)
return
}
kind, turnID, ok := s.resolveMessageKind(w, act, body.Kind, body.TurnID)
if !ok {
return
}
if !s.requireBotDirectWorkspace(w, r, act, chi.URLParam(r, "conversation_id")) {
return
}
message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce})
message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, Kind: kind, TurnID: turnID})
if err == nil && event.ID != "" {
s.publishEvent(r.Context(), event)
s.notifyMessageCreated(r.Context(), message)
if !store.IsActivityMessageKind(message.Kind) {
s.notifyMessageCreated(r.Context(), message)
}
}
writeMessageCreateResult(w, message, event, err)
}
Expand Down
51 changes: 49 additions & 2 deletions apps/api/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -609,22 +609,69 @@ func (s *Server) createMessage(w http.ResponseWriter, r *http.Request) {
QuotedMessageID string `json:"quoted_message_id"`
Nonce string `json:"nonce"`
TopicID string `json:"topic_id"`
Kind string `json:"kind"`
TurnID string `json:"turn_id"`
}
if err := readJSON(w, r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
kind, turnID, ok := s.resolveMessageKind(w, act, body.Kind, body.TurnID)
if !ok {
return
}
if !s.requireBotChannelWorkspace(w, r, act, chi.URLParam(r, "channel_id")) {
return
}
message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, TopicID: body.TopicID})
message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, TopicID: body.TopicID, Kind: kind, TurnID: turnID})
if err == nil && event.ID != "" {
s.publishEvent(r.Context(), event)
s.notifyMessageCreated(r.Context(), message)
if !store.IsActivityMessageKind(message.Kind) {
s.notifyMessageCreated(r.Context(), message)
}
}
writeMessageCreateResult(w, message, event, err)
}

// resolveMessageKind validates a caller-supplied message kind and turn_id and
// enforces the activity authorization contract:
//
// - an empty/'message' kind is always allowed and returned as 'message',
// - an unknown kind is a 400,
// - an ordinary 'message' MUST NOT carry a turn_id (400); turn_id correlates
// agent activity rows only, so a non-empty value on an ordinary message is
// a client contract violation and fails closed,
// - an activity kind (agent_commentary/agent_tool) requires a BOT token that
// carries agent_activity:write; a human session always gets 403 and a bot
// without the scope gets 403, and may carry a turn_id.
//
// It writes the error response itself and returns ok=false when the request
// must not proceed. On success it returns the normalized kind and the turn_id
// that should be persisted.
func (s *Server) resolveMessageKind(w http.ResponseWriter, act actor, rawKind, rawTurnID string) (string, string, bool) {
kind, err := store.NormalizeMessageKind(rawKind)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return "", "", false
}
if !store.IsActivityMessageKind(kind) {
if rawTurnID != "" {
writeError(w, http.StatusBadRequest, store.ErrTurnIDNotAllowed)
return "", "", false
}
return kind, "", true
}
if act.botTokenID == "" {
writeError(w, http.StatusForbidden, errors.New("agent activity messages require a bot token"))
return "", "", false
}
if err := act.requireScope(store.AgentActivityWriteScope); err != nil {
writeError(w, http.StatusForbidden, err)
return "", "", false
}
return kind, rawTurnID, true
}

func (s *Server) getMessage(w http.ResponseWriter, r *http.Request) {
act, err := s.currentActor(r)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions apps/api/internal/store/postgres/bots.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ var botAllowedScopes = []string{
"realtime:read",
"uploads:write",
"profile:read",
// agent_activity:write is grantable but intentionally NOT part of any
// bot:* bundle. Durable agent activity is a distinct authorization surface
// and must be granted explicitly, so existing bot:* deployments gain no new
// capability.
store.AgentActivityWriteScope,
}

func (s *Store) CreateBot(ctx context.Context, input store.CreateBotInput) (store.User, store.BotToken, error) {
Expand Down
19 changes: 16 additions & 3 deletions apps/api/internal/store/postgres/dms.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,16 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec
if err != nil {
return store.Message{}, store.Event{}, err
}
kind, err := store.NormalizeMessageKind(input.Kind)
if err != nil {
return store.Message{}, store.Event{}, err
}
var quotedID, quotedAuthorID, quotedSnapshot string
if input.QuotedMessageID != nil {
quotedID = strings.TrimSpace(*input.QuotedMessageID)
}
if existing, err := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); err == nil {
if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || !sameQuotedMessageID(existing, quotedID) {
if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) {
return store.Message{}, store.Event{}, store.ErrClientNonceConflict
}
return existing, store.Event{}, nil
Expand Down Expand Up @@ -303,9 +307,11 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec
QuotedBodySnapshot: quotedSnapshot,
QuotedAuthorID: sqlOptionalText(quotedAuthorID),
ClientNonce: nonce,
Kind: kind,
TurnID: sqlOptionalText(input.TurnID),
}); err != nil {
if existing, lookupErr := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); lookupErr == nil {
if existing.DirectConversationID == input.ConversationID && existing.ChannelID == "" && existing.ParentMessageID == nil && existing.Body == body && sameQuotedMessageID(existing, quotedID) {
if existing.DirectConversationID == input.ConversationID && existing.ChannelID == "" && existing.ParentMessageID == nil && existing.Body == body && existing.Kind == kind && existing.TurnID == input.TurnID && sameQuotedMessageID(existing, quotedID) {
return existing, store.Event{}, nil
}
return store.Message{}, store.Event{}, store.ErrClientNonceConflict
Expand All @@ -322,7 +328,14 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec
if err != nil {
return store.Message{}, store.Event{}, err
}
event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID, "author_id": input.AuthorID}, nonce), recipients)
dmEventFields := map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID, "author_id": input.AuthorID}
if kind != store.MessageKindMessage {
dmEventFields["kind"] = kind
}
if input.TurnID != "" {
dmEventFields["turn_id"] = input.TurnID
}
event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(dmEventFields, nonce), recipients)
if err != nil {
return store.Message{}, store.Event{}, err
}
Expand Down
Loading
Loading