Skip to content
Merged
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
11 changes: 7 additions & 4 deletions go/cmd/compass/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ func newMessageCmd() *cobra.Command {

// newMessagePostCmd builds `message post --channel <id> --topic <name> [--mention
// <handle>]`: post one message into a channel's topic, the body read from stdin.
// --topic is a get-or-create-by-name (an unknown name creates the topic per the
// PostMessage handler at internal/comms/comms.go:353). --mention prepends
// --topic is a get-or-create-by-name: this operator surface is a trusted minter,
// so it sets CreateTopic on the request and an unknown name mints the topic (the
// get-or-create gate is store.resolveTopicForAppend, keyed on
// PostMessageRequest.create_topic). --mention prepends
// `@<handle> ` to the body; the server parses @-mentions from the raw text, so
// there is no separate mention field on the wire (PostMessageRequest carries
// only container/topic/blocks). The body is read from stdin, never a flag or
Expand Down Expand Up @@ -126,8 +128,9 @@ func runMessagePost(ctx context.Context, client compassv1connect.CommsServiceCli
// auto-retry), so the (author, client_request_id) idempotency lane is left
// unused deliberately rather than minting a per-invocation key.
resp, err := client.PostMessage(ctx, connect.NewRequest(&compassv1.PostMessageRequest{
Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: args.channel},
Topic: &compassv1.PostMessageRequest_TopicName{TopicName: args.topic},
Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: args.channel},
Topic: &compassv1.PostMessageRequest_TopicName{TopicName: args.topic},
CreateTopic: true,
Blocks: []*compassv1.MessageBlock{
{Block: &compassv1.MessageBlock_Text{Text: body}},
},
Expand Down
13 changes: 8 additions & 5 deletions go/e2e/comms_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,21 @@ import (
// PostMessage posts one text block to a channel's topic over
// CommsService.PostMessage and returns the server-assigned message id. It
// addresses the channel by id (the PostMessageRequest.container oneof) and the
// topic by name (the topic oneof — get-or-create by name), matching the
// agent-initiated post shape the in-process suite exercises
// topic by name (the topic oneof). It is a trusted internal minter, so it sets
// CreateTopic — the first post to a freshly-minted home channel (no pre-seeded
// topic) mints the topic — matching the agent-initiated post shape the
// in-process suite exercises
// (integration_pgtest_test.go:162-163). Returns an error rather than panicking
// so the caller (a test) decides fatality; the per-call deadline is threaded
// from ctx.
func (f *Fixture) PostMessage(ctx context.Context, channelID, topicName, text string) (messageID string, err error) {
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := f.Comms().PostMessage(rctx, connect.NewRequest(&compassv1.PostMessageRequest{
Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: channelID},
Topic: &compassv1.PostMessageRequest_TopicName{TopicName: topicName},
Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: text}}},
Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: channelID},
Topic: &compassv1.PostMessageRequest_TopicName{TopicName: topicName},
CreateTopic: true,
Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: text}}},
}))
if err != nil {
return "", fmt.Errorf("PostMessage RPC: %w", err)
Expand Down
36 changes: 23 additions & 13 deletions go/e2e/legcomms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,21 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) {
// comms bus and cannot collide with the human trigger post.
const postTopic = "e2e-comms-leg"
const postBody = "comms leg: posting from the agent loop"
// The poster's handle. Its home channel is minted under the same name
// (store/accounts.go:337 names the home channel for the agent's handle), so
// this const doubles as the channel NAME the scripted post must target.
const posterHandle = "comms-leg-poster"
// The comms tool's arguments, serialized JSON (the OpenAI tool-call
// contract). Built from the consts above so the asserted values cannot drift
// from what the script issues. Field names are the postParameters wire schema
// (comms.ts): text, topic. channel_id is omitted so the post lands on the
// agent's home channel.
// (comms.ts): text, topic, channel, create_topic. As of the peer-DM cutover
// (record R2/R5) post has NO home default — `channel` is REQUIRED and carries
// the target channel NAME (here the poster's own home channel, whose name is
// its handle), and a name-miss topic needs create_topic:true (postTopic names
// no existing topic on the freshly-minted home channel, so it must mint).
postArgsJSON := fmt.Sprintf(
`{"text":%q,"topic":%q}`,
postBody, postTopic,
`{"text":%q,"topic":%q,"channel":%q,"create_topic":true}`,
postBody, postTopic, posterHandle,
)
// The assistant text the closing turn settles on after the tool result
// returns — a clean text settle, mirroring the sibling's settleReply. Unlike
Expand All @@ -70,7 +77,7 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) {

// The poster agent: created, provisioned, and started exactly as the
// sibling's spawner. Its turn issues the comms post.
posterID, err := f.CreateAgent(ctx, "comms-leg-poster", "Comms Leg Poster")
posterID, err := f.CreateAgent(ctx, posterHandle, "Comms Leg Poster")
if err != nil {
t.Fatalf("CreateAgent (poster): %v", err)
}
Expand Down Expand Up @@ -110,9 +117,9 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) {
defer tail.Close()

// Resolve the poster to get its home channel id (the channel the trigger post
// lands on and the channel the agent's own post — channel_id omitted — fans
// onto).
poster, err := adminAgentByHandle(ctx, st, "comms-leg-poster")
// lands on and the channel the agent's own post — targeting its home channel
// by name — fans onto).
poster, err := adminAgentByHandle(ctx, st, posterHandle)
if err != nil {
t.Fatalf("AgentByHandle(poster): %v", err)
}
Expand Down Expand Up @@ -160,9 +167,12 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) {
if got := posted.GetAuthorAccountId(); got != posterID {
t.Fatalf("posted message author = %q, want the poster agent's account id %q (the agent posted it, not the human trigger)", got, posterID)
}
// The 'omit channel_id => home channel' branch is not separately asserted:
// the returned Message carries no channel container (F9 removed it), and
// GetTopicId() is a server-minted id, not the scripted topic NAME, so there
// is nothing on the wire Message to compare against poster.Agent.HomeChannelID
// or postTopic. Body + author is the correct assertion ceiling here.
// The scripted post names its target channel explicitly (the poster's own
// home channel by name — post has no home default post-cutover) and mints the
// topic via create_topic. Neither the resolved channel nor the topic NAME is
// separately asserted off the wire Message: it carries no channel container
// (F9 removed it), and GetTopicId() is a server-minted id, not the scripted
// topic NAME, so there is nothing on the wire to compare against
// poster.Agent.HomeChannelID or postTopic. Body + author is the correct
// assertion ceiling here.
}
23 changes: 19 additions & 4 deletions go/gen/compass/v1/comms.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 77 additions & 2 deletions go/internal/comms/agent_caller.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,79 @@ func (c *Comms) ListAsAccount(
return resp.Msg, nil
}

// PostAsAccountByName is the AGENT TOOL entry for comms_post_message /
// comms_post_ask: it resolves the request's channel NAME (carried in the
// channel_id container arm — the TS tool sends a name there per peer-DM R1) to a
// real channel id within account's visible set, then delegates to the id-typed
// PostAsAccount. This is the ONLY caller that treats the container arm as a name;
// PostAsAccount stays id-typed for its internal id-holder co-callers
// (postSetupThread, CommitAgentPost, the offline-mention e2e), which the frozen
// record's "resolve in PostAsAccount" wording predates.
//
// Per R2 there is NO home-channel default at the tool level: an empty channel
// name is NOT filled from home (the agent must NAME its channel, even its own
// home), so an empty name resolves to ErrNotFound like any other miss. An
// unknown or invisible name collapses to CodeNotFound (the D9 merge); an
// ambiguous name is CodeInvalidArgument naming the collision — both surfaced via
// edgeError from ChannelByNameForViewer.
func (c *Comms) PostAsAccountByName(
ctx context.Context,
account store.AccountID,
req *compassv1.PostMessageRequest,
) (*compassv1.PostMessageResponse, error) {
if account == "" {
return nil, errNoActor
}
ch, err := c.store.ChannelByNameForViewer(ctx, account, req.GetChannelId())
if err != nil {
return nil, edgeError(err)
}
resolved := &compassv1.PostMessageRequest{
Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)},
Blocks: req.GetBlocks(),
Topic: req.GetTopic(),
ClientRequestId: req.GetClientRequestId(),
CreateTopic: req.GetCreateTopic(),
}
// The delegated PostAsAccount runs defaultChannel again, but it is a no-op
// here: the channel id is already resolved-non-empty, so defaultChannel's
// empty→home fill never fires. R2 (no home default for post/ask) is enforced
// upstream by ChannelByNameForViewer rejecting an empty name above, NOT by
// this residual pass — a future refactor of PostAsAccount's home default must
// not reintroduce a post/ask home fallback here.
return c.PostAsAccount(ctx, account, resolved)
}

// ListAsAccountByName is the AGENT TOOL entry for comms_list_messages. It mirrors
// PostAsAccountByName's name resolution but KEEPS omit-=home (a read has no
// misroute hazard, peer-DM R2): an empty channel name defaults to the account's
// home channel, a non-empty name resolves through ChannelByNameForViewer. The
// resolved id-typed request is delegated to the unchanged ListAsAccount.
func (c *Comms) ListAsAccountByName(
ctx context.Context,
account store.AccountID,
req *compassv1.ListMessagesRequest,
) (*compassv1.ListMessagesResponse, error) {
if account == "" {
return nil, errNoActor
}
channelID := req.GetChannelId()
if channelID != "" {
ch, err := c.store.ChannelByNameForViewer(ctx, account, channelID)
if err != nil {
return nil, edgeError(err)
}
channelID = string(ch.ID)
}
resolved := &compassv1.ListMessagesRequest{
Container: &compassv1.ListMessagesRequest_ChannelId{ChannelId: channelID},
Limit: req.GetLimit(),
BeforeMessageId: req.GetBeforeMessageId(),
SnapshotSeq: req.GetSnapshotSeq(),
}
return c.ListAsAccount(ctx, account, resolved)
}

// UpdatePinnedBoardAsAccount executes one agent-initiated UpdatePinnedBoard as
// account, mirroring PostAsAccount: WithActor + the shared UpdatePinnedBoard
// handler path, so the board authz (post_policy), the pure-pointer store ops,
Expand Down Expand Up @@ -230,8 +303,9 @@ func (c *Comms) CommitAgentPost(
// Container unset: routes to the agent's home channel (defaultChannel).
// Topic named: the store has no home-topic default, so the frame's
// conversation is addressed by topic name.
Topic: &compassv1.PostMessageRequest_TopicName{TopicName: agentConversationTopic},
Blocks: posted.GetMessage().GetBlocks(),
Topic: &compassv1.PostMessageRequest_TopicName{TopicName: agentConversationTopic},
CreateTopic: true,
Blocks: posted.GetMessage().GetBlocks(),
})
}

Expand Down Expand Up @@ -373,6 +447,7 @@ func (c *Comms) defaultChannel(
Blocks: req.GetBlocks(),
Topic: req.GetTopic(),
ClientRequestId: req.GetClientRequestId(),
CreateTopic: req.GetCreateTopic(),
}, nil
}

Expand Down
Loading