diff --git a/pkg/agentic/issue.go b/pkg/agentic/issue.go index 519b16e..68dedbc 100644 --- a/pkg/agentic/issue.go +++ b/pkg/agentic/issue.go @@ -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"]), diff --git a/pkg/agentic/issue_test.go b/pkg/agentic/issue_test.go index 5156ff5..855026c 100644 --- a/pkg/agentic/issue_test.go +++ b/pkg/agentic/issue_test.go @@ -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) { diff --git a/pkg/agentic/platform_tools.go b/pkg/agentic/platform_tools.go index 796101d..5d2a1c6 100644 --- a/pkg/agentic/platform_tools.go +++ b/pkg/agentic/platform_tools.go @@ -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 } diff --git a/pkg/agentic/session.go b/pkg/agentic/session.go index 0ecdac6..7683297 100644 --- a/pkg/agentic/session.go +++ b/pkg/agentic/session.go @@ -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)) diff --git a/pkg/agentic/session_test.go b/pkg/agentic/session_test.go index 5fb0fdb..96f9a56 100644 --- a/pkg/agentic/session_test.go +++ b/pkg/agentic/session_test.go @@ -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}, )) diff --git a/pkg/agentic/sprint.go b/pkg/agentic/sprint.go index 59d403d..09d5a25 100644 --- a/pkg/agentic/sprint.go +++ b/pkg/agentic/sprint.go @@ -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"]), diff --git a/pkg/agentic/sprint_test.go b/pkg/agentic/sprint_test.go index a583f8d..e3d5409 100644 --- a/pkg/agentic/sprint_test.go +++ b/pkg/agentic/sprint_test.go @@ -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) { diff --git a/pkg/agentic/sync.go b/pkg/agentic/sync.go index 628694a..fa15082 100644 --- a/pkg/agentic/sync.go +++ b/pkg/agentic/sync.go @@ -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() diff --git a/pkg/agentic/sync_test.go b/pkg/agentic/sync_test.go index 7700ace..87050b3 100644 --- a/pkg/agentic/sync_test.go +++ b/pkg/agentic/sync_test.go @@ -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)