fix: prefix ao send messages with sender session (#85)

* fix: prefix ao send messages with sender session

* feat: add orchestrator-aware spawn prompts

* fix(session): return first active orchestrator
This commit is contained in:
Harshit Singh Bhandari 2026-06-02 21:39:21 +05:30 committed by GitHub
parent 5435246c9a
commit 9058017439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 181 additions and 1 deletions

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
@ -41,6 +42,9 @@ func (c *commandContext) sendMessage(ctx context.Context, opts sendOptions) erro
return usageError{errors.New("usage: --message is required")}
}
message := opts.message
if sender := strings.TrimSpace(os.Getenv("AO_SESSION_ID")); sender != "" {
message = "[from " + sender + "] " + message
}
session := strings.TrimSpace(opts.session)
if session == "" {
return usageError{errors.New("usage: --session is required")}

View File

@ -58,6 +58,7 @@ func sendServer(t *testing.T, status int, respBody string) (*httptest.Server, *s
}
func TestSend_Success(t *testing.T) {
t.Setenv("AO_SESSION_ID", "")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK,
`{"ok":true,"sessionId":"demo-1","message":"hello agent"}`)
@ -83,7 +84,57 @@ func TestSend_Success(t *testing.T) {
}
}
func TestSend_PrefixesMessageWithSenderSessionID(t *testing.T) {
t.Setenv("AO_SESSION_ID", "aa-47")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK,
`{"ok":true,"sessionId":"demo-1","message":"hi"}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", " hi ")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
var req struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
want := "[from aa-47] hi "
if req.Message != want {
t.Errorf("captured message = %q, want %q", req.Message, want)
}
}
func TestSend_BlankSenderSessionIDDoesNotPrefixMessage(t *testing.T) {
t.Setenv("AO_SESSION_ID", " \t ")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK,
`{"ok":true,"sessionId":"demo-1","message":"hello agent"}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", "hello agent")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
var req struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
if req.Message != "hello agent" {
t.Errorf("captured message = %q, want %q", req.Message, "hello agent")
}
}
func TestSend_PreservesMessageWhitespace(t *testing.T) {
t.Setenv("AO_SESSION_ID", "")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK, `{"ok":true,"sessionId":"demo-1","message":"hi"}`)
writeRunFileFor(t, cfg, srv)

View File

@ -99,6 +99,11 @@ func New(d Deps) *Manager {
// workspace and runtime, then reports completion to the LCM. A failure after the
// row exists parks it as terminated and rolls back what was built.
func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) {
prompt, err := m.buildSpawnPrompt(ctx, cfg)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err)
}
rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock()))
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("spawn: create: %w", err)
@ -118,7 +123,6 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
}
prompt := buildPrompt(cfg)
agent, ok := m.agents.Agent(cfg.Harness)
if !ok {
_ = m.workspace.Destroy(ctx, ws)
@ -321,6 +325,70 @@ func buildPrompt(cfg ports.SpawnConfig) string {
}
}
func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) {
prompt := buildPrompt(cfg)
switch cfg.Kind {
case domain.KindOrchestrator:
return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil
case domain.KindWorker:
orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, cfg.ProjectID)
if err != nil {
return "", err
}
if ok {
prompt = appendPromptSection(prompt, workerOrchestratorPrompt(orchestratorID))
}
}
return prompt, nil
}
func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) {
recs, err := m.store.ListSessions(ctx, project)
if err != nil {
return "", false, fmt.Errorf("list sessions for %s: %w", project, err)
}
for _, rec := range recs {
if rec.Kind == domain.KindOrchestrator && !rec.IsTerminated {
return rec.ID, true, nil
}
}
return "", false, nil
}
func orchestratorPrompt(project domain.ProjectID) string {
return fmt.Sprintf(`## Orchestrator role
You are the human-facing coordinator for project %s. Coordinate work for the human, keep the project moving, and avoid doing implementation yourself unless it is necessary.
Spawn worker sessions for implementation with:
`+"`ao spawn --project %s --prompt \"<clear worker task>\"`"+`
Message workers with `+"`ao send`"+`, for example:
`+"`ao send --session <worker-session-id> --message \"<your message>\"`"+`
Use workers for focused implementation tasks, track their progress, synthesize their results, and only step into implementation directly for true emergencies or small coordination fixes.`, project, project)
}
func workerOrchestratorPrompt(orchestratorID domain.SessionID) string {
return fmt.Sprintf(`## Orchestrator coordination
An active orchestrator session exists for this project. If you hit a true blocker or need cross-session coordination, message it with:
`+"`ao send --session %s --message \"<your message>\"`"+`
Only ping the orchestrator for true blockers, cross-session coordination, or decisions that cannot be resolved within your own task.`, orchestratorID)
}
func appendPromptSection(prompt, section string) string {
switch {
case prompt == "":
return section
case section == "":
return prompt
default:
return prompt + "\n\n" + section
}
}
func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string) map[string]string {
return map[string]string{
EnvSessionID: string(id),

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
@ -258,6 +259,62 @@ func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) {
}
}
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
m, st, _, _ := newManager()
st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
if err != nil {
t.Fatal(err)
}
prompt := st.sessions[s.ID].Metadata.Prompt
for _, want := range []string{
"do it",
"## Orchestrator coordination",
`ao send --session mer-1 --message "<your message>"`,
"Only ping the orchestrator for true blockers, cross-session coordination",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q:\n%s", want, prompt)
}
}
}
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
m, st, _, _ := newManager()
st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
if err != nil {
t.Fatal(err)
}
prompt := st.sessions[s.ID].Metadata.Prompt
if strings.Contains(prompt, "## Orchestrator coordination") || strings.Contains(prompt, "ao send --session mer-1") {
t.Fatalf("terminated orchestrator should not be added to prompt:\n%s", prompt)
}
}
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
m, st, _, _ := newManager()
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator})
if err != nil {
t.Fatal(err)
}
prompt := st.sessions[s.ID].Metadata.Prompt
for _, want := range []string{
"You are the human-facing coordinator for project mer",
`ao spawn --project mer --prompt "<clear worker task>"`,
"`ao send`",
"avoid doing implementation yourself unless it is necessary",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q:\n%s", want, prompt)
}
}
}
func TestSpawn_KeepsExplicitBranch(t *testing.T) {
m, st, _, _ := newManager()
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "feature/x"})