feat(agentic): widen RFC compatibility inputs

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 15:42:44 +00:00
parent b84e5692a2
commit 51f05bf789
9 changed files with 263 additions and 56 deletions

View file

@ -50,7 +50,8 @@ type IssueCreateInput struct {
// input := agentic.IssueGetInput{Slug: "fix-auth"}
type IssueGetInput struct {
Slug string `json:"slug"`
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
}
// input := agentic.IssueListInput{Status: "open", Type: "bug"}
@ -64,7 +65,8 @@ type IssueListInput struct {
// input := agentic.IssueUpdateInput{Slug: "fix-auth", Status: "in_progress"}
type IssueUpdateInput struct {
Slug string `json:"slug"`
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
@ -77,7 +79,9 @@ type IssueUpdateInput struct {
// input := agentic.IssueCommentInput{Slug: "fix-auth", Body: "Ready for review"}
type IssueCommentInput struct {
Slug string `json:"slug"`
ID string `json:"id,omitempty"`
IssueID string `json:"issue_id,omitempty"`
Slug string `json:"slug,omitempty"`
Body string `json:"body"`
Author string `json:"author,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
@ -85,7 +89,8 @@ type IssueCommentInput struct {
// input := agentic.IssueArchiveInput{Slug: "fix-auth"}
type IssueArchiveInput struct {
Slug string `json:"slug"`
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
}
// out := agentic.IssueOutput{Success: true, Issue: agentic.Issue{Slug: "fix-auth"}}
@ -134,7 +139,8 @@ func (s *PrepSubsystem) handleIssueRecordCreate(ctx context.Context, options cor
// result := c.Action("issue.get").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"}))
func (s *PrepSubsystem) handleIssueRecordGet(ctx context.Context, options core.Options) core.Result {
_, output, err := s.issueGet(ctx, nil, IssueGetInput{
Slug: optionStringValue(options, "slug", "_arg"),
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
})
if err != nil {
return core.Result{Value: err, OK: false}
@ -160,7 +166,8 @@ func (s *PrepSubsystem) handleIssueRecordList(ctx context.Context, options core.
// result := c.Action("issue.update").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"}))
func (s *PrepSubsystem) handleIssueRecordUpdate(ctx context.Context, options core.Options) core.Result {
_, output, err := s.issueUpdate(ctx, nil, IssueUpdateInput{
Slug: optionStringValue(options, "slug", "_arg"),
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
Title: optionStringValue(options, "title"),
Description: optionStringValue(options, "description"),
Type: optionStringValue(options, "type"),
@ -179,7 +186,9 @@ func (s *PrepSubsystem) handleIssueRecordUpdate(ctx context.Context, options cor
// result := c.Action("issue.comment").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"}))
func (s *PrepSubsystem) handleIssueRecordComment(ctx context.Context, options core.Options) core.Result {
_, output, err := s.issueComment(ctx, nil, IssueCommentInput{
Slug: optionStringValue(options, "slug", "_arg"),
ID: optionStringValue(options, "id", "_arg"),
IssueID: optionStringValue(options, "issue_id", "issue-id"),
Slug: optionStringValue(options, "slug"),
Body: optionStringValue(options, "body"),
Author: optionStringValue(options, "author"),
Metadata: optionAnyMapValue(options, "metadata"),
@ -193,7 +202,8 @@ func (s *PrepSubsystem) handleIssueRecordComment(ctx context.Context, options co
// result := c.Action("issue.archive").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"}))
func (s *PrepSubsystem) handleIssueRecordArchive(ctx context.Context, options core.Options) core.Result {
_, output, err := s.issueArchive(ctx, nil, IssueArchiveInput{
Slug: optionStringValue(options, "slug", "_arg"),
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
})
if err != nil {
return core.Result{Value: err, OK: false}
@ -275,11 +285,12 @@ func (s *PrepSubsystem) issueCreate(ctx context.Context, _ *mcp.CallToolRequest,
}
func (s *PrepSubsystem) issueGet(ctx context.Context, _ *mcp.CallToolRequest, input IssueGetInput) (*mcp.CallToolResult, IssueOutput, error) {
if input.Slug == "" {
return nil, IssueOutput{}, core.E("issueGet", "slug is required", nil)
identifier := issueRecordIdentifier(input.Slug, input.ID)
if identifier == "" {
return nil, IssueOutput{}, core.E("issueGet", "id or slug is required", nil)
}
result := s.platformPayload(ctx, "issue.get", "GET", core.Concat("/v1/issues/", input.Slug), nil)
result := s.platformPayload(ctx, "issue.get", "GET", core.Concat("/v1/issues/", identifier), nil)
if !result.OK {
return nil, IssueOutput{}, resultErrorValue("issue.get", result)
}
@ -311,8 +322,9 @@ func (s *PrepSubsystem) issueList(ctx context.Context, _ *mcp.CallToolRequest, i
}
func (s *PrepSubsystem) issueUpdate(ctx context.Context, _ *mcp.CallToolRequest, input IssueUpdateInput) (*mcp.CallToolResult, IssueOutput, error) {
if input.Slug == "" {
return nil, IssueOutput{}, core.E("issueUpdate", "slug is required", nil)
identifier := issueRecordIdentifier(input.Slug, input.ID)
if identifier == "" {
return nil, IssueOutput{}, core.E("issueUpdate", "id or slug is required", nil)
}
body := map[string]any{}
@ -344,7 +356,7 @@ func (s *PrepSubsystem) issueUpdate(ctx context.Context, _ *mcp.CallToolRequest,
return nil, IssueOutput{}, core.E("issueUpdate", "at least one field is required", nil)
}
result := s.platformPayload(ctx, "issue.update", "PATCH", core.Concat("/v1/issues/", input.Slug), body)
result := s.platformPayload(ctx, "issue.update", "PATCH", core.Concat("/v1/issues/", identifier), body)
if !result.OK {
return nil, IssueOutput{}, resultErrorValue("issue.update", result)
}
@ -356,8 +368,9 @@ func (s *PrepSubsystem) issueUpdate(ctx context.Context, _ *mcp.CallToolRequest,
}
func (s *PrepSubsystem) issueComment(ctx context.Context, _ *mcp.CallToolRequest, input IssueCommentInput) (*mcp.CallToolResult, IssueCommentOutput, error) {
if input.Slug == "" {
return nil, IssueCommentOutput{}, core.E("issueComment", "slug is required", nil)
identifier := issueRecordIdentifier(input.Slug, input.IssueID, input.ID)
if identifier == "" {
return nil, IssueCommentOutput{}, core.E("issueComment", "issue_id, id, or slug is required", nil)
}
if input.Body == "" {
return nil, IssueCommentOutput{}, core.E("issueComment", "body is required", nil)
@ -373,7 +386,7 @@ func (s *PrepSubsystem) issueComment(ctx context.Context, _ *mcp.CallToolRequest
body["metadata"] = input.Metadata
}
result := s.platformPayload(ctx, "issue.comment", "POST", core.Concat("/v1/issues/", input.Slug, "/comments"), body)
result := s.platformPayload(ctx, "issue.comment", "POST", core.Concat("/v1/issues/", identifier, "/comments"), body)
if !result.OK {
return nil, IssueCommentOutput{}, resultErrorValue("issue.comment", result)
}
@ -385,18 +398,19 @@ func (s *PrepSubsystem) issueComment(ctx context.Context, _ *mcp.CallToolRequest
}
func (s *PrepSubsystem) issueArchive(ctx context.Context, _ *mcp.CallToolRequest, input IssueArchiveInput) (*mcp.CallToolResult, IssueArchiveOutput, error) {
if input.Slug == "" {
return nil, IssueArchiveOutput{}, core.E("issueArchive", "slug is required", nil)
identifier := issueRecordIdentifier(input.Slug, input.ID)
if identifier == "" {
return nil, IssueArchiveOutput{}, core.E("issueArchive", "id or slug is required", nil)
}
result := s.platformPayload(ctx, "issue.archive", "DELETE", core.Concat("/v1/issues/", input.Slug), nil)
result := s.platformPayload(ctx, "issue.archive", "DELETE", core.Concat("/v1/issues/", identifier), nil)
if !result.OK {
return nil, IssueArchiveOutput{}, resultErrorValue("issue.archive", result)
}
output := IssueArchiveOutput{
Success: true,
Archived: input.Slug,
Archived: identifier,
}
if values := payloadResourceMap(result.Value.(map[string]any), "issue", "result"); len(values) > 0 {
if slug := stringValue(values["slug"]); slug != "" {
@ -427,6 +441,15 @@ func parseIssue(values map[string]any) Issue {
}
}
func issueRecordIdentifier(values ...string) string {
for _, value := range values {
if trimmed := core.Trim(value); trimmed != "" {
return trimmed
}
}
return ""
}
func parseIssueComment(values map[string]any) IssueComment {
return IssueComment{
ID: intValue(values["id"]),

View file

@ -53,7 +53,26 @@ func TestIssue_HandleIssueRecordGet_Bad(t *testing.T) {
result := subsystem.handleIssueRecordGet(context.Background(), core.NewOptions())
assert.False(t, result.OK)
assert.EqualError(t, result.Value.(error), "issueGet: slug is required")
assert.EqualError(t, result.Value.(error), "issueGet: id or slug is required")
}
func TestIssue_HandleIssueRecordGet_Good_IDAlias(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/issues/42", r.URL.Path)
_, _ = w.Write([]byte(`{"data":{"id":42,"slug":"fix-auth","title":"Fix auth","status":"open"}}`))
}))
defer server.Close()
subsystem := testPrepWithPlatformServer(t, server, "secret-token")
result := subsystem.handleIssueRecordGet(context.Background(), core.NewOptions(
core.Option{Key: "id", Value: "42"},
))
require.True(t, result.OK)
output, ok := result.Value.(IssueOutput)
require.True(t, ok)
assert.Equal(t, 42, output.Issue.ID)
assert.Equal(t, "fix-auth", output.Issue.Slug)
}
func TestIssue_HandleIssueRecordList_Ugly_NestedEnvelope(t *testing.T) {

View file

@ -137,7 +137,7 @@ func (s *PrepSubsystem) registerPlatformTools(server *mcp.Server) {
}
func (s *PrepSubsystem) syncPushTool(ctx context.Context, _ *mcp.CallToolRequest, input SyncPushInput) (*mcp.CallToolResult, SyncPushOutput, error) {
output, err := s.syncPush(ctx, input.AgentID)
output, err := s.syncPushInput(ctx, input)
if err != nil {
return nil, SyncPushOutput{}, err
}
@ -145,7 +145,7 @@ func (s *PrepSubsystem) syncPushTool(ctx context.Context, _ *mcp.CallToolRequest
}
func (s *PrepSubsystem) syncPullTool(ctx context.Context, _ *mcp.CallToolRequest, input SyncPullInput) (*mcp.CallToolResult, SyncPullOutput, error) {
output, err := s.syncPull(ctx, input.AgentID)
output, err := s.syncPullInput(ctx, input)
if err != nil {
return nil, SyncPullOutput{}, err
}

View file

@ -42,9 +42,10 @@ type SessionGetInput struct {
// input := agentic.SessionListInput{PlanSlug: "ax-follow-up", Status: "active"}
type SessionListInput struct {
PlanSlug string `json:"plan_slug,omitempty"`
Status string `json:"status,omitempty"`
Limit int `json:"limit,omitempty"`
PlanSlug string `json:"plan_slug,omitempty"`
AgentType string `json:"agent_type,omitempty"`
Status string `json:"status,omitempty"`
Limit int `json:"limit,omitempty"`
}
// input := agentic.SessionContinueInput{SessionID: "ses_abc123", AgentType: "codex"}
@ -177,9 +178,10 @@ func (s *PrepSubsystem) handleSessionGet(ctx context.Context, options core.Optio
// result := c.Action("session.list").Run(ctx, core.NewOptions(core.Option{Key: "status", Value: "active"}))
func (s *PrepSubsystem) handleSessionList(ctx context.Context, options core.Options) core.Result {
_, output, err := s.sessionList(ctx, nil, SessionListInput{
PlanSlug: optionStringValue(options, "plan_slug", "plan"),
Status: optionStringValue(options, "status"),
Limit: optionIntValue(options, "limit"),
PlanSlug: optionStringValue(options, "plan_slug", "plan"),
AgentType: optionStringValue(options, "agent_type", "agent"),
Status: optionStringValue(options, "status"),
Limit: optionIntValue(options, "limit"),
})
if err != nil {
return core.Result{Value: err, OK: false}
@ -394,6 +396,7 @@ func (s *PrepSubsystem) sessionGet(ctx context.Context, _ *mcp.CallToolRequest,
func (s *PrepSubsystem) sessionList(ctx context.Context, _ *mcp.CallToolRequest, input SessionListInput) (*mcp.CallToolResult, SessionListOutput, error) {
path := "/v1/sessions"
path = appendQueryParam(path, "plan_slug", input.PlanSlug)
path = appendQueryParam(path, "agent_type", input.AgentType)
path = appendQueryParam(path, "status", input.Status)
if input.Limit > 0 {
path = appendQueryParam(path, "limit", core.Sprint(input.Limit))

View file

@ -109,6 +109,7 @@ func TestSession_HandleSessionList_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/sessions", r.URL.Path)
require.Equal(t, "ax-follow-up", r.URL.Query().Get("plan_slug"))
require.Equal(t, "codex", r.URL.Query().Get("agent_type"))
require.Equal(t, "active", r.URL.Query().Get("status"))
require.Equal(t, "5", r.URL.Query().Get("limit"))
_, _ = w.Write([]byte(`{"data":[{"session_id":"ses_1","agent_type":"codex","status":"active"},{"session_id":"ses_2","agent_type":"claude","status":"completed"}],"count":2}`))
@ -118,6 +119,7 @@ func TestSession_HandleSessionList_Good(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, server, "secret-token")
result := subsystem.handleSessionList(context.Background(), core.NewOptions(
core.Option{Key: "plan_slug", Value: "ax-follow-up"},
core.Option{Key: "agent_type", Value: "codex"},
core.Option{Key: "status", Value: "active"},
core.Option{Key: "limit", Value: 5},
))

View file

@ -36,7 +36,8 @@ type SprintCreateInput struct {
// input := agentic.SprintGetInput{Slug: "ax-follow-up"}
type SprintGetInput struct {
Slug string `json:"slug"`
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
}
// input := agentic.SprintListInput{Status: "active", Limit: 10}
@ -47,7 +48,8 @@ type SprintListInput struct {
// input := agentic.SprintUpdateInput{Slug: "ax-follow-up", Status: "completed"}
type SprintUpdateInput struct {
Slug string `json:"slug"`
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
Title string `json:"title,omitempty"`
Goal string `json:"goal,omitempty"`
Status string `json:"status,omitempty"`
@ -58,7 +60,8 @@ type SprintUpdateInput struct {
// input := agentic.SprintArchiveInput{Slug: "ax-follow-up"}
type SprintArchiveInput struct {
Slug string `json:"slug"`
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
}
// out := agentic.SprintOutput{Success: true, Sprint: agentic.Sprint{Slug: "ax-follow-up"}}
@ -99,7 +102,8 @@ func (s *PrepSubsystem) handleSprintCreate(ctx context.Context, options core.Opt
// result := c.Action("sprint.get").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "ax-follow-up"}))
func (s *PrepSubsystem) handleSprintGet(ctx context.Context, options core.Options) core.Result {
_, output, err := s.sprintGet(ctx, nil, SprintGetInput{
Slug: optionStringValue(options, "slug", "_arg"),
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
})
if err != nil {
return core.Result{Value: err, OK: false}
@ -122,7 +126,8 @@ func (s *PrepSubsystem) handleSprintList(ctx context.Context, options core.Optio
// result := c.Action("sprint.update").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "ax-follow-up"}))
func (s *PrepSubsystem) handleSprintUpdate(ctx context.Context, options core.Options) core.Result {
_, output, err := s.sprintUpdate(ctx, nil, SprintUpdateInput{
Slug: optionStringValue(options, "slug", "_arg"),
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
Title: optionStringValue(options, "title"),
Goal: optionStringValue(options, "goal"),
Status: optionStringValue(options, "status"),
@ -139,7 +144,8 @@ func (s *PrepSubsystem) handleSprintUpdate(ctx context.Context, options core.Opt
// result := c.Action("sprint.archive").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "ax-follow-up"}))
func (s *PrepSubsystem) handleSprintArchive(ctx context.Context, options core.Options) core.Result {
_, output, err := s.sprintArchive(ctx, nil, SprintArchiveInput{
Slug: optionStringValue(options, "slug", "_arg"),
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
})
if err != nil {
return core.Result{Value: err, OK: false}
@ -210,11 +216,12 @@ func (s *PrepSubsystem) sprintCreate(ctx context.Context, _ *mcp.CallToolRequest
}
func (s *PrepSubsystem) sprintGet(ctx context.Context, _ *mcp.CallToolRequest, input SprintGetInput) (*mcp.CallToolResult, SprintOutput, error) {
if input.Slug == "" {
return nil, SprintOutput{}, core.E("sprintGet", "slug is required", nil)
identifier := sprintIdentifier(input.Slug, input.ID)
if identifier == "" {
return nil, SprintOutput{}, core.E("sprintGet", "id or slug is required", nil)
}
result := s.platformPayload(ctx, "sprint.get", "GET", core.Concat("/v1/sprints/", input.Slug), nil)
result := s.platformPayload(ctx, "sprint.get", "GET", core.Concat("/v1/sprints/", identifier), nil)
if !result.OK {
return nil, SprintOutput{}, resultErrorValue("sprint.get", result)
}
@ -241,8 +248,9 @@ func (s *PrepSubsystem) sprintList(ctx context.Context, _ *mcp.CallToolRequest,
}
func (s *PrepSubsystem) sprintUpdate(ctx context.Context, _ *mcp.CallToolRequest, input SprintUpdateInput) (*mcp.CallToolResult, SprintOutput, error) {
if input.Slug == "" {
return nil, SprintOutput{}, core.E("sprintUpdate", "slug is required", nil)
identifier := sprintIdentifier(input.Slug, input.ID)
if identifier == "" {
return nil, SprintOutput{}, core.E("sprintUpdate", "id or slug is required", nil)
}
body := map[string]any{}
@ -268,7 +276,7 @@ func (s *PrepSubsystem) sprintUpdate(ctx context.Context, _ *mcp.CallToolRequest
return nil, SprintOutput{}, core.E("sprintUpdate", "at least one field is required", nil)
}
result := s.platformPayload(ctx, "sprint.update", "PATCH", core.Concat("/v1/sprints/", input.Slug), body)
result := s.platformPayload(ctx, "sprint.update", "PATCH", core.Concat("/v1/sprints/", identifier), body)
if !result.OK {
return nil, SprintOutput{}, resultErrorValue("sprint.update", result)
}
@ -280,18 +288,19 @@ func (s *PrepSubsystem) sprintUpdate(ctx context.Context, _ *mcp.CallToolRequest
}
func (s *PrepSubsystem) sprintArchive(ctx context.Context, _ *mcp.CallToolRequest, input SprintArchiveInput) (*mcp.CallToolResult, SprintArchiveOutput, error) {
if input.Slug == "" {
return nil, SprintArchiveOutput{}, core.E("sprintArchive", "slug is required", nil)
identifier := sprintIdentifier(input.Slug, input.ID)
if identifier == "" {
return nil, SprintArchiveOutput{}, core.E("sprintArchive", "id or slug is required", nil)
}
result := s.platformPayload(ctx, "sprint.archive", "DELETE", core.Concat("/v1/sprints/", input.Slug), nil)
result := s.platformPayload(ctx, "sprint.archive", "DELETE", core.Concat("/v1/sprints/", identifier), nil)
if !result.OK {
return nil, SprintArchiveOutput{}, resultErrorValue("sprint.archive", result)
}
output := SprintArchiveOutput{
Success: true,
Archived: input.Slug,
Archived: identifier,
}
if values := payloadResourceMap(result.Value.(map[string]any), "sprint", "result"); len(values) > 0 {
if slug := stringValue(values["slug"]); slug != "" {
@ -304,6 +313,15 @@ func (s *PrepSubsystem) sprintArchive(ctx context.Context, _ *mcp.CallToolReques
return nil, output, nil
}
func sprintIdentifier(values ...string) string {
for _, value := range values {
if trimmed := core.Trim(value); trimmed != "" {
return trimmed
}
}
return ""
}
func parseSprint(values map[string]any) Sprint {
return Sprint{
ID: intValue(values["id"]),

View file

@ -50,7 +50,26 @@ func TestSprint_HandleSprintGet_Bad(t *testing.T) {
result := subsystem.handleSprintGet(context.Background(), core.NewOptions())
assert.False(t, result.OK)
assert.EqualError(t, result.Value.(error), "sprintGet: slug is required")
assert.EqualError(t, result.Value.(error), "sprintGet: id or slug is required")
}
func TestSprint_HandleSprintGet_Good_IDAlias(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/sprints/7", r.URL.Path)
_, _ = w.Write([]byte(`{"data":{"id":7,"slug":"ax-follow-up","title":"AX Follow-up","status":"active"}}`))
}))
defer server.Close()
subsystem := testPrepWithPlatformServer(t, server, "secret-token")
result := subsystem.handleSprintGet(context.Background(), core.NewOptions(
core.Option{Key: "id", Value: "7"},
))
require.True(t, result.OK)
output, ok := result.Value.(SprintOutput)
require.True(t, ok)
assert.Equal(t, 7, output.Sprint.ID)
assert.Equal(t, "ax-follow-up", output.Sprint.Slug)
}
func TestSprint_HandleSprintList_Ugly_NestedEnvelope(t *testing.T) {

View file

@ -10,7 +10,8 @@ import (
)
type SyncPushInput struct {
AgentID string `json:"agent_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
Dispatches []map[string]any `json:"dispatches,omitempty"`
}
type SyncPushOutput struct {
@ -20,6 +21,7 @@ type SyncPushOutput struct {
type SyncPullInput struct {
AgentID string `json:"agent_id,omitempty"`
Since string `json:"since,omitempty"`
}
type SyncPullOutput struct {
@ -41,7 +43,10 @@ type syncStatusState struct {
// result := c.Action("agentic.sync.push").Run(ctx, core.NewOptions())
func (s *PrepSubsystem) handleSyncPush(ctx context.Context, options core.Options) core.Result {
output, err := s.syncPush(ctx, options.String("agent_id"))
output, err := s.syncPushInput(ctx, SyncPushInput{
AgentID: optionStringValue(options, "agent_id", "agent-id", "_arg"),
Dispatches: optionAnyMapSliceValue(options, "dispatches"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
@ -50,7 +55,10 @@ func (s *PrepSubsystem) handleSyncPush(ctx context.Context, options core.Options
// result := c.Action("agentic.sync.pull").Run(ctx, core.NewOptions())
func (s *PrepSubsystem) handleSyncPull(ctx context.Context, options core.Options) core.Result {
output, err := s.syncPull(ctx, options.String("agent_id"))
output, err := s.syncPullInput(ctx, SyncPullInput{
AgentID: optionStringValue(options, "agent_id", "agent-id", "_arg"),
Since: optionStringValue(options, "since"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
@ -58,15 +66,19 @@ func (s *PrepSubsystem) handleSyncPull(ctx context.Context, options core.Options
}
func (s *PrepSubsystem) syncPush(ctx context.Context, agentID string) (SyncPushOutput, error) {
return s.syncPushInput(ctx, SyncPushInput{AgentID: agentID})
}
func (s *PrepSubsystem) syncPushInput(ctx context.Context, input SyncPushInput) (SyncPushOutput, error) {
agentID := input.AgentID
if agentID == "" {
agentID = AgentName()
}
dispatches := collectSyncDispatches()
token := s.syncToken()
if token == "" {
return SyncPushOutput{Success: true, Count: 0}, nil
dispatches := input.Dispatches
if len(dispatches) == 0 {
dispatches = collectSyncDispatches()
}
token := s.syncToken()
queuedPushes := readSyncQueue()
if len(dispatches) > 0 {
queuedPushes = append(queuedPushes, syncQueuedPush{
@ -75,6 +87,12 @@ func (s *PrepSubsystem) syncPush(ctx context.Context, agentID string) (SyncPushO
QueuedAt: time.Now(),
})
}
if token == "" {
if len(input.Dispatches) > 0 {
writeSyncQueue(queuedPushes)
}
return SyncPushOutput{Success: true, Count: 0}, nil
}
if len(queuedPushes) == 0 {
return SyncPushOutput{Success: true, Count: 0}, nil
}
@ -97,6 +115,11 @@ func (s *PrepSubsystem) syncPush(ctx context.Context, agentID string) (SyncPushO
}
func (s *PrepSubsystem) syncPull(ctx context.Context, agentID string) (SyncPullOutput, error) {
return s.syncPullInput(ctx, SyncPullInput{AgentID: agentID})
}
func (s *PrepSubsystem) syncPullInput(ctx context.Context, input SyncPullInput) (SyncPullOutput, error) {
agentID := input.AgentID
if agentID == "" {
agentID = AgentName()
}
@ -106,7 +129,9 @@ func (s *PrepSubsystem) syncPull(ctx context.Context, agentID string) (SyncPullO
return SyncPullOutput{Success: true, Count: len(cached), Context: cached}, nil
}
endpoint := core.Concat(s.syncAPIURL(), "/v1/agent/context?agent_id=", agentID)
path := appendQueryParam("/v1/agent/context", "agent_id", agentID)
path = appendQueryParam(path, "since", input.Since)
endpoint := core.Concat(s.syncAPIURL(), path)
result := HTTPGet(ctx, endpoint, token, "Bearer")
if !result.OK {
cached := readSyncContext()

View file

@ -62,6 +62,51 @@ func TestSync_HandleSyncPush_Good(t *testing.T) {
assert.False(t, readSyncStatusState().LastPushAt.IsZero())
}
func TestSync_HandleSyncPush_Good_UsesProvidedDispatches(t *testing.T) {
root := t.TempDir()
t.Setenv("CORE_WORKSPACE", root)
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/agent/sync", r.URL.Path)
bodyResult := core.ReadAll(r.Body)
require.True(t, bodyResult.OK)
var payload map[string]any
parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload)
require.True(t, parseResult.OK)
require.Equal(t, "charon", payload["agent_id"])
dispatches, ok := payload["dispatches"].([]any)
require.True(t, ok)
require.Len(t, dispatches, 1)
record, ok := dispatches[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "external-1", record["workspace"])
require.Equal(t, "completed", record["status"])
_, _ = w.Write([]byte(`{"data":{"synced":1}}`))
}))
defer server.Close()
subsystem := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
brainURL: server.URL,
}
output, err := subsystem.syncPushInput(context.Background(), SyncPushInput{
AgentID: "charon",
Dispatches: []map[string]any{
{"workspace": "external-1", "status": "completed"},
},
})
require.NoError(t, err)
assert.True(t, output.Success)
assert.Equal(t, 1, output.Count)
assert.Empty(t, readSyncQueue())
}
func TestSync_HandleSyncPush_Bad(t *testing.T) {
root := t.TempDir()
t.Setenv("CORE_WORKSPACE", root)
@ -90,6 +135,31 @@ func TestSync_HandleSyncPush_Bad(t *testing.T) {
assert.Empty(t, readSyncQueue())
}
func TestSync_HandleSyncPush_Bad_QueuesProvidedDispatchesWhenOffline(t *testing.T) {
root := t.TempDir()
t.Setenv("CORE_WORKSPACE", root)
t.Setenv("CORE_AGENT_API_KEY", "")
subsystem := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
}
output, err := subsystem.syncPushInput(context.Background(), SyncPushInput{
AgentID: "charon",
Dispatches: []map[string]any{
{"workspace": "external-1", "status": "completed"},
},
})
require.NoError(t, err)
assert.True(t, output.Success)
assert.Equal(t, 0, output.Count)
queued := readSyncQueue()
require.Len(t, queued, 1)
assert.Equal(t, "charon", queued[0].AgentID)
require.Len(t, queued[0].Dispatches, 1)
assert.Equal(t, "external-1", queued[0].Dispatches[0]["workspace"])
}
func TestSync_HandleSyncPush_Ugly(t *testing.T) {
root := t.TempDir()
t.Setenv("CORE_WORKSPACE", root)
@ -159,6 +229,34 @@ func TestSync_HandleSyncPull_Good(t *testing.T) {
assert.False(t, readSyncStatusState().LastPullAt.IsZero())
}
func TestSync_HandleSyncPull_Good_SinceQuery(t *testing.T) {
root := t.TempDir()
t.Setenv("CORE_WORKSPACE", root)
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/agent/context", r.URL.Path)
require.Equal(t, "codex", r.URL.Query().Get("agent_id"))
require.Equal(t, "2026-03-30T00:00:00Z", r.URL.Query().Get("since"))
_, _ = w.Write([]byte(`{"data":[{"id":"mem-2","content":"Recent pattern"}]}`))
}))
defer server.Close()
subsystem := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
brainURL: server.URL,
}
output, err := subsystem.syncPullInput(context.Background(), SyncPullInput{
AgentID: "codex",
Since: "2026-03-30T00:00:00Z",
})
require.NoError(t, err)
assert.True(t, output.Success)
assert.Equal(t, 1, output.Count)
require.Len(t, output.Context, 1)
assert.Equal(t, "mem-2", output.Context[0]["id"])
}
func TestSync_HandleSyncPush_Good_ReportMetadata(t *testing.T) {
root := t.TempDir()
t.Setenv("CORE_WORKSPACE", root)