Skip to content

Commit 82e9111

Browse files
Merge PR #70 (feat(daemon): wire SM + agent shim + RepoResolver + inbox messenger, aa-39) into staging
2 parents e0b8ab0 + b463232 commit 82e9111

14 files changed

Lines changed: 854 additions & 22 deletions

File tree

backend/internal/adapters/agent/agent.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ type Agent interface {
3030
SessionInfo(ctx context.Context, session SessionRef) (info SessionInfo, ok bool, err error)
3131
}
3232

33+
// MetadataKeyAgentSessionID is the SessionRef.Metadata key under which every
34+
// adapter persists the native agent session id captured at launch and reads it
35+
// back during restore. The Better-AO portshim sets it so the underlying
36+
// adapter's GetRestoreCommand sees a unified location regardless of harness.
37+
const MetadataKeyAgentSessionID = "agentSessionId"
38+
3339
// Config contains values loaded from the selected agent's config section.
3440
// Agent adapters own validation for their custom keys.
3541
type Config map[string]any

backend/internal/adapters/agent/claudecode/claudecode.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,8 @@ const (
4141
// Normalized session-metadata keys the Claude Code hooks persist into the
4242
// Better-AO session store and SessionInfo reads back. Shared vocabulary
4343
// with the Codex adapter so the dashboard treats every agent uniformly.
44-
// agentSessionId is also the preferred restore id.
45-
claudeAgentSessionIDMetadataKey = "agentSessionId"
46-
claudeTitleMetadataKey = "title"
47-
claudeSummaryMetadataKey = "summary"
44+
claudeTitleMetadataKey = "title"
45+
claudeSummaryMetadataKey = "summary"
4846
)
4947

5048
// claudeSessionNamespace seeds the UUIDv5 derivation that maps a better-ao
@@ -179,7 +177,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg agent.RestoreConfig)
179177
return nil, false, err
180178
}
181179

182-
sessionID := strings.TrimSpace(cfg.Session.Metadata[claudeAgentSessionIDMetadataKey])
180+
sessionID := strings.TrimSpace(cfg.Session.Metadata[agent.MetadataKeyAgentSessionID])
183181
if sessionID == "" && cfg.Session.ID != "" {
184182
// Explicit fallback for pre-hook sessions: the id better-ao
185183
// deterministically pinned via --session-id at launch.
@@ -210,7 +208,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session agent.SessionRef) (age
210208
return agent.SessionInfo{}, false, err
211209
}
212210
info := agent.SessionInfo{
213-
AgentSessionID: session.Metadata[claudeAgentSessionIDMetadataKey],
211+
AgentSessionID: session.Metadata[agent.MetadataKeyAgentSessionID],
214212
Title: session.Metadata[claudeTitleMetadataKey],
215213
Summary: session.Metadata[claudeSummaryMetadataKey],
216214
}

backend/internal/adapters/agent/claudecode/claudecode_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
228228
info, ok, err := (&Plugin{resolvedBinary: "claude"}).SessionInfo(context.Background(), agent.SessionRef{
229229
WorkspacePath: "/some/path",
230230
Metadata: map[string]string{
231-
claudeAgentSessionIDMetadataKey: "claude-native-1",
231+
agent.MetadataKeyAgentSessionID: "claude-native-1",
232232
claudeTitleMetadataKey: "Fix login redirect",
233233
claudeSummaryMetadataKey: "Updated the auth callback and tests.",
234234
"ignored": "not returned",
@@ -299,7 +299,7 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
299299
Permissions: agent.PermissionModeBypassPermissions,
300300
Session: agent.SessionRef{
301301
ID: "sess-r",
302-
Metadata: map[string]string{claudeAgentSessionIDMetadataKey: "claude-native-1"},
302+
Metadata: map[string]string{agent.MetadataKeyAgentSessionID: "claude-native-1"},
303303
},
304304
})
305305
if err != nil || !ok {
@@ -334,7 +334,7 @@ func TestGetRestoreCommandFalseWithoutSessionID(t *testing.T) {
334334
ref agent.SessionRef
335335
}{
336336
{"empty ref", agent.SessionRef{}},
337-
{"blank agent session, no id", agent.SessionRef{Metadata: map[string]string{claudeAgentSessionIDMetadataKey: " "}}},
337+
{"blank agent session, no id", agent.SessionRef{Metadata: map[string]string{agent.MetadataKeyAgentSessionID: " "}}},
338338
{"workspace path only", agent.SessionRef{WorkspacePath: "/some/path"}},
339339
}
340340
for _, tc := range cases {

backend/internal/adapters/agent/codex/codex.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
)
2121

2222
const (
23-
codexAgentSessionIDMetadataKey = "agentSessionId"
23+
codexAgentSessionIDMetadataKey = agent.MetadataKeyAgentSessionID
2424
codexTitleMetadataKey = "title"
2525
codexSummaryMetadataKey = "summary"
2626
)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Package portshim bridges the richer adapters/agent.Agent interface onto the
2+
// narrower ports.Agent the Session Manager consumes. The richer interface
3+
// returns argv slices and takes a context; ports.Agent returns a single shell
4+
// string and is context-free. The shim joins argv with POSIX shell quoting so
5+
// the zellij runtime, which evaluates LaunchCommand under `sh -lc`, sees the
6+
// agent's argv intact.
7+
package portshim
8+
9+
import (
10+
"context"
11+
"strings"
12+
13+
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent"
14+
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
15+
)
16+
17+
// Shim wraps an adapters/agent.Agent and satisfies ports.Agent. The shim is
18+
// context-free at its API surface; it threads context.Background() into the
19+
// richer interface. That matches the existing ports.Agent shape — extending it
20+
// is a separate change.
21+
type Shim struct {
22+
agent agent.Agent
23+
}
24+
25+
// New constructs a Shim. agent is required; nil is not supported.
26+
func New(a agent.Agent) *Shim { return &Shim{agent: a} }
27+
28+
var _ ports.Agent = (*Shim)(nil)
29+
30+
// GetLaunchCommand asks the wrapped agent for its launch argv and renders it as
31+
// a single POSIX-shell-safe string. An adapter error or empty argv yields "".
32+
func (s *Shim) GetLaunchCommand(cfg ports.AgentConfig) string {
33+
argv, err := s.agent.GetLaunchCommand(context.Background(), launchConfigFor(cfg))
34+
if err != nil {
35+
return ""
36+
}
37+
return joinShellArgv(argv)
38+
}
39+
40+
// GetEnvironment returns nil: the richer agent interface doesn't carry the env
41+
// keys ports.AgentConfig exposes, and the SM layers AO_SESSION_ID,
42+
// AO_PROJECT_ID, AO_ISSUE_ID on top of whatever the agent contributes. A nil
43+
// map is fine here — session.spawnEnv treats nil as empty.
44+
func (s *Shim) GetEnvironment(ports.AgentConfig) map[string]string {
45+
return nil
46+
}
47+
48+
// GetRestoreCommand resumes a native agent session given its agentSessionID and
49+
// returns the resume command as a POSIX-shell-safe string. An adapter error or
50+
// ok=false yields "" — the SM falls back to a fresh Spawn.
51+
func (s *Shim) GetRestoreCommand(agentSessionID string) string {
52+
cfg := agent.RestoreConfig{
53+
Session: agent.SessionRef{
54+
ID: agentSessionID,
55+
Metadata: map[string]string{
56+
agent.MetadataKeyAgentSessionID: agentSessionID,
57+
},
58+
},
59+
}
60+
argv, ok, err := s.agent.GetRestoreCommand(context.Background(), cfg)
61+
if err != nil || !ok {
62+
return ""
63+
}
64+
return joinShellArgv(argv)
65+
}
66+
67+
func launchConfigFor(cfg ports.AgentConfig) agent.LaunchConfig {
68+
return agent.LaunchConfig{
69+
SessionID: string(cfg.SessionID),
70+
WorkspacePath: cfg.WorkspacePath,
71+
Prompt: cfg.Prompt,
72+
}
73+
}
74+
75+
// joinShellArgv renders argv as a single string the POSIX shell will re-parse
76+
// into the same tokens. Each arg is quoted in single quotes unless it consists
77+
// only of characters guaranteed safe to leave bare.
78+
func joinShellArgv(argv []string) string {
79+
if len(argv) == 0 {
80+
return ""
81+
}
82+
parts := make([]string, len(argv))
83+
for i, a := range argv {
84+
parts[i] = shellQuote(a)
85+
}
86+
return strings.Join(parts, " ")
87+
}
88+
89+
func shellQuote(s string) string {
90+
if s == "" {
91+
return "''"
92+
}
93+
if isShellSafe(s) {
94+
return s
95+
}
96+
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
97+
}
98+
99+
// isShellSafe matches the conservative bash-completion convention: letters,
100+
// digits, and a handful of punctuation that never trigger expansion or word
101+
// splitting. Anything else is quoted.
102+
func isShellSafe(s string) bool {
103+
for _, r := range s {
104+
switch {
105+
case r >= 'a' && r <= 'z',
106+
r >= 'A' && r <= 'Z',
107+
r >= '0' && r <= '9',
108+
r == '-', r == '_', r == '/', r == '.', r == ',', r == ':', r == '+', r == '@', r == '=':
109+
continue
110+
}
111+
return false
112+
}
113+
return true
114+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package portshim_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
"testing"
8+
9+
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent"
10+
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/portshim"
11+
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
12+
"github.com/aoagents/agent-orchestrator/backend/internal/session"
13+
)
14+
15+
type fakeAgent struct {
16+
launchCmd []string
17+
launchErr error
18+
restoreCmd []string
19+
restoreOK bool
20+
restoreErr error
21+
gotLaunchCfg agent.LaunchConfig
22+
gotRestoreCfg agent.RestoreConfig
23+
}
24+
25+
func (f *fakeAgent) GetConfigSpec(context.Context) (agent.ConfigSpec, error) {
26+
return agent.ConfigSpec{}, nil
27+
}
28+
func (f *fakeAgent) GetLaunchCommand(_ context.Context, cfg agent.LaunchConfig) ([]string, error) {
29+
f.gotLaunchCfg = cfg
30+
return f.launchCmd, f.launchErr
31+
}
32+
func (f *fakeAgent) GetPromptDeliveryStrategy(context.Context, agent.LaunchConfig) (agent.PromptDeliveryStrategy, error) {
33+
return agent.PromptDeliveryInCommand, nil
34+
}
35+
func (f *fakeAgent) GetAgentHooks(context.Context, agent.WorkspaceHookConfig) error { return nil }
36+
func (f *fakeAgent) GetRestoreCommand(_ context.Context, cfg agent.RestoreConfig) ([]string, bool, error) {
37+
f.gotRestoreCfg = cfg
38+
return f.restoreCmd, f.restoreOK, f.restoreErr
39+
}
40+
func (f *fakeAgent) SessionInfo(context.Context, agent.SessionRef) (agent.SessionInfo, bool, error) {
41+
return agent.SessionInfo{}, false, nil
42+
}
43+
44+
func TestSatisfiesPortsAgent(t *testing.T) {
45+
var _ ports.Agent = (*portshim.Shim)(nil)
46+
}
47+
48+
func TestGetLaunchCommand_JoinsArgvShellSafely(t *testing.T) {
49+
tests := []struct {
50+
name string
51+
argv []string
52+
want string
53+
}{
54+
{"simple", []string{"claude"}, "claude"},
55+
{"flags and prompt", []string{"claude", "--", "do it"}, "claude -- 'do it'"},
56+
{"path with spaces", []string{"/Applications/My App/claude", "--flag"}, "'/Applications/My App/claude' --flag"},
57+
{"prompt with single quote", []string{"claude", "--", "it's fine"}, `claude -- 'it'\''s fine'`},
58+
{"empty argv", []string{}, ""},
59+
}
60+
for _, tc := range tests {
61+
t.Run(tc.name, func(t *testing.T) {
62+
s := portshim.New(&fakeAgent{launchCmd: tc.argv})
63+
got := s.GetLaunchCommand(ports.AgentConfig{})
64+
if got != tc.want {
65+
t.Fatalf("got %q want %q", got, tc.want)
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestGetLaunchCommand_PropagatesAgentConfig(t *testing.T) {
72+
fake := &fakeAgent{launchCmd: []string{"claude"}}
73+
s := portshim.New(fake)
74+
cfg := ports.AgentConfig{SessionID: "p-1", WorkspacePath: "/ws/p-1", Prompt: "hello"}
75+
_ = s.GetLaunchCommand(cfg)
76+
if fake.gotLaunchCfg.SessionID != "p-1" {
77+
t.Errorf("SessionID not propagated: %+v", fake.gotLaunchCfg)
78+
}
79+
if fake.gotLaunchCfg.WorkspacePath != "/ws/p-1" {
80+
t.Errorf("WorkspacePath not propagated: %+v", fake.gotLaunchCfg)
81+
}
82+
if fake.gotLaunchCfg.Prompt != "hello" {
83+
t.Errorf("Prompt not propagated: %+v", fake.gotLaunchCfg)
84+
}
85+
}
86+
87+
func TestGetLaunchCommand_AgentErrorReturnsEmpty(t *testing.T) {
88+
fake := &fakeAgent{launchErr: errors.New("boom")}
89+
s := portshim.New(fake)
90+
got := s.GetLaunchCommand(ports.AgentConfig{SessionID: "p-1"})
91+
if got != "" {
92+
t.Fatalf("expected empty on error, got %q", got)
93+
}
94+
}
95+
96+
func TestGetEnvironment_ReturnsAgentEnvKeysOnly(t *testing.T) {
97+
// The richer Agent interface doesn't carry the env keys the SM port supplies,
98+
// so the shim has nothing agent-specific to surface. SM layers AO_* on top.
99+
s := portshim.New(&fakeAgent{})
100+
got := s.GetEnvironment(ports.AgentConfig{SessionID: "p-1"})
101+
if len(got) != 0 {
102+
t.Fatalf("expected empty env from shim, got %v", got)
103+
}
104+
for _, k := range []string{session.EnvSessionID, session.EnvProjectID, session.EnvIssueID} {
105+
if _, ok := got[k]; ok {
106+
t.Errorf("shim must not pre-populate AO env key %s; SM owns it", k)
107+
}
108+
}
109+
}
110+
111+
func TestGetRestoreCommand_JoinsWhenOK(t *testing.T) {
112+
fake := &fakeAgent{restoreCmd: []string{"claude", "--resume", "abc 123"}, restoreOK: true}
113+
s := portshim.New(fake)
114+
got := s.GetRestoreCommand("abc 123")
115+
want := `claude --resume 'abc 123'`
116+
if got != want {
117+
t.Fatalf("got %q want %q", got, want)
118+
}
119+
if fake.gotRestoreCfg.Session.ID != "abc 123" {
120+
t.Errorf("session id not propagated: %+v", fake.gotRestoreCfg)
121+
}
122+
}
123+
124+
func TestGetRestoreCommand_NotOKReturnsEmpty(t *testing.T) {
125+
fake := &fakeAgent{restoreOK: false}
126+
s := portshim.New(fake)
127+
if got := s.GetRestoreCommand("anything"); got != "" {
128+
t.Fatalf("expected empty when not restorable, got %q", got)
129+
}
130+
}
131+
132+
func TestGetRestoreCommand_ErrorReturnsEmpty(t *testing.T) {
133+
fake := &fakeAgent{restoreErr: errors.New("boom")}
134+
s := portshim.New(fake)
135+
if got := s.GetRestoreCommand("x"); got != "" {
136+
t.Fatalf("expected empty on restore error, got %q", got)
137+
}
138+
}
139+
140+
func TestGetRestoreCommand_PassesAgentSessionIDAsMetadata(t *testing.T) {
141+
// Claude-code (and Codex) read the native session id off cfg.Session.Metadata
142+
// ["agentSessionId"] to rebuild the --resume command. Pass it via both Session.ID
143+
// (the legacy fallback) and Session.Metadata so the richer adapter can find it.
144+
fake := &fakeAgent{restoreCmd: []string{"claude", "--resume", "x"}, restoreOK: true}
145+
s := portshim.New(fake)
146+
_ = s.GetRestoreCommand("native-uuid")
147+
gotID := fake.gotRestoreCfg.Session.ID
148+
if gotID != "native-uuid" {
149+
t.Errorf("Session.ID want native-uuid, got %q", gotID)
150+
}
151+
if m := fake.gotRestoreCfg.Session.Metadata[agent.MetadataKeyAgentSessionID]; m != "native-uuid" {
152+
t.Errorf("Session.Metadata[%s] want native-uuid, got %q", agent.MetadataKeyAgentSessionID, m)
153+
}
154+
}
155+
156+
func TestShellQuotingDoesNotDoubleQuoteSafeStrings(t *testing.T) {
157+
// Safe identifiers (letters, digits, dash, dot, slash, underscore) should
158+
// pass through unquoted; quoting them would inflate every command.
159+
s := portshim.New(&fakeAgent{launchCmd: []string{"/usr/local/bin/claude", "--session-id", "abc-123_xyz.uuid"}})
160+
got := s.GetLaunchCommand(ports.AgentConfig{})
161+
if strings.Contains(got, "'") {
162+
t.Fatalf("got unexpected quotes: %q", got)
163+
}
164+
}

0 commit comments

Comments
 (0)