diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 981323a67..a4637edb7 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -14,6 +14,8 @@ Two errors are shared across domains, each with one `api` helper: `textvalue.Err Shared vocabulary has one owner each, and domains use it rather than copy it. `internal/environmentconfig` owns Environment setup, Skills, Plugins and initial files with their validation and public metadata; `Setup.Validate` checks requested configuration, where a Skill may be an unresolved reference, and `Setup.ValidateInstalled` checks frozen, installable configuration. `internal/skills` owns `ParseVersion`, the canonical positive decimal Skill version. `internal/metadata` owns the metadata rules: `Validate` for the pair, key and value limits and U+0000, `ValidateStorable` for U+0000 alone, and `Encode` with its 64 KiB bound. `internal/jsonobject` owns `Normalize`, the stable encoding of stored JSON objects that snapshots and retry identities compare. These packages import no persistence. +`internal/sessions` owns the Session change vocabulary and its decisions: the public changes that report Turn and Session transitions, what a Turn that ends settles, measured Turn usage and the Session activity a change reports. `internal/items` owns public Items: it projects observations, merges them into stored Items and builds the ordered events that report each Item change. Neither imports persistence. `internal/persistence/postgres/sessionpg` loads the facts those decisions read and applies them inside the caller's Session transaction, under the Session lock: it allocates event sequence positions, event IDs, Item positions and output indexes, writes the journal, Items, Turn usage and Artifact settlement, and prunes the journal. It decides nothing. + `store` is transitional. `store.New` builds a pooled Store, and `store.NewExecution(s, lease)` builds the execution writer on a lease it borrows. An execution-only operation on a pooled Store fails with `store.ErrExecutionAuthority`. New adapters do not copy that check: their execution repositories require a `*pgunit.Lease` at construction, their public repositories expose no execution operation, and the check goes away with `store`. `cmd/server` owns the execution lease. It acquires one `pgunit.Lease`, builds every lease-bound adapter on it, and passes the lease and those adapters together as one `execution.Owner` to `execution.StartWorker`. If anything fails before that call, `cmd/server` closes the lease. From that call the Worker owns cleanup: a failed start closes the lease before it returns, and a started Worker closes it after `Run` has cancelled and drained its work. Each close runs under its own bounded deadline, independent of the cancelled request or run. Lease-bound adapters and the store writer borrow the lease and never close it, and the Worker uses the lease only through `Owner.Lease`, never through an adapter. Store integration tests start the Worker the same way through `startWorker`. @@ -44,7 +46,7 @@ Source Files are Project resources with a lifecycle independent of copied worksp Session Artifacts are immutable published copies, separate from live workspace files and source Files. The private output exporter reuses the authorized workspace path boundary and streams bounded bytes; publication requires complete capture and confirmed helper and transport success, not merely valid archive syntax. The daemon owns and drains the exporter's stdout pipe separately from child reaping, so pull-transport backpressure cannot consume the process-exit I/O deadline; after helper exit, each pipe read has one second, reset after consumer delays, which rejects inherited pipes that never close. Cancellation closes the owned reader and the dispatch consumer, then waits for the child. Never extract an output archive into Core's filesystem or hold the execution lease through a large transfer. -Capture bytes into private large objects without a Session admission lock. Before capture, seal native input under that lock with the private capture marker; later messages reuse the Environment input reservation and wait for the next Turn, and the public Turn stays in progress until publication settles. Directory reads during capture use an independent read-only preparation, not the released native Run. After a confirmed export, lock and recheck the live Turn, then publish the metadata in the same transaction as the Turn's completion. Decide Artifact republication in the Turn's terminal transaction, not the capture transaction: capture commits and releases the Session lock before the Turn completes, and the terminal transaction holds the Session lock that also orders Artifact deletion. Drop staged rows whose SHA-256 equals the newest remaining published Artifact for the path, ordered by the producing Turn's creation time and then ID (publication time can come from the Runtime and does not order Turns), and unlink their large objects in the same transaction. Published rows are never modified. Failed and cancelled Turns discard private objects, and Session deletion removes private and published copies. The exporter skips output symlinks by their `lstat` type without following them; hard links, other special files, device crossings and concurrent changes reject the capture. Stored reads are authorized independently of Environment availability, so published Artifacts outlive the Environment. +Capture bytes into private large objects without a Session admission lock. Before capture, seal native input under that lock with the private capture marker; later messages reuse the Environment input reservation and wait for the next Turn, and the public Turn stays in progress until publication settles. Directory reads during capture use an independent read-only preparation, not the released native Run. After a confirmed export, lock and recheck the live Turn, then publish the metadata in the same transaction as the Turn's completion. `sessions.EndTurn` decides Artifact publication and `sessionpg` applies it in the Turn's terminal transaction, not the capture transaction: capture commits and releases the Session lock before the Turn completes, and the terminal transaction holds the Session lock that also orders Artifact deletion. Drop staged rows whose SHA-256 equals the newest remaining published Artifact for the path, ordered by the producing Turn's creation time and then ID (publication time can come from the Runtime and does not order Turns), and unlink their large objects in the same transaction. Published rows are never modified. Failed and cancelled Turns discard private objects, and Session deletion removes private and published copies. The exporter skips output symlinks by their `lstat` type without following them; hard links, other special files, device crossings and concurrent changes reject the capture. Stored reads are authorized independently of Environment availability, so published Artifacts outlive the Environment. - A malformed `environment_id` Artifact filter resolves to the never-assigned maximum UUID, so it matches nothing without a text comparison. @@ -139,7 +141,7 @@ Accepted public MCP credential profiles are declared centrally by the service, s `services/core/internal/execution` claims a Turn from `queued` to `in_progress` before subscribing or sending, and never replays a claimed or interrupted Turn. Extra inputs require native receipts. The terminal outcome and the native Session ID commit together under the admission lock, and unapplied messages prevent a successful completion. Credentials are resolved separately from the immutable non-secret snapshot. A peer that lacks a required capability is rejected before the claim, and a failed strict resume never starts unrelated history. Native continuation needs the device's persisted engine files; a native Session ID alone cannot restore deleted history. -Core replaces a Turn's usage with each complete valid token breakdown and keeps the last committed measurement when execution is interrupted. It never infers tokens from context occupancy or costs and never parses native raw payloads. When `done` also carries usage that was already reported, the counters are not added again. +Core replaces a Turn's usage with each complete valid token breakdown, which `sessions.MeasuredUsage` decides, and keeps the last committed measurement when execution is interrupted. It never infers tokens from context occupancy or costs and never parses native raw payloads. When `done` also carries usage that was already reported, the counters are not added again. Subagent observations use the common types in `internal/agentdaemon/proto/subagents.go`. Core assigns public IDs and projects them under the Session lock and the leased execution journal; native names, history parsing and outcome proof stay in the adapters. Child Turns have their own table, separate from Core's queue, and Session Turn reads and the Session event stream carry root work only. The migration-defined `public_execution_turns` view has no public reader; do not reintroduce mixed Session Turn pages. Repeated effects are idempotent, root output freezes first, and child Items are delivered before their terminal Turn snapshot. @@ -151,15 +153,15 @@ Neutral tool and message observations go into the journal before Core projects p Execution observations are written to tenant-scoped `turn_events` in ordered, idempotent batches before they back recovery or publication, with daemon payloads intact. Flush at least every 100 ms while consuming events and before terminal persistence; the terminal outcome, its journal entry and native continuity commit together. Journal limits are 512 KiB per payload, 1 MiB per batch, 65,536 observations and 32 MiB per Turn, with one extra entry reserved for the terminal outcome. Never infer a successful completion after a persistence error or stream overflow. -Live Session SSE reads `session_events` committed with the corresponding input, Item or lifecycle change under the Session lock, as immutable transition snapshots. The notification buffer keeps at most 256 events and 64 MiB per Session after each transaction, and read batches at most 32 events or 1 MiB, each keeping a single oversized event. GET polls committed events every 100 ms from the committed high-water mark; a missing sequence position ends the stream with a safe error. Socket writes have a five-second deadline and hold no database connection. Rebuilding historical indexes emits no live events. +Live Session SSE reads `session_events` committed with the corresponding input, Item or lifecycle change under the Session lock, as immutable transition snapshots. The notification buffer keeps at most 256 events and 64 MiB per Session after each transaction (the `sessions` retention bounds, which `sessionpg` applies), and read batches at most 32 events or 1 MiB, each keeping a single oversized event. GET polls committed events every 100 ms from the committed high-water mark; a missing sequence position ends the stream with a safe error. Socket writes have a five-second deadline and hold no database connection. Rebuilding historical indexes emits no live events. -A streaming Session creation reuses atomic input admission and the live event loop. The creation upsert returns its event cursor under the Session lock, before the initial inputs; never replace it with a post-commit cursor lookup. The settled marker and pending-input flag the stream uses to end are store-internal, never wire fields. The stream rereads the Session projection after it sends a Session status event and otherwise at most once a second. +A streaming Session creation reuses atomic input admission and the live event loop. The creation upsert returns its event cursor under the Session lock, before the initial inputs; never replace it with a post-commit cursor lookup. The settled marker on `sessions.SessionChange` and the pending-input flag the stream uses to end are internal, never wire fields. The stream rereads the Session projection after it sends a Session status event and otherwise at most once a second. ## Items Public Items read a projection updated in the same Session transaction as admitted messages and journal batches. Item IDs derive from the Turn and source identity, and the first-observation timestamp and tie breakers never change when content or status does. Each new Item's Session position is allocated under the Session lock, preserving observation order for equal timestamps, and each Turn allocates its own zero-based `output_index`, which inputs do not consume; updates and retries keep both. -Item merging never mutates the incoming observation or the previous snapshot: public text delta events read the original fragment after merging, while the Item keeps the accumulated text, and the content slice is copied before its text pointer is replaced. A first observation without its own fragment carries its unchanged text in one delta. Wire-only explicit nulls come from response marshalling, while stored Item payloads keep their original encoding through `Item.MarshalStored`, so replayed child Items compare equal. Structured tool JSON is kept without float conversion, and an unfinished call never becomes a successful result. Function results are Session input Items: they emit `item.added` with a null `output_index` and never `item.done`, whose upstream union allows only agent output, and their public output and error come from the saved submission. +Item merging never mutates the incoming observation or the previous snapshot: public text delta events read the original fragment after merging, while the Item keeps the accumulated text, and the content slice is copied before its text pointer is replaced. A first observation without its own fragment carries its unchanged text in one delta. Wire-only explicit nulls come from response marshalling, while stored Item payloads keep their original encoding through `Item.MarshalStored`, so replayed child Items compare equal. Structured tool JSON is kept without float conversion, and an unfinished call never becomes a successful result. When a Turn ends, `sessions.EndTurn` makes its unfinished Items incomplete with their partial content and reports them in Session position order before the Turn's event and its settled Session activity; `sessionpg` gives them one shared settlement time. Function results are Session input Items: they emit `item.added` with a null `output_index` and never `item.done`, whose upstream union allows only agent output, and their public output and error come from the saved submission. ## Worker ownership diff --git a/services/core/internal/api/admin_resources_test.go b/services/core/internal/api/admin_resources_test.go index b8f7cb365..174fb205d 100644 --- a/services/core/internal/api/admin_resources_test.go +++ b/services/core/internal/api/admin_resources_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -123,7 +124,7 @@ func (s *summaryFixture) ReadAdminSummary(_ context.Context, tenant string, filt for i, usage := range []json.RawMessage{nil, json.RawMessage(`{"input_tokens":3,"output_tokens":5,"total_tokens":8,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":1}}`)} { session := store.Session{ID: "session", TenantID: tenant, Configuration: json.RawMessage(`{"agent":{"id":"agent","model":"model","tools":[]},"environment":{"type":"none"}}`), CreatedAt: time.Unix(100+int64(i), 0), Usage: usage} if i == 0 { - session.LastTurn = &store.Turn{Status: store.TurnInProgress, CreatedAt: time.Unix(110, 0)} + session.LastTurn = &sessions.Turn{Status: sessions.TurnInProgress, CreatedAt: time.Unix(110, 0)} } if err := visit(session, nil); err != nil { return store.AdminAssetCounts{}, err diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index ffd7b3925..3170131e0 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -16,6 +16,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" ) @@ -739,7 +740,7 @@ func (f *fakeSessionArchive) ArchiveManagedSession(a0 context.Context, a1 string type fakeSessionEvents struct { t testing.TB sessionEventCursor func(context.Context, string, string) (int64, error) - listSessionEvents func(context.Context, string, string, int64) ([]store.SessionChange, error) + listSessionEvents func(context.Context, string, string, int64) ([]sessions.SessionChange, error) sessionStreamSnapshot func(context.Context, string, string) (store.Session, int64, error) } @@ -750,7 +751,7 @@ func (f *fakeSessionEvents) SessionEventCursor(a0 context.Context, a1 string, a2 return f.sessionEventCursor(a0, a1, a2) } -func (f *fakeSessionEvents) ListSessionEvents(a0 context.Context, a1 string, a2 string, a3 int64) ([]store.SessionChange, error) { +func (f *fakeSessionEvents) ListSessionEvents(a0 context.Context, a1 string, a2 string, a3 int64) ([]sessions.SessionChange, error) { if f.listSessionEvents == nil { unexpectedCall(f.t, "ListSessionEvents") } @@ -766,12 +767,12 @@ func (f *fakeSessionEvents) SessionStreamSnapshot(a0 context.Context, a1 string, type fakeSessionHistory struct { t testing.TB - getTurn func(context.Context, string, string, string) (store.Turn, error) + getTurn func(context.Context, string, string, string) (sessions.Turn, error) listTurns func(context.Context, string, string, string, int, bool) (store.TurnPage, error) listItems func(context.Context, string, string, string, int, bool) (store.ItemPage, error) } -func (f *fakeSessionHistory) GetTurn(a0 context.Context, a1 string, a2 string, a3 string) (store.Turn, error) { +func (f *fakeSessionHistory) GetTurn(a0 context.Context, a1 string, a2 string, a3 string) (sessions.Turn, error) { if f.getTurn == nil { unexpectedCall(f.t, "GetTurn") } diff --git a/services/core/internal/api/function_state_test.go b/services/core/internal/api/function_state_test.go index d636bc618..a3a34761f 100644 --- a/services/core/internal/api/function_state_test.go +++ b/services/core/internal/api/function_state_test.go @@ -6,6 +6,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -16,7 +17,7 @@ func TestFunctionStateEventsUseTheirOwnSnapshot(t *testing.T) { } for _, arguments := range []string{`{"n":9007199254740993}`, `null`, `[1,"value"]`, `"value"`, `false`} { action := v1.FunctionCallAction{Type: "function_call", CallID: "call", Name: "lookup", TurnID: "turn", Arguments: json.RawMessage(arguments)} - change := store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.requires_action", EventID: "event", SessionID: "session"}, Turn: &store.Turn{ID: "turn", Status: store.TurnWaiting}, RequiredActions: []v1.FunctionCallAction{action}} + change := sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.requires_action", EventID: "event", SessionID: "session"}, Turn: &sessions.Turn{ID: "turn", Status: sessions.TurnWaiting}, RequiredActions: []v1.FunctionCallAction{action}} event, err := streamResponse(session, change, "") if err != nil || event.Session.Status != "requires_action" { t.Fatal(event, err) @@ -38,7 +39,7 @@ func TestFunctionStateEventsUseTheirOwnSnapshot(t *testing.T) { t.Fatal("unexpected public fields", string(raw)) } change.RequiredActions = nil - change.Turn.Status = store.TurnInProgress + change.Turn.Status = sessions.TurnInProgress change.Event.Type = "agent.session.in_progress" event, err = streamResponse(session, change, "") if err != nil || event.Session.Status != "in_progress" || event.Session.RequiredActions == nil || len(event.Session.RequiredActions) != 0 { diff --git a/services/core/internal/api/hosted_failure_test.go b/services/core/internal/api/hosted_failure_test.go index e1d63de32..02e47ea9d 100644 --- a/services/core/internal/api/hosted_failure_test.go +++ b/services/core/internal/api/hosted_failure_test.go @@ -15,6 +15,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -33,10 +34,10 @@ func hostedFailureSession() store.Session { // reason and failure time, whether or not pending input was settled with it. func TestHostedProvisioningFailureSessionProjection(t *testing.T) { failedAt := time.Unix(1790187038, 0) - for _, activity := range []*store.EnvironmentInputActivity{nil, {Status: "failed", Failure: "environment_unavailable", LastActiveAt: failedAt.Add(-time.Second)}} { + for _, activity := range []*sessions.EnvironmentInputActivity{nil, {Status: "failed", Failure: "environment_unavailable", LastActiveAt: failedAt.Add(-time.Second)}} { session := hostedFailureSession() session.EnvironmentInputActivity = activity - session.EnvironmentFailure = &store.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: failedAt} + session.EnvironmentFailure = &sessions.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: failedAt} response, err := sessionResponse(session, "") if err != nil || response.Status != "failed" || response.Error == nil || *response.Error != hostedFailureReason || response.LastActiveAt != failedAt.Unix() || response.RequiredActions == nil || len(response.RequiredActions) != 0 { @@ -50,7 +51,7 @@ func TestHostedProvisioningFailureSessionProjection(t *testing.T) { t.Fatal("unrecorded failure changed the projection", response, err) } session = environmentSession() - session.EnvironmentFailure = &store.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: failedAt} + session.EnvironmentFailure = &sessions.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: failedAt} if value, err := sessionResponse(session, environmentOrigin); err != nil || value.Status != "failed" || value.Error == nil || *value.Error != hostedFailureReason { t.Fatal("self-hosted preparation failure lost its common projection", value, err) } @@ -60,7 +61,7 @@ func TestHostedProvisioningFailureSessionProjection(t *testing.T) { // Environment state error keeps the observed three fields. func TestHostedProvisioningFailureEventShapes(t *testing.T) { session := hostedFailureSession() - fields := func(change store.SessionChange) map[string]any { + fields := func(change sessions.SessionChange) map[string]any { t.Helper() event, err := streamResponse(session, change, "") if err != nil { @@ -76,14 +77,14 @@ func TestHostedProvisioningFailureEventShapes(t *testing.T) { } return value } - got := fields(store.SessionChange{Event: v1.SessionEvent{Type: "error", EventID: "event", SessionID: "session", + got := fields(sessions.SessionChange{Event: v1.SessionEvent{Type: "error", EventID: "event", SessionID: "session", Error: &v1.StreamError{Type: "environment_error", Code: "sandbox_error", Message: hostedFailureReason}}}) want := map[string]any{"type": "error", "event_id": "event", "session_id": "session", "error": map[string]any{"type": "environment_error", "code": "sandbox_error", "message": hostedFailureReason, "param": nil}} if !reflect.DeepEqual(got, want) { t.Fatal("error event", got) } - got = fields(store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.environment.failed", EventID: "event", SessionID: "session", + got = fields(sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.environment.failed", EventID: "event", SessionID: "session", Environment: &v1.SessionEnvironmentState{ID: "environment", Type: "openai_hosted", Status: "failed", Error: &v1.StreamError{Type: "environment_error", Code: "environment_connection_failed", Message: "The environment failed to connect."}}}}) if environment, _ := got["environment"].(map[string]any); !reflect.DeepEqual(environment["error"], map[string]any{ @@ -91,8 +92,8 @@ func TestHostedProvisioningFailureEventShapes(t *testing.T) { t.Fatal("environment.failed event", got) } failedAt := time.Unix(1790187038, 0) - got = fields(store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.failed", EventID: "event"}, - EnvironmentFailure: &store.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: failedAt}}) + got = fields(sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.failed", EventID: "event"}, + EnvironmentFailure: &sessions.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: failedAt}}) if snapshot, _ := got["session"].(map[string]any); snapshot["status"] != "failed" || snapshot["error"] != hostedFailureReason || snapshot["last_active_at"] != float64(failedAt.Unix()) || !reflect.DeepEqual(snapshot["required_actions"], []any{}) { t.Fatal("failed snapshot", got) @@ -113,13 +114,13 @@ func TestGetStreamEndsAfterHostedProvisioningFailure(t *testing.T) { h := newTestHandler(t, deps) server := httptest.NewServer(h) defer server.Close() - failed := store.SessionChange{Sequence: 13, Event: v1.SessionEvent{Type: "agent.session.failed", EventID: "failed"}} + failed := sessions.SessionChange{Sequence: 13, Event: v1.SessionEvent{Type: "agent.session.failed", EventID: "failed"}} if terminal { - failed.EnvironmentFailure = &store.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: time.Unix(1790187038, 0)} + failed.EnvironmentFailure = &sessions.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: time.Unix(1790187038, 0)} } else { - failed.Turn = &store.Turn{ID: "turn", Status: store.TurnFailed} + failed.Turn = &sessions.Turn{ID: "turn", Status: sessions.TurnFailed} } - f.changes = []store.SessionChange{ + f.changes = []sessions.SessionChange{ {Sequence: 11, Event: v1.SessionEvent{Type: "agent.session.environment.failed", EventID: "environment", SessionID: "session", Environment: &v1.SessionEnvironmentState{ID: "environment", Type: "openai_hosted", Status: "failed", Error: &v1.StreamError{Type: "environment_error", Code: "environment_connection_failed", Message: "The environment failed to connect."}}}}, {Sequence: 12, Event: v1.SessionEvent{Type: "error", EventID: "error", SessionID: "session", Error: &v1.StreamError{Type: "environment_error", Code: "sandbox_error", Message: hostedFailureReason}}}, diff --git a/services/core/internal/api/native_classification_integration_test.go b/services/core/internal/api/native_classification_integration_test.go index 5e5aa8742..992f25948 100644 --- a/services/core/internal/api/native_classification_integration_test.go +++ b/services/core/internal/api/native_classification_integration_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -26,7 +27,7 @@ func TestNativeClassificationPostgresRoundTripAndPublicPrivacy(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } status := 503 @@ -35,7 +36,7 @@ func TestNativeClassificationPostgresRoundTripAndPublicPrivacy(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnFailed, Outcome: outcome}); err != nil { + if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed, Outcome: outcome}); err != nil { t.Fatal(err) } snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) diff --git a/services/core/internal/api/session_creation_stream.go b/services/core/internal/api/session_creation_stream.go index 7d5544b4a..6a8801c4f 100644 --- a/services/core/internal/api/session_creation_stream.go +++ b/services/core/internal/api/session_creation_stream.go @@ -6,6 +6,7 @@ import ( "net/http" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -83,7 +84,7 @@ func sessionSettled(session store.Session, response v1.Session) bool { } if turn := session.LastTurn; turn != nil { switch turn.Status { - case store.TurnQueued, store.TurnInProgress, store.TurnWaiting: + case sessions.TurnQueued, sessions.TurnInProgress, sessions.TurnWaiting: return false } } diff --git a/services/core/internal/api/session_creation_stream_test.go b/services/core/internal/api/session_creation_stream_test.go index 46eb33414..994992b3a 100644 --- a/services/core/internal/api/session_creation_stream_test.go +++ b/services/core/internal/api/session_creation_stream_test.go @@ -16,6 +16,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -52,11 +53,11 @@ func (f *creationStreamFixture) SessionEventCursor(context.Context, string, stri return f.sequence, nil } -func (f *creationStreamFixture) ListSessionEvents(_ context.Context, _, _ string, cursor int64) ([]store.SessionChange, error) { +func (f *creationStreamFixture) ListSessionEvents(_ context.Context, _, _ string, cursor int64) ([]sessions.SessionChange, error) { f.mu.Lock() defer f.mu.Unlock() f.cursors = append(f.cursors, cursor) - var changes []store.SessionChange + var changes []sessions.SessionChange for _, change := range f.changes { if change.Sequence > cursor { changes = append(changes, change) @@ -173,8 +174,8 @@ func newCreationStreamHarness(t *testing.T) *creationStreamHarness { } // turn returns a Turn snapshot with the given status for the fixture Session. -func (h *creationStreamHarness) turn(status string, usage string) *store.Turn { - turn := &store.Turn{ID: "turn_1", SessionID: h.fixture.session.ID, Status: status, CreatedAt: time.Unix(1700000001, 0)} +func (h *creationStreamHarness) turn(status string, usage string) *sessions.Turn { + turn := &sessions.Turn{ID: "turn_1", SessionID: h.fixture.session.ID, Status: status, CreatedAt: time.Unix(1700000001, 0)} if usage != "" { turn.Usage = json.RawMessage(usage) } @@ -183,7 +184,7 @@ func (h *creationStreamHarness) turn(status string, usage string) *store.Turn { // commit applies a projection change and its events atomically, as the Store // commits Session events with the state they describe. -func (h *creationStreamHarness) commit(update func(*store.Session), changes ...store.SessionChange) { +func (h *creationStreamHarness) commit(update func(*store.Session), changes ...sessions.SessionChange) { h.fixture.mu.Lock() defer h.fixture.mu.Unlock() if update != nil { @@ -308,24 +309,24 @@ func sameJSON(t *testing.T, raw json.RawMessage, value any) bool { return reflect.DeepEqual(left, right) } -func turnChange(kind string, turn *store.Turn) store.SessionChange { - return store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn." + kind, EventID: uuid.NewString(), SessionID: turn.SessionID, TurnID: turn.ID}, Turn: turn} +func turnChange(kind string, turn *sessions.Turn) sessions.SessionChange { + return sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn." + kind, EventID: uuid.NewString(), SessionID: turn.SessionID, TurnID: turn.ID}, Turn: turn} } // sessionChange records a Turn-owned Session status snapshot, settled when its // Turn is terminal, as the Store records it. -func sessionChange(status string, turn *store.Turn, actions []v1.FunctionCallAction) store.SessionChange { - settled := turn != nil && (turn.Status == store.TurnCompleted || turn.Status == store.TurnFailed || turn.Status == store.TurnCancelled) - return store.SessionChange{Event: v1.SessionEvent{Type: "agent.session." + status, EventID: uuid.NewString(), SessionID: "session"}, Turn: turn, RequiredActions: actions, Settled: settled} +func sessionChange(status string, turn *sessions.Turn, actions []v1.FunctionCallAction) sessions.SessionChange { + settled := turn != nil && (turn.Status == sessions.TurnCompleted || turn.Status == sessions.TurnFailed || turn.Status == sessions.TurnCancelled) + return sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session." + status, EventID: uuid.NewString(), SessionID: "session"}, Turn: turn, RequiredActions: actions, Settled: settled} } -func activityChange(activity *store.EnvironmentInputActivity, settled bool) store.SessionChange { - return store.SessionChange{Event: v1.SessionEvent{Type: "agent.session." + activity.Status, EventID: uuid.NewString()}, EnvironmentInputActivity: activity, Settled: settled} +func activityChange(activity *sessions.EnvironmentInputActivity, settled bool) sessions.SessionChange { + return sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session." + activity.Status, EventID: uuid.NewString()}, EnvironmentInputActivity: activity, Settled: settled} } -func userItemChange(turn *store.Turn) store.SessionChange { +func userItemChange(turn *sessions.Turn) sessions.SessionChange { text := "First" - return store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.added", EventID: uuid.NewString(), SessionID: turn.SessionID, TurnID: turn.ID, + return sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.added", EventID: uuid.NewString(), SessionID: turn.SessionID, TurnID: turn.ID, Item: &v1.Item{ID: "item_1", TurnID: turn.ID, Type: "message", Status: "completed", Role: "user", Content: []v1.ItemContent{{Type: "input_text", Text: &text}}}}} } @@ -335,7 +336,7 @@ const creationBody = `{"agent":{"model":"model"},"environment":{"type":"none"}," const savedBody = `{"agent_id":"agent_test","environment":{"type":"none"},"input":"First","stream":true}` -func setTurn(turn *store.Turn) func(*store.Session) { +func setTurn(turn *sessions.Turn) func(*store.Session) { return func(session *store.Session) { session.LastTurn, session.RequiredActions = turn, nil } } @@ -348,7 +349,7 @@ func TestCreationStreamClosesOnceSettled(t *testing.T) { t.Run(test.terminal, func(t *testing.T) { h := newCreationStreamHarness(t) // The admission result is the committed post-input projection. - queued := h.turn(store.TurnQueued, "") + queued := h.turn(sessions.TurnQueued, "") h.commit(setTurn(queued), turnChange("created", queued), userItemChange(queued), sessionChange("in_progress", queued, nil)) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Created: true, Cursor: 10} frames, cancel := h.open(http.MethodPost, "/v1/agents/sessions", creationBody) @@ -359,7 +360,7 @@ func TestCreationStreamClosesOnceSettled(t *testing.T) { if err != nil || created.Session == nil || created.Session.Status != "in_progress" || !sameJSON(t, observed[0].Data["session"], projection) { t.Fatal("created snapshot differs from the JSON projection", string(observed[0].Data["session"]), err) } - running := h.turn(store.TurnInProgress, "") + running := h.turn(sessions.TurnInProgress, "") h.commit(setTurn(running), turnChange("in_progress", running)) expectTypes(t, frames, "agent.session.turn.in_progress") expectOpen(t, frames) @@ -387,14 +388,14 @@ func TestCreationStreamClosesOnceSettled(t *testing.T) { func TestCreationStreamStaysOpenAcrossRequiredAction(t *testing.T) { h := newCreationStreamHarness(t) - queued := h.turn(store.TurnQueued, "") + queued := h.turn(sessions.TurnQueued, "") h.commit(setTurn(queued), turnChange("created", queued), userItemChange(queued), sessionChange("in_progress", queued, nil)) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Created: true, Cursor: 10} frames, cancel := h.open(http.MethodPost, "/v1/agents/sessions", creationBody) defer cancel() expectTypes(t, frames, "agent.session.created", "agent.session.turn.created", "agent.session.turn.item.added", "agent.session.in_progress") - waiting := h.turn(store.TurnWaiting, "") + waiting := h.turn(sessions.TurnWaiting, "") actions := []v1.FunctionCallAction{{Type: "function_call", CallID: "call_1", Name: "lookup", TurnID: waiting.ID, Arguments: json.RawMessage(`{}`)}} h.commit(func(session *store.Session) { session.LastTurn, session.RequiredActions = waiting, actions }, sessionChange("requires_action", waiting, actions)) @@ -402,11 +403,11 @@ func TestCreationStreamStaysOpenAcrossRequiredAction(t *testing.T) { expectOpen(t, frames) // The function result resumes the Turn; the stream ends once it settles. - resumed := h.turn(store.TurnInProgress, "") + resumed := h.turn(sessions.TurnInProgress, "") h.commit(setTurn(resumed), sessionChange("in_progress", resumed, nil)) expectTypes(t, frames, "agent.session.in_progress") expectOpen(t, frames) - completed := h.turn(store.TurnCompleted, measuredUsage) + completed := h.turn(sessions.TurnCompleted, measuredUsage) h.commit(setTurn(completed), turnChange("completed", completed), sessionChange("idle", completed, nil)) expectTypes(t, frames, "agent.session.turn.completed", "agent.session.idle") expectEnded(t, frames) @@ -416,7 +417,7 @@ func TestCreationStreamWithoutAdmittedWorkClosesAfterCreated(t *testing.T) { h := newCreationStreamHarness(t) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Created: true, Cursor: 10} // Work committed after the creation is not followed. - queued := h.turn(store.TurnQueued, "") + queued := h.turn(sessions.TurnQueued, "") h.commit(nil, turnChange("created", queued)) frames, cancel := h.open(http.MethodPost, "/v1/agents/sessions", creationBody) defer cancel() @@ -443,7 +444,7 @@ func TestCreationStreamWaitsForPendingProvisioning(t *testing.T) { } expectOpen(t, frames) if outcome == "failure" { - failed := &store.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Unix(1700000005, 0)} + failed := &sessions.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Unix(1700000005, 0)} h.commit(func(session *store.Session) { session.PendingInput, session.EnvironmentInputActivity = false, failed }, activityChange(failed, true)) expectTypes(t, frames, "agent.session.failed") @@ -458,7 +459,7 @@ func TestCreationStreamWaitsForPendingProvisioning(t *testing.T) { func TestCreationStreamEndsWhenSessionIsDeleted(t *testing.T) { h := newCreationStreamHarness(t) - queued := h.turn(store.TurnQueued, "") + queued := h.turn(sessions.TurnQueued, "") h.commit(setTurn(queued)) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Created: true, Cursor: 10} frames, cancel := h.open(http.MethodPost, "/v1/agents/sessions", creationBody) @@ -470,15 +471,15 @@ func TestCreationStreamEndsWhenSessionIsDeleted(t *testing.T) { func TestGetStreamStaysOpenAfterSettlement(t *testing.T) { h := newCreationStreamHarness(t) - h.commit(setTurn(h.turn(store.TurnInProgress, ""))) + h.commit(setTurn(h.turn(sessions.TurnInProgress, ""))) frames, cancel := h.open(http.MethodGet, "/v1/agents/sessions/"+h.fixture.session.ID+"/events", "") defer cancel() - completed := h.turn(store.TurnCompleted, "") + completed := h.turn(sessions.TurnCompleted, "") h.commit(setTurn(completed), turnChange("completed", completed), sessionChange("idle", completed, nil)) expectTypes(t, frames, "agent.session.turn.completed", "agent.session.idle") expectOpen(t, frames) - later := h.turn(store.TurnQueued, "") - failed := h.turn(store.TurnFailed, "") + later := h.turn(sessions.TurnQueued, "") + failed := h.turn(sessions.TurnFailed, "") h.commit(setTurn(failed), turnChange("created", later), sessionChange("failed", failed, nil)) expectTypes(t, frames, "agent.session.turn.created", "agent.session.failed") expectOpen(t, frames) @@ -488,21 +489,21 @@ func TestGetStreamStaysOpenAfterSettlement(t *testing.T) { // commits in the same drained batch as the settling idle or between the // fallback's settled snapshot and its drain. func TestCreationStreamStopsBeforeLaterWork(t *testing.T) { - later := func(h *creationStreamHarness) []store.SessionChange { - queued := h.turn(store.TurnQueued, "") + later := func(h *creationStreamHarness) []sessions.SessionChange { + queued := h.turn(sessions.TurnQueued, "") queued.ID = "turn_b" - return []store.SessionChange{turnChange("created", queued), userItemChange(queued), sessionChange("in_progress", queued, nil)} + return []sessions.SessionChange{turnChange("created", queued), userItemChange(queued), sessionChange("in_progress", queued, nil)} } t.Run("same batch as the settling idle", func(t *testing.T) { h := newCreationStreamHarness(t) - h.commit(setTurn(h.turn(store.TurnInProgress, ""))) + h.commit(setTurn(h.turn(sessions.TurnInProgress, ""))) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Created: true, Cursor: h.fixture.sequence} frames, cancel := h.open(http.MethodPost, "/v1/agents/sessions", creationBody) defer cancel() expectTypes(t, frames, "agent.session.created") // The settling idle and B's new Turn commit before the next poll drains them together. - completed := h.turn(store.TurnCompleted, "") - changes := append([]store.SessionChange{turnChange("completed", completed), sessionChange("idle", completed, nil)}, later(h)...) + completed := h.turn(sessions.TurnCompleted, "") + changes := append([]sessions.SessionChange{turnChange("completed", completed), sessionChange("idle", completed, nil)}, later(h)...) h.commit(func(session *store.Session) { session.LastTurn = changes[2].Turn }, changes...) expectTypes(t, frames, "agent.session.turn.completed", "agent.session.idle") expectEnded(t, frames) @@ -520,7 +521,7 @@ func TestCreationStreamStopsBeforeLaterWork(t *testing.T) { // alongside an unsent environment event, before the fallback read. h.fixture.beforeSnapshot = func() { h.commit(func(session *store.Session) { session.PendingInput = false }, - store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.environment.disconnected", EventID: uuid.NewString(), SessionID: h.fixture.session.ID, + sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.environment.disconnected", EventID: uuid.NewString(), SessionID: h.fixture.session.ID, Environment: &v1.SessionEnvironmentState{ID: "environment", Type: "self_hosted", Status: "disconnected"}}}) } // B's Turn commits after that settled read and before its drain. @@ -536,14 +537,14 @@ func TestCreationStreamStopsBeforeLaterWork(t *testing.T) { func TestCreationStreamIgnoresConnectionIdle(t *testing.T) { h := newCreationStreamHarness(t) - waiting := &store.EnvironmentInputActivity{Status: "requires_action", LastActiveAt: time.Unix(1700000002, 0)} + waiting := &sessions.EnvironmentInputActivity{Status: "requires_action", LastActiveAt: time.Unix(1700000002, 0)} h.commit(func(session *store.Session) { session.PendingInput = true }) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Created: true, Cursor: h.fixture.sequence} frames, cancel := h.open(http.MethodPost, "/v1/agents/sessions", creationBody) defer cancel() expectTypes(t, frames, "agent.session.created") // A self-hosted connection clears the action; the input is still pending. - connected := &store.EnvironmentInputActivity{Status: "idle", LastActiveAt: waiting.LastActiveAt} + connected := &sessions.EnvironmentInputActivity{Status: "idle", LastActiveAt: waiting.LastActiveAt} h.commit(func(session *store.Session) { session.EnvironmentInputActivity = connected }, activityChange(connected, false)) expectTypes(t, frames, "agent.session.idle") expectOpen(t, frames) @@ -554,7 +555,7 @@ func TestCreationStreamIgnoresConnectionIdle(t *testing.T) { func TestCreationStreamBoundsProjectionReads(t *testing.T) { h := newCreationStreamHarness(t) - running := h.turn(store.TurnInProgress, "") + running := h.turn(sessions.TurnInProgress, "") h.commit(setTurn(running)) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Created: true, Cursor: h.fixture.sequence} frames, cancel := h.open(http.MethodPost, "/v1/agents/sessions", creationBody) @@ -565,7 +566,7 @@ func TestCreationStreamBoundsProjectionReads(t *testing.T) { count := 0 for time.Now().Before(deadline) { text := "x" - h.commit(nil, store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.output_text.delta", EventID: uuid.NewString(), SessionID: running.SessionID, TurnID: running.ID, Delta: &text}}) + h.commit(nil, sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.output_text.delta", EventID: uuid.NewString(), SessionID: running.SessionID, TurnID: running.ID, Delta: &text}}) count++ time.Sleep(20 * time.Millisecond) } @@ -590,20 +591,20 @@ func TestCreationRetryStreamEndsImmediately(t *testing.T) { upsert bool }{ {"initial work running", func(h *creationStreamHarness) { - running := h.turn(store.TurnInProgress, "") + running := h.turn(sessions.TurnInProgress, "") h.commit(setTurn(running), turnChange("in_progress", running)) }, false}, {"initial work settled", func(h *creationStreamHarness) { - completed := h.turn(store.TurnCompleted, "") + completed := h.turn(sessions.TurnCompleted, "") h.commit(setTurn(completed), turnChange("completed", completed), sessionChange("idle", completed, nil)) }, false}, {"superseded by another client's Turn", func(h *creationStreamHarness) { - later := h.turn(store.TurnInProgress, "") + later := h.turn(sessions.TurnInProgress, "") later.ID = "turn_b" h.commit(setTurn(later), turnChange("created", later), userItemChange(later), sessionChange("in_progress", later, nil)) }, false}, {"upsert retry", func(h *creationStreamHarness) { - h.commit(setTurn(h.turn(store.TurnInProgress, ""))) + h.commit(setTurn(h.turn(sessions.TurnInProgress, ""))) h.fixture.creation = store.SessionCreation{Session: h.fixture.session, Cursor: 10} }, true}, } { @@ -643,17 +644,17 @@ func TestCreationRetryStreamEndsImmediately(t *testing.T) { func TestSettlingEvents(t *testing.T) { for _, test := range []struct { - change store.SessionChange + change sessions.SessionChange settles bool }{ - {sessionChange("idle", &store.Turn{Status: store.TurnCompleted}, nil), true}, - {activityChange(&store.EnvironmentInputActivity{Status: "idle"}, true), true}, - {activityChange(&store.EnvironmentInputActivity{Status: "idle"}, false), false}, - {activityChange(&store.EnvironmentInputActivity{Status: "failed"}, true), true}, - {sessionChange("failed", &store.Turn{Status: store.TurnFailed}, nil), true}, - {sessionChange("requires_action", &store.Turn{Status: store.TurnWaiting}, nil), false}, - {sessionChange("in_progress", &store.Turn{Status: store.TurnQueued}, nil), false}, - {turnChange("completed", &store.Turn{Status: store.TurnCompleted}), false}, + {sessionChange("idle", &sessions.Turn{Status: sessions.TurnCompleted}, nil), true}, + {activityChange(&sessions.EnvironmentInputActivity{Status: "idle"}, true), true}, + {activityChange(&sessions.EnvironmentInputActivity{Status: "idle"}, false), false}, + {activityChange(&sessions.EnvironmentInputActivity{Status: "failed"}, true), true}, + {sessionChange("failed", &sessions.Turn{Status: sessions.TurnFailed}, nil), true}, + {sessionChange("requires_action", &sessions.Turn{Status: sessions.TurnWaiting}, nil), false}, + {sessionChange("in_progress", &sessions.Turn{Status: sessions.TurnQueued}, nil), false}, + {turnChange("completed", &sessions.Turn{Status: sessions.TurnCompleted}), false}, } { if settlingEvent(test.change) != test.settles { t.Fatal("unexpected settling event", test.change.Event.Type, test.change.Settled) @@ -668,19 +669,19 @@ func TestSessionSettledProjection(t *testing.T) { pending bool settled bool }{ - {"idle", store.TurnCompleted, false, true}, - {"failed", store.TurnFailed, false, true}, + {"idle", sessions.TurnCompleted, false, true}, + {"failed", sessions.TurnFailed, false, true}, {"idle", "", false, true}, {"failed", "", false, true}, {"idle", "", true, false}, - {"idle", store.TurnCancelled, true, false}, - {"in_progress", store.TurnQueued, false, false}, - {"requires_action", store.TurnWaiting, false, false}, + {"idle", sessions.TurnCancelled, true, false}, + {"in_progress", sessions.TurnQueued, false, false}, + {"requires_action", sessions.TurnWaiting, false, false}, {"requires_action", "", true, false}, } { session := store.Session{PendingInput: test.pending} if test.turn != "" { - session.LastTurn = &store.Turn{Status: test.turn} + session.LastTurn = &sessions.Turn{Status: test.turn} } if sessionSettled(session, v1.Session{Status: test.status}) != test.settled { t.Fatal("unexpected settlement", test) diff --git a/services/core/internal/api/session_diagnostics.go b/services/core/internal/api/session_diagnostics.go index f23b765c3..d8406682d 100644 --- a/services/core/internal/api/session_diagnostics.go +++ b/services/core/internal/api/session_diagnostics.go @@ -6,6 +6,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) @@ -154,7 +155,7 @@ func diagnosticTime(value time.Time) *time.Time { return &value } -func provisioningFailureParams(detail *store.ProvisioningFailureDetail) CoreErrorDetails { +func provisioningFailureParams(detail *sessions.ProvisioningFailureDetail) CoreErrorDetails { params := CoreErrorDetails{"step": CoreErrorNull(), "index": CoreErrorNull(), "exit_code": CoreErrorNull()} if detail == nil { return params diff --git a/services/core/internal/api/session_diagnostics_deadline_test.go b/services/core/internal/api/session_diagnostics_deadline_test.go index 288fc9f47..ff3aefa2e 100644 --- a/services/core/internal/api/session_diagnostics_deadline_test.go +++ b/services/core/internal/api/session_diagnostics_deadline_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -28,7 +29,7 @@ func (s *diagnosticDeadlineStore) GetTurnDiagnosticsSnapshot(ctx context.Context func TestDiagnosticsReadDeadline(t *testing.T) { id, turn := uuid.NewString(), uuid.NewString() - session := store.Session{ID: id, Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`), LastTurn: &store.Turn{ID: turn, SessionID: id, Status: store.TurnCompleted}} + session := store.Session{ID: id, Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`), LastTurn: &sessions.Turn{ID: turn, SessionID: id, Status: sessions.TurnCompleted}} for _, suffix := range []string{"/diagnostics", "/turns/" + turn + "/diagnostics"} { for _, shorter := range []bool{false, true} { t.Run(suffix+map[bool]string{false: "/server", true: "/caller"}[shorter], func(t *testing.T) { diff --git a/services/core/internal/api/session_diagnostics_test.go b/services/core/internal/api/session_diagnostics_test.go index 74b86dc81..d699d0169 100644 --- a/services/core/internal/api/session_diagnostics_test.go +++ b/services/core/internal/api/session_diagnostics_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -23,10 +24,10 @@ func TestDiagnosticsCoreHandlerDatabaseBoundary(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } - if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"device_disconnected","error":"Bearer raw-secret-canary https://private.example/key","done":{"native_id":"secret-native-canary"}}`)}); err != nil { + if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"device_disconnected","error":"Bearer raw-secret-canary https://private.example/key","done":{"native_id":"secret-native-canary"}}`)}); err != nil { t.Fatal(err) } base := adminSessionsPath + session.ID @@ -87,11 +88,11 @@ func serveDiagnostics(source diagnosticSnapshots) func(*Dependencies, *testFakes func TestDiagnosticsFailurePrecedenceAndUnknownTime(t *testing.T) { id, turnID := uuid.NewString(), uuid.NewString() - base := store.Session{ID: id, Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`), LastTurn: &store.Turn{ID: turnID, SessionID: id, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed","error":"secret-canary"}`)}} + base := store.Session{ID: id, Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`), LastTurn: &sessions.Turn{ID: turnID, SessionID: id, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed","error":"secret-canary"}`)}} for _, tc := range []struct { - activity *store.EnvironmentInputActivity + activity *sessions.EnvironmentInputActivity code, source string - }{{nil, "harness_error", "turn"}, {&store.EnvironmentInputActivity{Status: "failed"}, "environment_connection_timeout", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "model_provider_required"}, "model_provider_required", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "runtime_preparation_failed"}, "runtime_preparation_failed", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "secret-canary"}, "internal_error", "environment_input"}} { + }{{nil, "harness_error", "turn"}, {&sessions.EnvironmentInputActivity{Status: "failed"}, "environment_connection_timeout", "environment_input"}, {&sessions.EnvironmentInputActivity{Status: "failed", Failure: "model_provider_required"}, "model_provider_required", "environment_input"}, {&sessions.EnvironmentInputActivity{Status: "failed", Failure: "runtime_preparation_failed"}, "runtime_preparation_failed", "environment_input"}, {&sessions.EnvironmentInputActivity{Status: "failed", Failure: "secret-canary"}, "internal_error", "environment_input"}} { value := base value.EnvironmentInputActivity = tc.activity h, _, _ := adminTestHandler(t, serveDiagnostics(diagnosticSnapshotStore{session: value})) @@ -100,7 +101,7 @@ func TestDiagnosticsFailurePrecedenceAndUnknownTime(t *testing.T) { t.Fatal(w.Code, w.Body) } } - base.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "idle", LastActiveAt: time.Now()} + base.EnvironmentInputActivity = &sessions.EnvironmentInputActivity{Status: "idle", LastActiveAt: time.Now()} h, _, _ := adminTestHandler(t, serveDiagnostics(diagnosticSnapshotStore{session: base})) if w := diagnosticRequest(h, adminSessionsPath+id+"/diagnostics", "Bearer admin"); w.Code != 200 || !strings.Contains(w.Body.String(), `"failure":null`) { t.Fatal("public activity precedence changed", w.Code, w.Body) @@ -109,11 +110,11 @@ func TestDiagnosticsFailurePrecedenceAndUnknownTime(t *testing.T) { func TestDiagnosticsHostedFailureOverridesInputWithoutParsingReason(t *testing.T) { session := hostedFailureSession() - session.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Now()} - session.LastTurn = &store.Turn{ID: uuid.NewString(), SessionID: session.ID, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)} + session.EnvironmentInputActivity = &sessions.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Now()} + session.LastTurn = &sessions.Turn{ID: uuid.NewString(), SessionID: session.ID, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)} step, index, exit := "setup", 2, 7 - for _, detail := range []*store.ProvisioningFailureDetail{nil, {Step: &step, Index: &index, ExitCode: &exit}} { - session.EnvironmentFailure = &store.EnvironmentFailure{Reason: "private-secret-canary setup_commands[99] exit 254", Detail: detail} + for _, detail := range []*sessions.ProvisioningFailureDetail{nil, {Step: &step, Index: &index, ExitCode: &exit}} { + session.EnvironmentFailure = &sessions.EnvironmentFailure{Reason: "private-secret-canary setup_commands[99] exit 254", Detail: detail} h, _, _ := adminTestHandler(t, serveDiagnostics(diagnosticSnapshotStore{session: session})) w := diagnosticRequest(h, adminSessionsPath+session.ID+"/diagnostics", "Bearer admin") if w.Code != 200 || strings.Contains(w.Body.String(), "canary") || !strings.Contains(w.Body.String(), `"code":"environment_provisioning_failed"`) || !strings.Contains(w.Body.String(), `"source":"environment"`) { diff --git a/services/core/internal/api/session_environment_http_test.go b/services/core/internal/api/session_environment_http_test.go index 34e14de2b..690f83ad9 100644 --- a/services/core/internal/api/session_environment_http_test.go +++ b/services/core/internal/api/session_environment_http_test.go @@ -12,6 +12,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -35,10 +36,10 @@ func TestSelfHostedSessionHTTPReadListMetadataAndLiveStream(t *testing.T) { session := environmentSession() session.TenantID = uuid.NewString() session.Environment.TenantID = session.TenantID - session.EnvironmentInputActivity = &store.EnvironmentInputActivity{ + session.EnvironmentInputActivity = &sessions.EnvironmentInputActivity{ Status: "requires_action", EnvironmentID: session.Environment.ID, LastActiveAt: time.Unix(1700000100, 0), } - fixture := &environmentHTTPFixture{streamFixture{session: session, changes: []store.SessionChange{{ + fixture := &environmentHTTPFixture{streamFixture{session: session, changes: []sessions.SessionChange{{ Sequence: 11, Event: v1.SessionEvent{Type: "agent.session.requires_action", EventID: "activity", SessionID: session.ID}, EnvironmentInputActivity: session.EnvironmentInputActivity, }}}} diff --git a/services/core/internal/api/session_environment_test.go b/services/core/internal/api/session_environment_test.go index 4dc8b0ba4..2f4634e29 100644 --- a/services/core/internal/api/session_environment_test.go +++ b/services/core/internal/api/session_environment_test.go @@ -7,6 +7,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -71,16 +72,16 @@ func TestSessionEnvironmentUsesSafeStoredAssociation(t *testing.T) { func TestEnvironmentInputActivitySnapshotsIgnoreCurrentTurnAndActivity(t *testing.T) { session := environmentSession() - session.LastTurn = &store.Turn{ID: "old-failure", Status: store.TurnFailed, CompletedAt: time.Unix(1700000200, 0)} - session.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "requires_action", EnvironmentID: "environment", LastActiveAt: time.Unix(1700000300, 0)} + session.LastTurn = &sessions.Turn{ID: "old-failure", Status: sessions.TurnFailed, CompletedAt: time.Unix(1700000200, 0)} + session.EnvironmentInputActivity = &sessions.EnvironmentInputActivity{Status: "requires_action", EnvironmentID: "environment", LastActiveAt: time.Unix(1700000300, 0)} session.RequiredActions = []v1.FunctionCallAction{{Type: "function_call", CallID: "stale"}} session.Usage = json.RawMessage(`{"input_tokens":999,"output_tokens":0,"total_tokens":999}`) for _, status := range []string{"requires_action", "idle", "failed"} { - activity := &store.EnvironmentInputActivity{Status: status, LastActiveAt: time.Unix(1700000100, 0)} + activity := &sessions.EnvironmentInputActivity{Status: status, LastActiveAt: time.Unix(1700000100, 0)} if status == "requires_action" { activity.EnvironmentID = "environment" } - change := store.SessionChange{ + change := sessions.SessionChange{ Event: v1.SessionEvent{Type: "agent.session." + status, EventID: "event", SessionID: session.ID}, EnvironmentInputActivity: activity, } @@ -111,9 +112,9 @@ func TestEnvironmentInputActivitySnapshotsIgnoreCurrentTurnAndActivity(t *testin t.Fatal("unsafe or missing initial failure message", value.Error) } } - change := store.SessionChange{ + change := sessions.SessionChange{ Event: v1.SessionEvent{Type: "agent.session.in_progress", EventID: "promotion"}, - Turn: &store.Turn{ID: "promoted", Status: store.TurnInProgress, CreatedAt: time.Unix(1700000400, 0)}, + Turn: &sessions.Turn{ID: "promoted", Status: sessions.TurnInProgress, CreatedAt: time.Unix(1700000400, 0)}, } event, err := streamResponse(session, change, environmentOrigin) if err != nil || event.Session.Status != "in_progress" || len(event.Session.RequiredActions) != 0 || event.Session.LastActiveAt != 1700000400 { diff --git a/services/core/internal/api/session_response.go b/services/core/internal/api/session_response.go index ae7fc46c2..2a48c95d3 100644 --- a/services/core/internal/api/session_response.go +++ b/services/core/internal/api/session_response.go @@ -5,6 +5,7 @@ import ( "errors" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -51,7 +52,7 @@ func sessionResponse(session store.Session, executorURL string) (v1.Session, err } response.LastActiveAt = active.Unix() switch turn.Status { - case store.TurnQueued, store.TurnInProgress, store.TurnWaiting: + case sessions.TurnQueued, sessions.TurnInProgress, sessions.TurnWaiting: response.Status = "in_progress" if turn.CancelRequestedAt.IsZero() && len(session.RequiredActions) > 0 { response.Status = "requires_action" @@ -61,7 +62,7 @@ func sessionResponse(session store.Session, executorURL string) (v1.Session, err }) } } - case store.TurnFailed: + case sessions.TurnFailed: response.Status = "failed" message := "The execution could not complete." response.Error = &message diff --git a/services/core/internal/api/stream.go b/services/core/internal/api/stream.go index 75dc197ef..5296a605d 100644 --- a/services/core/internal/api/stream.go +++ b/services/core/internal/api/stream.go @@ -11,6 +11,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -20,7 +21,7 @@ import ( // projection with its event cursor from one snapshot. type SessionEvents interface { SessionEventCursor(context.Context, string, string) (int64, error) - ListSessionEvents(context.Context, string, string, int64) ([]store.SessionChange, error) + ListSessionEvents(context.Context, string, string, int64) ([]sessions.SessionChange, error) SessionStreamSnapshot(context.Context, string, string) (store.Session, int64, error) } @@ -209,7 +210,7 @@ func emitSessionEvent(write func([]byte) error, session string, event v1.Session // settlingEvent reports a recorded event after which a creation stream ends: an // idle marked settled when recorded, or any failure. A self-hosted connection // that clears a pending input's action to idle is not marked. -func settlingEvent(change store.SessionChange) bool { +func settlingEvent(change sessions.SessionChange) bool { switch change.Event.Type { case "agent.session.failed": return true @@ -222,7 +223,7 @@ func settlingEvent(change store.SessionChange) bool { // terminalEvent reports the agent.session.failed of a hosted provisioning // failure. The Session can never run again, so GET streams end after it too, as // officially observed; other failures leave GET streams open. -func terminalEvent(change store.SessionChange) bool { +func terminalEvent(change sessions.SessionChange) bool { return change.Event.Type == "agent.session.failed" && change.EnvironmentFailure != nil } @@ -234,7 +235,7 @@ func sessionStatusEvent(eventType string) bool { return false } -func streamResponse(session store.Session, change store.SessionChange, executorURL string) (v1.SessionEvent, error) { +func streamResponse(session store.Session, change sessions.SessionChange, executorURL string) (v1.SessionEvent, error) { event := change.Event if change.Turn == nil && change.EnvironmentInputActivity == nil && change.EnvironmentFailure == nil { return withTurnUsage(event), nil diff --git a/services/core/internal/api/stream_authority_test.go b/services/core/internal/api/stream_authority_test.go index 1573274ad..8c76c697f 100644 --- a/services/core/internal/api/stream_authority_test.go +++ b/services/core/internal/api/stream_authority_test.go @@ -13,6 +13,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -36,12 +37,12 @@ type busyAuthorityStream struct { sequence int64 } -func (s *busyAuthorityStream) ListSessionEvents(ctx context.Context, _, _ string, _ int64) ([]store.SessionChange, error) { +func (s *busyAuthorityStream) ListSessionEvents(ctx context.Context, _, _ string, _ int64) ([]sessions.SessionChange, error) { if ctx.Err() != nil { return nil, ctx.Err() } s.sequence++ - return []store.SessionChange{{Sequence: s.sequence + 10, Event: v1.SessionEvent{Type: "agent.session.idle", EventID: "busy"}}}, nil + return []sessions.SessionChange{{Sequence: s.sequence + 10, Event: v1.SessionEvent{Type: "agent.session.idle", EventID: "busy"}}}, nil } func TestBusyStreamRechecksAuthorityAndFailsClosed(t *testing.T) { diff --git a/services/core/internal/api/stream_test.go b/services/core/internal/api/stream_test.go index 74bc33726..4437a2a39 100644 --- a/services/core/internal/api/stream_test.go +++ b/services/core/internal/api/stream_test.go @@ -14,6 +14,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -22,7 +23,7 @@ import ( type streamFixture struct { session store.Session mu sync.Mutex - changes []store.SessionChange + changes []sessions.SessionChange gap bool cursors []int64 } @@ -47,7 +48,7 @@ func (f *streamFixture) SessionStreamSnapshot(_ context.Context, tenant, id stri return f.session, 10, nil } -func (f *streamFixture) ListSessionEvents(_ context.Context, _, _ string, cursor int64) ([]store.SessionChange, error) { +func (f *streamFixture) ListSessionEvents(_ context.Context, _, _ string, cursor int64) ([]sessions.SessionChange, error) { f.mu.Lock() defer f.mu.Unlock() f.cursors = append(f.cursors, cursor) @@ -141,7 +142,7 @@ func TestLiveStreamAuthDisconnectRecoveryAndServerDeadline(t *testing.T) { response = request("key") f.mu.Lock() text := strings.Repeat("x", 16*1024*1024) - f.changes = []store.SessionChange{{Sequence: 11, Event: v1.SessionEvent{Type: "agent.session.turn.output_text.delta", EventID: "large", SessionID: f.session.ID, Delta: &text}}} + f.changes = []sessions.SessionChange{{Sequence: 11, Event: v1.SessionEvent{Type: "agent.session.turn.output_text.delta", EventID: "large", SessionID: f.session.ID, Delta: &text}}} f.mu.Unlock() select { case <-done: @@ -156,15 +157,15 @@ func TestTerminalTurnEventsMirrorTurnUsage(t *testing.T) { measured := json.RawMessage(`{"input_tokens":7,"input_tokens_details":{"cached_tokens":2},"output_tokens":3,"output_tokens_details":{"reasoning_tokens":1},"total_tokens":10}`) child := &v1.Turn{ID: "child", Status: "cancelled"} for _, test := range []struct { - change store.SessionChange + change sessions.SessionChange want string }{ - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.completed"}, Turn: &store.Turn{ID: "turn", Status: store.TurnCompleted, Usage: measured}}, string(measured)}, - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.failed"}, Turn: &store.Turn{ID: "turn", Status: store.TurnFailed}}, "null"}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.completed"}, Turn: &sessions.Turn{ID: "turn", Status: sessions.TurnCompleted, Usage: measured}}, string(measured)}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.failed"}, Turn: &sessions.Turn{ID: "turn", Status: sessions.TurnFailed}}, "null"}, // Child Turn snapshots are rendered when recorded. - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.cancelled", Turn: child}}, "null"}, - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.in_progress"}, Turn: &store.Turn{ID: "turn", Status: store.TurnInProgress, Usage: measured}}, ""}, - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.idle"}, Turn: &store.Turn{ID: "turn", Status: store.TurnCompleted, Usage: measured}, SessionUsage: measured}, ""}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.cancelled", Turn: child}}, "null"}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.in_progress"}, Turn: &sessions.Turn{ID: "turn", Status: sessions.TurnInProgress, Usage: measured}}, ""}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.idle"}, Turn: &sessions.Turn{ID: "turn", Status: sessions.TurnCompleted, Usage: measured}, SessionUsage: measured}, ""}, } { event, err := streamResponse(session, test.change, "") if err != nil { @@ -200,13 +201,13 @@ func TestStreamEventsCarryExplicitNullFields(t *testing.T) { result := &v1.Item{ID: "result", TurnID: "turn", Type: "function_call_output", Status: "completed", CallID: "call", Output: "value"} index := int32(0) for _, test := range []struct { - change store.SessionChange + change sessions.SessionChange want map[string]string }{ - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.added", TurnID: "turn", Item: user}}, map[string]string{"output_index": "null"}}, - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.added", TurnID: "turn", Item: result}}, map[string]string{"output_index": "null"}}, - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.done", TurnID: "turn", OutputIndex: &index, Item: &v1.Item{ID: "answer", TurnID: "turn", Type: "message", Status: "completed", Role: "assistant", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}}}}, map[string]string{"output_index": "0"}}, - {store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.idle"}, Turn: &store.Turn{ID: "turn", Status: store.TurnCompleted}}, map[string]string{"output_index": ""}}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.added", TurnID: "turn", Item: user}}, map[string]string{"output_index": "null"}}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.added", TurnID: "turn", Item: result}}, map[string]string{"output_index": "null"}}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.done", TurnID: "turn", OutputIndex: &index, Item: &v1.Item{ID: "answer", TurnID: "turn", Type: "message", Status: "completed", Role: "assistant", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}}}}, map[string]string{"output_index": "0"}}, + {sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.idle"}, Turn: &sessions.Turn{ID: "turn", Status: sessions.TurnCompleted}}, map[string]string{"output_index": ""}}, } { event, err := streamResponse(session, test.change, "") if err != nil { diff --git a/services/core/internal/api/turn_diagnostic_failure.go b/services/core/internal/api/turn_diagnostic_failure.go index 25801f515..4449831de 100644 --- a/services/core/internal/api/turn_diagnostic_failure.go +++ b/services/core/internal/api/turn_diagnostic_failure.go @@ -4,11 +4,11 @@ import ( "encoding/json" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -func turnDiagnosticFailure(turn store.Turn) *DiagnosticFailure { - if turn.Status != store.TurnFailed { +func turnDiagnosticFailure(turn sessions.Turn) *DiagnosticFailure { + if turn.Status != sessions.TurnFailed { return nil } var outcome map[string]json.RawMessage diff --git a/services/core/internal/api/turn_diagnostic_failure_test.go b/services/core/internal/api/turn_diagnostic_failure_test.go index b1a6733a9..0cffe47e1 100644 --- a/services/core/internal/api/turn_diagnostic_failure_test.go +++ b/services/core/internal/api/turn_diagnostic_failure_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) func TestDiagnosticFailureWhitelist(t *testing.T) { @@ -26,18 +26,18 @@ func TestDiagnosticFailureWhitelist(t *testing.T) { } for _, input := range inputs { raw, _ := json.Marshal(map[string]string{"error_code": input, "error": "raw-secret-canary"}) - got := turnDiagnosticFailure(store.Turn{Status: store.TurnFailed, Outcome: raw}) + got := turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: raw}) encoded, _ := json.Marshal(got) if got.Code != want || strings.Contains(string(encoded), "canary") { t.Fatal(input, got) } } } - if got := turnDiagnosticFailure(store.Turn{Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed",`)}); got.Code != "internal_error" { + if got := turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed",`)}); got.Code != "internal_error" { t.Fatal("malformed outcome accepted", got) } - for _, status := range []string{store.TurnQueued, store.TurnInProgress, store.TurnWaiting, store.TurnCompleted, store.TurnCancelled} { - if got := turnDiagnosticFailure(store.Turn{Status: status, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)}); got != nil { + for _, status := range []string{sessions.TurnQueued, sessions.TurnInProgress, sessions.TurnWaiting, sessions.TurnCompleted, sessions.TurnCancelled} { + if got := turnDiagnosticFailure(sessions.Turn{Status: status, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)}); got != nil { t.Fatal("nonfailure classified", status, got) } } @@ -56,7 +56,7 @@ func TestDiagnosticNativeClassification(t *testing.T) { } for _, status := range []any{nil, 100, 429, 599, 99, 600, 503.5, "503", true, map[string]any{"token": "secret-canary"}} { raw, _ := json.Marshal(map[string]any{"error_code": "engine_failed", "error": "secret-canary", "engine_error_code": code, "engine_http_status": status}) - got := turnDiagnosticFailure(store.Turn{Status: store.TurnFailed, Outcome: raw}) + got := turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: raw}) encoded, _ := json.Marshal(got) if got.Code != code || strings.Contains(string(encoded), "canary") { t.Fatal(string(encoded)) @@ -77,20 +77,20 @@ func TestDiagnosticNativeClassification(t *testing.T) { }) } for _, optional := range []string{``, `,"engine_error_code":null`, `,"engine_error_code":17`, `,"engine_error_code":{"code":"authentication_error"}`, `,"engine_error_code":"secret-canary"`, `,"done":{"engine_error_code":"authentication_error"}`, `,"Engine_Error_Code":"authentication_error"`} { - got := turnDiagnosticFailure(store.Turn{Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"` + optional + `}`)}) + got := turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"` + optional + `}`)}) if got.Code != "harness_error" || len(got.Params) != 0 { t.Fatal(optional, got) } } for core, want := range map[string]string{"event_persistence_failed": "core_storage_failed", "artifact_capture_failed": "core_storage_failed", "event_stream_incomplete": "runtime_disconnected", "cancel_unconfirmed": "delivery_unconfirmed", "invalid_executor_result": "executor_protocol_error", "secret-canary": "internal_error"} { raw, _ := json.Marshal(map[string]any{"error_code": core, "engine_error_code": "connection_failed", "engine_http_status": 503}) - got := turnDiagnosticFailure(store.Turn{Status: store.TurnFailed, Outcome: raw}) + got := turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: raw}) if got.Code != want || len(got.Params) != 0 { t.Fatal(got) } } - for _, status := range []string{store.TurnQueued, store.TurnInProgress, store.TurnWaiting, store.TurnCompleted, store.TurnCancelled} { - if got := turnDiagnosticFailure(store.Turn{Status: status, Outcome: json.RawMessage(`{"error_code":"engine_failed","engine_error_code":"authentication_error"}`)}); got != nil { + for _, status := range []string{sessions.TurnQueued, sessions.TurnInProgress, sessions.TurnWaiting, sessions.TurnCompleted, sessions.TurnCancelled} { + if got := turnDiagnosticFailure(sessions.Turn{Status: status, Outcome: json.RawMessage(`{"error_code":"engine_failed","engine_error_code":"authentication_error"}`)}); got != nil { t.Fatal(status, got) } } diff --git a/services/core/internal/api/turns.go b/services/core/internal/api/turns.go index 8b53d8a08..c970c9563 100644 --- a/services/core/internal/api/turns.go +++ b/services/core/internal/api/turns.go @@ -8,13 +8,14 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) // SessionHistory reads a Session's root Turns and Items. type SessionHistory interface { - GetTurn(context.Context, string, string, string) (store.Turn, error) + GetTurn(context.Context, string, string, string) (sessions.Turn, error) ListTurns(context.Context, string, string, string, int, bool) (store.TurnPage, error) ListItems(context.Context, string, string, string, int, bool) (store.ItemPage, error) } @@ -91,14 +92,14 @@ func (h *Handler) listTurns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, turnListResponse(response.Data, response.HasMore)) } -func turnResponse(session store.Session, turn store.Turn) (v1.Turn, error) { +func turnResponse(session store.Session, turn sessions.Turn) (v1.Turn, error) { var cfg configuration if err := json.Unmarshal(session.Configuration, &cfg); err != nil || cfg.Agent.ID == "" { return v1.Turn{}, errors.New("missing stored agent identity") } // Session Turns are root Turns, so subagent_id is always null here. response := v1.Turn{Usage: tokenUsage(turn.Usage), ID: turn.ID, SessionID: turn.SessionID, AgentID: cfg.Agent.ID, Object: "agent.session.turn", Status: turn.Status, CreatedAt: turn.CreatedAt.Unix(), StartedAt: unixTime(turn.StartedAt), CompletedAt: unixTime(turn.CompletedAt)} - if turn.Status == store.TurnFailed { + if turn.Status == sessions.TurnFailed { // Native errors can contain secrets; publish a stable category without raw diagnostics. response.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} } diff --git a/services/core/internal/api/turns_test.go b/services/core/internal/api/turns_test.go index 2d726bf7d..a06609817 100644 --- a/services/core/internal/api/turns_test.go +++ b/services/core/internal/api/turns_test.go @@ -10,6 +10,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -18,24 +19,24 @@ type turnReadStore struct { limit int ascending bool session store.Session - turn store.Turn + turn sessions.Turn } func (s *turnReadStore) GetSession(_ context.Context, tenant, id string) (store.Session, error) { s.tenant, s.sessionID = tenant, id return s.session, nil } -func (s *turnReadStore) GetTurn(_ context.Context, tenant, session, id string) (store.Turn, error) { +func (s *turnReadStore) GetTurn(_ context.Context, tenant, session, id string) (sessions.Turn, error) { s.tenant, s.sessionID, s.turnID = tenant, session, id return s.turn, nil } func (s *turnReadStore) ListTurns(_ context.Context, tenant, session, cursor string, limit int, asc bool) (store.TurnPage, error) { s.tenant, s.sessionID, s.cursor, s.limit, s.ascending = tenant, session, cursor, limit, asc - return store.TurnPage{Turns: []store.Turn{s.turn}, NextCursor: s.turn.ID}, nil + return store.TurnPage{Turns: []sessions.Turn{s.turn}, NextCursor: s.turn.ID}, nil } func TestTurnRoutesUseAuthenticatedScopeAndSafeProjection(t *testing.T) { - s := &turnReadStore{session: store.Session{Configuration: json.RawMessage(`{"agent":{"id":"agent_snapshot"}}`)}, turn: store.Turn{ID: "turn", SessionID: "session", Status: store.TurnFailed, CreatedAt: time.Unix(1700000000, 999), Outcome: json.RawMessage(`{"error":"Bearer SECRET","done":{"metadata":{"password":"SECRET"}}}`)}} + s := &turnReadStore{session: store.Session{Configuration: json.RawMessage(`{"agent":{"id":"agent_snapshot"}}`)}, turn: sessions.Turn{ID: "turn", SessionID: "session", Status: sessions.TurnFailed, CreatedAt: time.Unix(1700000000, 999), Outcome: json.RawMessage(`{"error":"Bearer SECRET","done":{"metadata":{"password":"SECRET"}}}`)}} h, _, tenant := testHandler(t, func(_ *Dependencies, f *testFakes) { f.sessions.getSession, f.sessionHistory.getTurn, f.sessionHistory.listTurns = s.GetSession, s.GetTurn, s.ListTurns }) @@ -84,10 +85,10 @@ func TestTurnRoutesUseAuthenticatedScopeAndSafeProjection(t *testing.T) { func TestTurnProjectionPreservesLifecycle(t *testing.T) { session := store.Session{Configuration: json.RawMessage(`{"agent":{"id":"agent_snapshot"}}`)} - for _, status := range []string{store.TurnQueued, store.TurnInProgress, store.TurnWaiting, store.TurnCompleted, store.TurnFailed, store.TurnCancelled} { - turn := store.Turn{Status: status, CreatedAt: time.Unix(1700000000, 0), StartedAt: time.Unix(1700000001, 0), CompletedAt: time.Unix(1700000002, 0)} + for _, status := range []string{sessions.TurnQueued, sessions.TurnInProgress, sessions.TurnWaiting, sessions.TurnCompleted, sessions.TurnFailed, sessions.TurnCancelled} { + turn := sessions.Turn{Status: status, CreatedAt: time.Unix(1700000000, 0), StartedAt: time.Unix(1700000001, 0), CompletedAt: time.Unix(1700000002, 0)} got, err := turnResponse(session, turn) - if err != nil || got.Status != status || *got.StartedAt != 1700000001 || *got.CompletedAt != 1700000002 || (got.Error != nil) != (status == store.TurnFailed) { + if err != nil || got.Status != status || *got.StartedAt != 1700000001 || *got.CompletedAt != 1700000002 || (got.Error != nil) != (status == sessions.TurnFailed) { t.Fatalf("lifecycle %s: %+v %v", status, got, err) } } diff --git a/services/core/internal/db/queries/session_events.sql b/services/core/internal/db/queries/session_events.sql index 25afdd5d0..4350f3866 100644 --- a/services/core/internal/db/queries/session_events.sql +++ b/services/core/internal/db/queries/session_events.sql @@ -23,18 +23,12 @@ ORDER BY sequence LIMIT 32; WITH retained AS ( SELECT sequence, row_number() OVER (ORDER BY sequence DESC) AS n, sum(payload_bytes) OVER (ORDER BY sequence DESC) AS bytes - FROM session_events e WHERE e.session_id = $1 + FROM session_events e WHERE e.session_id = sqlc.arg(session_id) ) -DELETE FROM session_events e WHERE e.session_id = $1 AND e.sequence IN ( - SELECT sequence FROM retained WHERE n > 256 OR (bytes > 67108864 AND n > 1) +DELETE FROM session_events e WHERE e.session_id = sqlc.arg(session_id) AND e.sequence IN ( + SELECT sequence FROM retained + WHERE n > sqlc.arg(retained_events)::bigint OR (bytes > sqlc.arg(retained_bytes)::bigint AND n > 1) ); --- name: FinishSessionItems :many -WITH observed AS MATERIALIZED (SELECT clock_timestamp() AS at) -UPDATE session_items SET payload = jsonb_set(payload, '{status}', '"incomplete"'), settled_at = COALESCE(session_items.settled_at, observed.at) -FROM observed -WHERE session_id = $1 AND turn_id = $2 AND payload->>'status' = 'in_progress' -RETURNING session_items.*; - -- name: SessionEventTurn :one SELECT * FROM turns WHERE session_id = $1 AND id = $2; diff --git a/services/core/internal/db/queries/session_items.sql b/services/core/internal/db/queries/session_items.sql index b1c2c508c..269897731 100644 --- a/services/core/internal/db/queries/session_items.sql +++ b/services/core/internal/db/queries/session_items.sql @@ -15,6 +15,19 @@ ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, THEN COALESCE(session_items.settled_at, EXCLUDED.created_at) ELSE session_items.settled_at END RETURNING *; +-- name: ListUnfinishedSessionItems :many +SELECT id, position, output_index, payload FROM session_items +WHERE session_id = $1 AND turn_id = $2 AND payload->>'status' = 'in_progress'; + +-- name: FinishSessionItems :exec +-- The finished Items share one settlement time; an Item keeps a settlement +-- time it already has. +WITH observed AS MATERIALIZED (SELECT clock_timestamp() AS at) +UPDATE session_items SET payload = jsonb_set(payload, '{status}', to_jsonb(sqlc.arg(status)::text)), + settled_at = COALESCE(session_items.settled_at, observed.at) +FROM observed +WHERE session_id = sqlc.arg(session_id) AND id = ANY(sqlc.arg(ids)::uuid[]); + -- name: ListSessionItems :many SELECT i.id, i.created_at, (CASE WHEN i.payload->>'status' = 'in_progress' AND t.status IN ('completed', 'failed', 'cancelled') diff --git a/services/core/internal/db/sqlc/session_events.sql.go b/services/core/internal/db/sqlc/session_events.sql.go index 491b41709..4648deed9 100644 --- a/services/core/internal/db/sqlc/session_events.sql.go +++ b/services/core/internal/db/sqlc/session_events.sql.go @@ -30,48 +30,6 @@ func (q *Queries) AppendSessionEvent(ctx context.Context, arg AppendSessionEvent return err } -const finishSessionItems = `-- name: FinishSessionItems :many -WITH observed AS MATERIALIZED (SELECT clock_timestamp() AS at) -UPDATE session_items SET payload = jsonb_set(payload, '{status}', '"incomplete"'), settled_at = COALESCE(session_items.settled_at, observed.at) -FROM observed -WHERE session_id = $1 AND turn_id = $2 AND payload->>'status' = 'in_progress' -RETURNING session_items.id, session_items.session_id, session_items.turn_id, session_items.created_at, session_items.position, session_items.payload, session_items.output_index, session_items.settled_at -` - -type FinishSessionItemsParams struct { - SessionID pgtype.UUID `json:"session_id"` - TurnID pgtype.UUID `json:"turn_id"` -} - -func (q *Queries) FinishSessionItems(ctx context.Context, arg FinishSessionItemsParams) ([]SessionItem, error) { - rows, err := q.db.Query(ctx, finishSessionItems, arg.SessionID, arg.TurnID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []SessionItem{} - for rows.Next() { - var i SessionItem - if err := rows.Scan( - &i.ID, - &i.SessionID, - &i.TurnID, - &i.CreatedAt, - &i.Position, - &i.Payload, - &i.OutputIndex, - &i.SettledAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const listSessionEvents = `-- name: ListSessionEvents :many SELECT sequence, payload FROM ( SELECT e.sequence, e.payload, @@ -121,12 +79,19 @@ WITH retained AS ( FROM session_events e WHERE e.session_id = $1 ) DELETE FROM session_events e WHERE e.session_id = $1 AND e.sequence IN ( - SELECT sequence FROM retained WHERE n > 256 OR (bytes > 67108864 AND n > 1) + SELECT sequence FROM retained + WHERE n > $2::bigint OR (bytes > $3::bigint AND n > 1) ) ` -func (q *Queries) PruneSessionEvents(ctx context.Context, sessionID pgtype.UUID) error { - _, err := q.db.Exec(ctx, pruneSessionEvents, sessionID) +type PruneSessionEventsParams struct { + SessionID pgtype.UUID `json:"session_id"` + RetainedEvents int64 `json:"retained_events"` + RetainedBytes int64 `json:"retained_bytes"` +} + +func (q *Queries) PruneSessionEvents(ctx context.Context, arg PruneSessionEventsParams) error { + _, err := q.db.Exec(ctx, pruneSessionEvents, arg.SessionID, arg.RetainedEvents, arg.RetainedBytes) return err } diff --git a/services/core/internal/db/sqlc/session_items.sql.go b/services/core/internal/db/sqlc/session_items.sql.go index 30e328fe2..d7c498c15 100644 --- a/services/core/internal/db/sqlc/session_items.sql.go +++ b/services/core/internal/db/sqlc/session_items.sql.go @@ -11,6 +11,27 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const finishSessionItems = `-- name: FinishSessionItems :exec +WITH observed AS MATERIALIZED (SELECT clock_timestamp() AS at) +UPDATE session_items SET payload = jsonb_set(payload, '{status}', to_jsonb($1::text)), + settled_at = COALESCE(session_items.settled_at, observed.at) +FROM observed +WHERE session_id = $2 AND id = ANY($3::uuid[]) +` + +type FinishSessionItemsParams struct { + Status string `json:"status"` + SessionID pgtype.UUID `json:"session_id"` + Ids []pgtype.UUID `json:"ids"` +} + +// The finished Items share one settlement time; an Item keeps a settlement +// time it already has. +func (q *Queries) FinishSessionItems(ctx context.Context, arg FinishSessionItemsParams) error { + _, err := q.db.Exec(ctx, finishSessionItems, arg.Status, arg.SessionID, arg.Ids) + return err +} + const getSessionItem = `-- name: GetSessionItem :one SELECT id, session_id, turn_id, created_at, position, payload, output_index, settled_at FROM session_items WHERE session_id = $1 AND id = $2 ` @@ -214,6 +235,48 @@ func (q *Queries) ListTurnItemDiagnostics(ctx context.Context, arg ListTurnItemD return items, nil } +const listUnfinishedSessionItems = `-- name: ListUnfinishedSessionItems :many +SELECT id, position, output_index, payload FROM session_items +WHERE session_id = $1 AND turn_id = $2 AND payload->>'status' = 'in_progress' +` + +type ListUnfinishedSessionItemsParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` +} + +type ListUnfinishedSessionItemsRow struct { + ID pgtype.UUID `json:"id"` + Position int32 `json:"position"` + OutputIndex pgtype.Int4 `json:"output_index"` + Payload []byte `json:"payload"` +} + +func (q *Queries) ListUnfinishedSessionItems(ctx context.Context, arg ListUnfinishedSessionItemsParams) ([]ListUnfinishedSessionItemsRow, error) { + rows, err := q.db.Query(ctx, listUnfinishedSessionItems, arg.SessionID, arg.TurnID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUnfinishedSessionItemsRow{} + for rows.Next() { + var i ListUnfinishedSessionItemsRow + if err := rows.Scan( + &i.ID, + &i.Position, + &i.OutputIndex, + &i.Payload, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const putSessionItem = `-- name: PutSessionItem :one INSERT INTO session_items(id, session_id, turn_id, created_at, payload, position, output_index, settled_at) VALUES ($1, $2, $3, $4, $5, diff --git a/services/core/internal/execution/archive_cancellation_cleanup_test.go b/services/core/internal/execution/archive_cancellation_cleanup_test.go index f02edbf74..36cdde83e 100644 --- a/services/core/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/core/internal/execution/archive_cancellation_cleanup_test.go @@ -21,6 +21,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -99,7 +100,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { } // This fixture isolates lifecycle ordering. Protocol-driven waiting is // independently exercised in TestArchiveWaitingCancellationReceipts. - for _, transition := range []store.TurnTransition{{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}, {ExpectedStatus: store.TurnInProgress, Status: store.TurnWaiting}} { + for _, transition := range []store.TurnTransition{{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}, {ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnWaiting}} { if _, err := writer.TransitionTurn(t.Context(), project.TenantID, session.ID, input.TurnID, transition); err != nil { t.Fatal(err) } @@ -156,11 +157,11 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { t.Fatal(err) } kills := 0 - expectedStatus := store.TurnWaiting + expectedStatus := sessions.TurnWaiting provider := waitingCleanupProvider{beforeKill: func() { kills++ turn, err := s.GetTurn(t.Context(), project.TenantID, session.ID, input.TurnID) - if err != nil || turn.Status != expectedStatus || turn.CancelRequestedAt.IsZero() || (turn.CompletedAt.IsZero() != (expectedStatus == store.TurnWaiting)) { + if err != nil || turn.Status != expectedStatus || turn.CancelRequestedAt.IsZero() || (turn.CompletedAt.IsZero() != (expectedStatus == sessions.TurnWaiting)) { t.Fatal("cleanup observed unexpected terminal state", turn, err) } allocation, err := s.GetRuntimeAllocation(t.Context(), project.TenantID, session.Environment.ID) @@ -184,10 +185,10 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { t.Fatal(pending, err) } // Controlled terminal receipt fixture; no native cancellation claim. - if _, err := writer.TransitionTurn(t.Context(), project.TenantID, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnWaiting, Status: store.TurnCancelled}); err != nil { + if _, err := writer.TransitionTurn(t.Context(), project.TenantID, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnWaiting, Status: sessions.TurnCancelled}); err != nil { t.Fatal(err) } - expectedStatus = store.TurnCancelled + expectedStatus = sessions.TurnCancelled if err := lifecycle.observe(t.Context(), owner); err != nil { t.Fatal(err) } @@ -197,7 +198,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { t.Fatal("cleanup did not release", after, kills, err) } turn, err := s.GetTurn(t.Context(), project.TenantID, session.ID, input.TurnID) - if err != nil || turn.Status != expectedStatus || (turn.CompletedAt.IsZero() != (expectedStatus == store.TurnWaiting)) { + if err != nil || turn.Status != expectedStatus || (turn.CompletedAt.IsZero() != (expectedStatus == sessions.TurnWaiting)) { t.Fatal("cleanup should not fabricate cancellation", turn, err) } }) diff --git a/services/core/internal/execution/artifacts.go b/services/core/internal/execution/artifacts.go index 3ed933b3b..9d6e42eb4 100644 --- a/services/core/internal/execution/artifacts.go +++ b/services/core/internal/execution/artifacts.go @@ -8,11 +8,12 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (d *Dispatcher) captureCompletedArtifacts(ctx context.Context, peer *runtimegateway.Session, session store.Session, environment store.Environment, bound store.ExecutionDevice, turnID string, result Result, status string) (Result, string) { - if status != store.TurnCompleted || !LocalWorkspaceConfiguration(environment.Configuration) { + if status != sessions.TurnCompleted || !LocalWorkspaceConfiguration(environment.Configuration) { return result, status } owner, cancel := context.WithTimeout(ctx, 180*time.Second) @@ -33,11 +34,11 @@ func (d *Dispatcher) captureCompletedArtifacts(ctx context.Context, peer *runtim if errors.Is(err, store.ErrUnappliedInputs) { result.ErrorCode = "input_not_applied" } - status = store.TurnFailed + status = sessions.TurnFailed check, stop := context.WithTimeout(context.Background(), 5*time.Second) defer stop() if turn, err := d.Store.GetTurn(check, session.TenantID, session.ID, turnID); err == nil && !turn.CancelRequestedAt.IsZero() { - status = store.TurnCancelled + status = sessions.TurnCancelled } return result, status } diff --git a/services/core/internal/execution/delivery.go b/services/core/internal/execution/delivery.go index b215a7d2e..df15ca24f 100644 --- a/services/core/internal/execution/delivery.go +++ b/services/core/internal/execution/delivery.go @@ -9,7 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) type pendingInput struct { @@ -53,7 +53,7 @@ func abort(peer *runtimegateway.Session, runID string) { func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, peer *runtimegateway.Session, request proto.PromptRequestPayload, first int64, prepared *preparedStart) (result Result, status string) { changed, unsubscribeChanges := d.notifications.subscribe(tenantID, sessionID) defer unsubscribeChanges() - status = store.TurnFailed + status = sessions.TurnFailed result.AppliedThrough = first subscription, err := peer.SubscribeDurable(request.RunID) if err != nil { @@ -63,7 +63,7 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe upstream := subscription.Events defer peer.Unsubscribe(request.RunID) defer func() { - if status == store.TurnFailed { + if status == sessions.TurnFailed { abort(peer, request.RunID) } }() @@ -73,13 +73,13 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe finishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := journal.drain(upstream, &result); err != nil { - result.ErrorCode, status = "event_persistence_failed", store.TurnFailed + result.ErrorCode, status = "event_persistence_failed", sessions.TurnFailed } if err := journal.flush(finishCtx); err != nil { - result.ErrorCode, status = "event_persistence_failed", store.TurnFailed + result.ErrorCode, status = "event_persistence_failed", sessions.TurnFailed } if subscription.Err() != nil { - result.ErrorCode, status = "event_stream_incomplete", store.TurnFailed + result.ErrorCode, status = "event_stream_incomplete", sessions.TurnFailed } }() var preparationEvents <-chan proto.Envelope @@ -137,7 +137,7 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe result.ErrorCode = "device_disconnected" return } - replacement, prepareErr := d.prepareTurnExecutor(ctx, peer, tenantID, sessionID, request.RunID, request, store.TurnInProgress) + replacement, prepareErr := d.prepareTurnExecutor(ctx, peer, tenantID, sessionID, request.RunID, request, sessions.TurnInProgress) if prepareErr != nil { result.ErrorCode = "executor_recovery_failed" return @@ -185,7 +185,7 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe result.ErrorCode = "cancel_outcome_unavailable" return } - status = store.TurnCancelled + status = sessions.TurnCancelled } else { result.ErrorCode = "cancel_unconfirmed" } @@ -286,7 +286,7 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe } return } - if turn.Status != store.TurnInProgress && turn.Status != store.TurnWaiting { + if turn.Status != sessions.TurnInProgress && turn.Status != sessions.TurnWaiting { result.ErrorCode = "execution_state_changed" return } diff --git a/services/core/internal/execution/deployment_provider_observations.go b/services/core/internal/execution/deployment_provider_observations.go index 458c38f9c..672ec93ce 100644 --- a/services/core/internal/execution/deployment_provider_observations.go +++ b/services/core/internal/execution/deployment_provider_observations.go @@ -7,14 +7,14 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) // Observation is strictly after terminal commit. Its pool/lock timeout cannot // cancel the execution lease or change the already committed public outcome. -func (d *Dispatcher) observeDeploymentProvider(tenantID, sessionID string, turn store.Turn) { - if turn.Status != store.TurnCompleted { - if turn.Status != store.TurnFailed { +func (d *Dispatcher) observeDeploymentProvider(tenantID, sessionID string, turn sessions.Turn) { + if turn.Status != sessions.TurnCompleted { + if turn.Status != sessions.TurnFailed { return } var result Result diff --git a/services/core/internal/execution/deployment_provider_observations_test.go b/services/core/internal/execution/deployment_provider_observations_test.go index 97f7fbe86..e19a84152 100644 --- a/services/core/internal/execution/deployment_provider_observations_test.go +++ b/services/core/internal/execution/deployment_provider_observations_test.go @@ -11,6 +11,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -65,7 +66,7 @@ func (f finishObservationFixture) start(t *testing.T) store.InputReceipt { if err != nil { t.Fatal(err) } - if _, err = f.writer.TransitionTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = f.writer.TransitionTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } return receipt @@ -83,13 +84,13 @@ func TestFinishRunObservesOnlyFinalCommittedOutcome(t *testing.T) { name, status, core, native string unapplied, invalid, used, failed bool }{ - {name: "success", status: store.TurnCompleted, used: true}, - {name: "provider_failure", status: store.TurnFailed, core: "engine_failed", native: "authentication_error", failed: true}, - {name: "runtime_dominates", status: store.TurnFailed, core: "device_disconnected", native: "authentication_error"}, - {name: "input_policy", status: store.TurnFailed, core: "engine_failed", native: "cyber_policy"}, - {name: "cancelled", status: store.TurnCancelled, core: "engine_failed", native: "authentication_error"}, - {name: "fallback", status: store.TurnCompleted, core: "engine_failed", native: "authentication_error", unapplied: true}, - {name: "invalid_result", status: store.TurnCompleted, invalid: true}, + {name: "success", status: sessions.TurnCompleted, used: true}, + {name: "provider_failure", status: sessions.TurnFailed, core: "engine_failed", native: "authentication_error", failed: true}, + {name: "runtime_dominates", status: sessions.TurnFailed, core: "device_disconnected", native: "authentication_error"}, + {name: "input_policy", status: sessions.TurnFailed, core: "engine_failed", native: "cyber_policy"}, + {name: "cancelled", status: sessions.TurnCancelled, core: "engine_failed", native: "authentication_error"}, + {name: "fallback", status: sessions.TurnCompleted, core: "engine_failed", native: "authentication_error", unapplied: true}, + {name: "invalid_result", status: sessions.TurnCompleted, invalid: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -107,7 +108,7 @@ func TestFinishRunObservesOnlyFinalCommittedOutcome(t *testing.T) { t.Fatal(err) } if tc.unapplied || tc.invalid { - if turn.Status != store.TurnFailed { + if turn.Status != sessions.TurnFailed { t.Fatal("fallback not final", turn.Status) } } @@ -174,7 +175,7 @@ func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { t.Fatal(err) } defer conn.Release() - turn, err := f.writer.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, store.TurnCompleted, json.RawMessage(`{}`), "", receipt.Sequence) + turn, err := f.writer.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, sessions.TurnCompleted, json.RawMessage(`{}`), "", receipt.Sequence) if err != nil { t.Fatal(err) } @@ -193,9 +194,9 @@ func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { defer cleanup() } started := time.Now() - turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", Result{AppliedThrough: receipt.Sequence}, store.TurnCompleted) + turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", Result{AppliedThrough: receipt.Sequence}, sessions.TurnCompleted) elapsed := time.Since(started) - if err != nil || turn.Status != store.TurnCompleted { + if err != nil || turn.Status != sessions.TurnCompleted { t.Fatal("observation changed commit result", err, turn.Status) } if elapsed > 2*time.Second || (mode == "lock_timeout" && elapsed < 900*time.Millisecond) { @@ -205,7 +206,7 @@ func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { cleanup() } persisted, err := f.s.GetTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID) - if err != nil || persisted.Status != store.TurnCompleted { + if err != nil || persisted.Status != sessions.TurnCompleted { t.Fatal("terminal outcome lost", err) } used, code := f.fields(t) diff --git a/services/core/internal/execution/dispatcher.go b/services/core/internal/execution/dispatcher.go index fdb297b9c..392fefeac 100644 --- a/services/core/internal/execution/dispatcher.go +++ b/services/core/internal/execution/dispatcher.go @@ -11,6 +11,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -45,61 +46,61 @@ type Result struct { } // Run claims once before subscribing or sending. Uncertain deliveries are not replayed. -func (d *Dispatcher) Run(ctx context.Context, tenantID, sessionID, turnID string) (store.Turn, error) { +func (d *Dispatcher) Run(ctx context.Context, tenantID, sessionID, turnID string) (sessions.Turn, error) { session, err := d.Store.GetSession(ctx, tenantID, sessionID) if err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } bound, err := d.Store.GetSessionExecutionBinding(ctx, tenantID, sessionID) if err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } peer, err := d.authorizedPeer(ctx, bound.Device.ID) if err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } var snapshot Snapshot if json.Unmarshal(session.Configuration, &snapshot) != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { - return store.Turn{}, store.ErrInvalidInput + return sessions.Turn{}, store.ErrInvalidInput } caps, err := d.engineCapabilities(peer, session.Engine, snapshot) if err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } if !environmentNone(snapshot) { - return store.Turn{}, store.ErrInvalidInput + return sessions.Turn{}, store.ErrInvalidInput } text, through, err := d.initialInput(ctx, tenantID, sessionID, turnID) if err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } if err := d.messageInputSupport(peer, session.Engine, snapshot, text); err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } req, err := d.executionRequest(ctx, session, snapshot, caps, bound) if err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } req.DisableExecutionEnvironment = true - prepared, err := d.prepareTurnExecutor(ctx, peer, tenantID, sessionID, turnID, req, store.TurnQueued) + prepared, err := d.prepareTurnExecutor(ctx, peer, tenantID, sessionID, turnID, req, sessions.TurnQueued) if err != nil { - return store.Turn{}, err + return sessions.Turn{}, err } defer prepared.close() - if _, err := d.Store.TransitionTurn(ctx, tenantID, sessionID, turnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { - return store.Turn{}, err + if _, err := d.Store.TransitionTurn(ctx, tenantID, sessionID, turnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { + return sessions.Turn{}, err } req.ConversationID, req.RunID, req.Input = sessionID, turnID, text release, err := peer.TrackExecutionDelivery(req.RunID) if err != nil { - return d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, store.TurnFailed) + return d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, sessions.TurnFailed) } defer release() result, status := d.deliver(ctx, tenantID, sessionID, peer, req, through, prepared) return d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, result, status) } -func (d *Dispatcher) finishRun(tenantID, sessionID, turnID, model string, result Result, status string) (store.Turn, error) { +func (d *Dispatcher) finishRun(tenantID, sessionID, turnID, model string, result Result, status string) (sessions.Turn, error) { if result.Done.Usage.Model == "" { result.Done.Usage.Model = model } @@ -110,13 +111,13 @@ func (d *Dispatcher) finishRun(tenantID, sessionID, turnID, model string, result if err != nil || len(encoded) > 512*1024 || len(nativeID) > 512 { result = Result{ErrorCode: "invalid_executor_result", AppliedThrough: result.AppliedThrough} encoded, _ = json.Marshal(result) - status, nativeID = store.TurnFailed, "" + status, nativeID = sessions.TurnFailed, "" } turn, err := d.Store.CompleteExecution(finishCtx, tenantID, sessionID, turnID, status, encoded, nativeID, result.AppliedThrough) if errors.Is(err, store.ErrUnappliedInputs) { result.ErrorCode = "input_not_applied" encoded, _ = json.Marshal(result) - turn, err = d.Store.CompleteExecution(finishCtx, tenantID, sessionID, turnID, store.TurnFailed, encoded, nativeID, result.AppliedThrough) + turn, err = d.Store.CompleteExecution(finishCtx, tenantID, sessionID, turnID, sessions.TurnFailed, encoded, nativeID, result.AppliedThrough) } if err == nil { d.observeDeploymentProvider(tenantID, sessionID, turn) diff --git a/services/core/internal/execution/engine_failure_test.go b/services/core/internal/execution/engine_failure_test.go index 792dde41b..fab868cc1 100644 --- a/services/core/internal/execution/engine_failure_test.go +++ b/services/core/internal/execution/engine_failure_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) func TestEngineClassificationSurvivesDrainWithoutChangingSettlement(t *testing.T) { @@ -68,7 +68,7 @@ func TestClassifiedErrorCannotOverrideCancellationReceipt(t *testing.T) { t.Fatal(err) } } - if status != store.TurnCancelled || result.Done.Usage.InputTokens != 9 || result.Done.Metadata[proto.DoneMetaAgentSessionID] != "native" || result.EngineErrorCode != "rate_limit_exceeded" { + if status != sessions.TurnCancelled || result.Done.Usage.InputTokens != 9 || result.Done.Metadata[proto.DoneMetaAgentSessionID] != "native" || result.EngineErrorCode != "rate_limit_exceeded" { t.Fatalf("receipt lost authority: %s %+v", status, result) } } diff --git a/services/core/internal/execution/environment_directory.go b/services/core/internal/execution/environment_directory.go index 8cbab2fa7..cf05316ca 100644 --- a/services/core/internal/execution/environment_directory.go +++ b/services/core/internal/execution/environment_directory.go @@ -6,6 +6,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -91,7 +92,7 @@ func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRe return } run := "" - if session.LastTurn != nil && (session.LastTurn.Status == store.TurnInProgress || session.LastTurn.Status == store.TurnWaiting) { + if session.LastTurn != nil && (session.LastTurn.Status == sessions.TurnInProgress || session.LastTurn.Status == sessions.TurnWaiting) { run = session.LastTurn.ID } if (run == "") != reserved { diff --git a/services/core/internal/execution/finish.go b/services/core/internal/execution/finish.go index ab14c9c72..8521e241e 100644 --- a/services/core/internal/execution/finish.go +++ b/services/core/internal/execution/finish.go @@ -3,7 +3,7 @@ package execution import ( "context" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) func finishDelivery(ctx context.Context, journal *journal, result *Result, cancelReply <-chan cancellationResult, pendingInput bool, functions *functionExchange) string { @@ -12,18 +12,18 @@ func finishDelivery(ctx context.Context, journal *journal, result *Result, cance case reply := <-cancelReply: if recordCancellation(ctx, journal, reply, result) != nil { result.ErrorCode = "event_persistence_failed" - return store.TurnFailed + return sessions.TurnFailed } if reply.err == nil && reply.ack.Applied { - return store.TurnCancelled + return sessions.TurnCancelled } if reply.err != nil || reply.ack.ErrorCode != "run_inactive" { result.ErrorCode = "cancel_unconfirmed" - return store.TurnFailed + return sessions.TurnFailed } case <-ctx.Done(): result.ErrorCode = "cancel_unconfirmed" - return store.TurnFailed + return sessions.TurnFailed } } if result.ErrorCode == "" { @@ -32,8 +32,8 @@ func finishDelivery(ctx context.Context, journal *journal, result *Result, cance } else if functions.complete(ctx) != nil { result.ErrorCode = "function_result_unconfirmed" } else { - return store.TurnCompleted + return sessions.TurnCompleted } } - return store.TurnFailed + return sessions.TurnFailed } diff --git a/services/core/internal/execution/functions.go b/services/core/internal/execution/functions.go index c925f10cc..d1b8e32eb 100644 --- a/services/core/internal/execution/functions.go +++ b/services/core/internal/execution/functions.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -146,7 +147,7 @@ func (f *functionExchange) complete(ctx context.Context) error { if err != nil { return err } - if turn.Status == store.TurnWaiting { + if turn.Status == sessions.TurnWaiting { return errors.New("function turn has not resumed") } return nil diff --git a/services/core/internal/execution/prepared_dispatch.go b/services/core/internal/execution/prepared_dispatch.go index b2545dca4..ac3fa0d49 100644 --- a/services/core/internal/execution/prepared_dispatch.go +++ b/services/core/internal/execution/prepared_dispatch.go @@ -8,12 +8,13 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type EnvironmentRun struct { Reservation store.EnvironmentInputReservation - Turn store.Turn + Turn sessions.Turn } // RunEnvironmentInput reserves a Turn on the Session-owned Runtime Executor. It @@ -112,7 +113,7 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, lease Ownership, t through := run.Reservation.Receipts[len(run.Reservation.Receipts)-1].Sequence releaseDelivery, err := peer.TrackExecutionDelivery(req.RunID) if err != nil { - run.Turn, err = d.finishRun(tenantID, sessionID, req.RunID, snapshot.Agent.Model, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, store.TurnFailed) + run.Turn, err = d.finishRun(tenantID, sessionID, req.RunID, snapshot.Agent.Model, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, sessions.TurnFailed) return run, err } defer releaseDelivery() diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index 0d8989db5..97e91ad22 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -330,7 +331,7 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { func (w *Worker) reconcile(ctx context.Context) error { cursor := "" for { - work, err := w.dispatcher.Store.ListExecutionWork(ctx, cursor, []string{store.TurnInProgress, store.TurnWaiting}, nil) + work, err := w.dispatcher.Store.ListExecutionWork(ctx, cursor, []string{sessions.TurnInProgress, sessions.TurnWaiting}, nil) if err != nil { return err } @@ -338,7 +339,7 @@ func (w *Worker) reconcile(ctx context.Context) error { return nil } for _, item := range work { - _, err := w.dispatcher.Store.TransitionTurn(ctx, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: item.Status, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_interrupted"}`)}) + _, err := w.dispatcher.Store.TransitionTurn(ctx, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: item.Status, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_interrupted"}`)}) if err != nil && !errors.Is(err, store.ErrTurnConflict) { return err } @@ -364,16 +365,16 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { if err != nil { return err } - if turn.Status == store.TurnQueued && capacityRejected { + if turn.Status == sessions.TurnQueued && capacityRejected { // No input was sent. Leave durable work for the existing scheduler tick; // active and cleanup-held Runtime capacity have the same rejection. return nil } - if turn.Status == store.TurnCompleted || turn.Status == store.TurnFailed || turn.Status == store.TurnCancelled { + if turn.Status == sessions.TurnCompleted || turn.Status == sessions.TurnFailed || turn.Status == sessions.TurnCancelled { return nil } log.Ctx(ctx).Error("oac-core dispatch did not complete", "turn_id", item.TurnID) - _, err = w.dispatcher.Store.TransitionTurn(finish, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: turn.Status, Status: store.TurnFailed, Outcome: outcome}) + _, err = w.dispatcher.Store.TransitionTurn(finish, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: turn.Status, Status: sessions.TurnFailed, Outcome: outcome}) if errors.Is(err, store.ErrTurnConflict) { return nil } diff --git a/services/core/internal/execution/worker_device.go b/services/core/internal/execution/worker_device.go index b02550227..83e3df3ef 100644 --- a/services/core/internal/execution/worker_device.go +++ b/services/core/internal/execution/worker_device.go @@ -6,6 +6,7 @@ import ( "errors" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -23,7 +24,7 @@ func (w *Worker) bind(ctx context.Context, item store.ExecutionWork) (bool, erro if err != nil { return false, err } - if turn.Status != store.TurnQueued || !turn.CancelRequestedAt.IsZero() { + if turn.Status != sessions.TurnQueued || !turn.CancelRequestedAt.IsZero() { return false, nil } if inputErr != nil { @@ -36,7 +37,7 @@ func (w *Worker) bind(ctx context.Context, item store.ExecutionWork) (bool, erro if !errors.Is(err, store.ErrDeviceBindingConflict) { return ready, err } - _, err = w.dispatcher.Store.TransitionTurn(ctx, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_device_unavailable"}`)}) + _, err = w.dispatcher.Store.TransitionTurn(ctx, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_device_unavailable"}`)}) if errors.Is(err, store.ErrTurnConflict) { err = nil } diff --git a/services/core/internal/execution/worker_schedule.go b/services/core/internal/execution/worker_schedule.go index 70b103d9b..db0ac4c07 100644 --- a/services/core/internal/execution/worker_schedule.go +++ b/services/core/internal/execution/worker_schedule.go @@ -6,6 +6,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -21,13 +22,13 @@ type scheduledWork struct { } func (s *workerSchedule) selectWork(ctx context.Context, w *Worker, devices []string, active map[string]bool) ([]scheduledWork, error) { - turns, err := w.dispatcher.Store.ListExecutionWork(ctx, s.turnCursor, []string{store.TurnQueued}, devices) + turns, err := w.dispatcher.Store.ListExecutionWork(ctx, s.turnCursor, []string{sessions.TurnQueued}, devices) if err != nil { return nil, err } if len(turns) == 0 && s.turnCursor != "" { s.turnCursor = "" - turns, err = w.dispatcher.Store.ListExecutionWork(ctx, "", []string{store.TurnQueued}, devices) + turns, err = w.dispatcher.Store.ListExecutionWork(ctx, "", []string{sessions.TurnQueued}, devices) if err != nil { return nil, err } diff --git a/services/core/internal/items/changes.go b/services/core/internal/items/changes.go new file mode 100644 index 000000000..adf6c6fc1 --- /dev/null +++ b/services/core/internal/items/changes.go @@ -0,0 +1,80 @@ +package items + +import ( + "encoding/json" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Stored is what a Session already holds for an Update's Item. +type Stored struct { + // Item is the stored Item, or the zero Item when the Update adds a new one. + Item v1.Item + // NativeMessage reports that the Turn has an assistant message other than + // this Item. Observe reads it only when the Update NeedsNativeMessage. + NativeMessage bool + // FunctionResult is the result the application saved for the call, if any. + // Observe reads it only for an Update with a ResultCall. + FunctionResult json.RawMessage +} + +// Change is one decided Item write. +type Change struct { + // Previous is the stored Item, zero for a new one. + Previous v1.Item + Item v1.Item + // Output reports whether a new Item takes the Turn's next output index. + // Inputs, function results included, take none. + Output bool + // Delta is the text or command output fragment this observation + // contributed, nil when it contributed none. + Delta *string +} + +// NeedsNativeMessage reports whether Observe reads Stored.NativeMessage for u: +// a legacy aggregate applies only when the Turn recorded no native message. +func (u Update) NeedsNativeMessage() bool { + return u.LegacyFinal +} + +// ResultCall returns the call whose saved result Observe reads for u, a +// function result. +func (u Update) ResultCall() (string, bool) { + return u.Item.CallID, u.Item.Type == "function_call_output" +} + +// Observe decides how an Update projected from an observation of kind changes +// its stored Item. It reports false when the Update is superseded and writes +// nothing. +func Observe(kind string, update Update, stored Stored) (Change, bool, error) { + if update.LegacyFinal && stored.NativeMessage { + return Change{}, false, nil + } + item, err := Merge(update, stored.Item) + if err != nil { + return Change{}, false, err + } + if item.Type == "function_call_output" && len(stored.FunctionResult) > 0 { + var fields map[string]json.RawMessage + if err := json.Unmarshal(stored.FunctionResult, &fields); err != nil { + return Change{}, false, err + } + // Native results may normalize content; public Items retain the saved submission. + item.Output, item.Error = nil, nil + if value, ok := fields["output"]; ok { + item.Output = value + } + if value, ok := fields["error"]; ok { + item.Error = value + } + } + change := Change{Previous: stored.Item, Item: item, Output: kind != "message" && item.Type != "function_call_output"} + switch kind { + case proto.TypeDelta: + change.Delta = update.Item.Content[0].Text + case proto.TypeCommandOutput: + change.Delta = update.CommandOutputDelta + } + return change, true, nil +} diff --git a/services/core/internal/items/changes_test.go b/services/core/internal/items/changes_test.go new file mode 100644 index 000000000..38cf514ca --- /dev/null +++ b/services/core/internal/items/changes_test.go @@ -0,0 +1,88 @@ +package items + +import ( + "encoding/json" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func TestObserveDecidesItemChanges(t *testing.T) { + project := func(kind, body string) Update { + t.Helper() + updates, err := Project(testTurn, kind, 1, []byte(body)) + if err != nil || len(updates) == 0 { + t.Fatal(updates, err) + } + return updates[len(updates)-1] + } + draft := project("delta", `{"item_id":"a","delta":"draft"}`).Item + command := project("tool_call", `{"id":"c","stage":"before","observation":{"status":"in_progress","kind":"command","command":"ls"}}`).Item + result := project("tool_call", `{"id":"f","stage":"after","observation":{"status":"completed","kind":"function","name":"lookup","content":[{"type":"input_text","text":"native"}]}}`) + for _, test := range []struct { + name string + kind string + update Update + stored Stored + skip bool + check func(Change) bool + }{ + { + name: "a legacy aggregate yields to a native message", kind: "execution_completed", + update: project("execution_completed", `{"done":{"content":"final"}}`), stored: Stored{NativeMessage: true}, skip: true, + }, + { + name: "a legacy aggregate applies without a native message", kind: "execution_completed", + update: project("execution_completed", `{"done":{"content":"final"}}`), + check: func(c Change) bool { return c.Item.Status == "completed" && c.Output && c.Delta == nil }, + }, + { + name: "a text delta merges and keeps its own fragment", kind: "delta", + update: project("delta", `{"item_id":"a","delta":" more"}`), stored: Stored{Item: draft}, + check: func(c Change) bool { + return *c.Item.Content[0].Text == "draft more" && *c.Delta == " more" && *c.Previous.Content[0].Text == "draft" + }, + }, + { + name: "command output carries its fragment", kind: "command_output", + update: project("command_output", `{"id":"c","delta":"out"}`), stored: Stored{Item: command}, + check: func(c Change) bool { return *c.Delta == "out" && c.Item.Output == "out" && c.Output }, + }, + { + name: "a function result keeps the saved submission and takes no output index", kind: "tool_call", + update: result, stored: Stored{FunctionResult: json.RawMessage(`{"output":"saved","error":null}`)}, + check: func(c Change) bool { + return string(c.Item.Output.(json.RawMessage)) == `"saved"` && string(c.Item.Error.(json.RawMessage)) == "null" && !c.Output + }, + }, + { + name: "an input message takes no output index", kind: "message", + update: project("message", `{"text":"hello"}`), + check: func(c Change) bool { return !c.Output && c.Item.Role == "user" }, + }, + } { + t.Run(test.name, func(t *testing.T) { + change, ok, err := Observe(test.kind, test.update, test.stored) + if err != nil || ok == test.skip { + t.Fatal(ok, err) + } + if !test.skip && !test.check(change) { + t.Fatalf("%+v", change) + } + }) + } +} + +func TestUpdatesDeclareTheFactsObserveReads(t *testing.T) { + legacy := Update{Item: v1.Item{Type: "message"}, LegacyFinal: true} + result := Update{Item: v1.Item{Type: "function_call_output", CallID: "call"}} + if !legacy.NeedsNativeMessage() || result.NeedsNativeMessage() { + t.Fatal("native message need") + } + if call, ok := result.ResultCall(); !ok || call != "call" { + t.Fatal("result call", call, ok) + } + if _, ok := legacy.ResultCall(); ok { + t.Fatal("message asked for a function result") + } +} diff --git a/services/core/internal/items/doc.go b/services/core/internal/items/doc.go new file mode 100644 index 000000000..b9a92c72c --- /dev/null +++ b/services/core/internal/items/doc.go @@ -0,0 +1,4 @@ +// Package items owns public Items: it projects execution observations to the +// supported Item variants, merges them into stored Items and builds the ordered +// public events that report each Item change. +package items diff --git a/services/core/internal/store/item_events.go b/services/core/internal/items/events.go similarity index 63% rename from services/core/internal/store/item_events.go rename to services/core/internal/items/events.go index 8345935d7..db27391e0 100644 --- a/services/core/internal/store/item_events.go +++ b/services/core/internal/items/events.go @@ -1,29 +1,32 @@ -package store +package items import ( - "context" "reflect" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/jackc/pgx/v5/pgtype" ) -func recordItemChange(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, index pgtype.Int4, previous, item v1.Item, delta *string) error { +// Events returns the public events that report change, in publication order. +// outputIndex is the Item's output index, nil when it has none. An unchanged +// Item reports nothing. +func Events(change Change, outputIndex *int32) []v1.SessionEvent { + previous, item, delta := change.Previous, change.Item, change.Delta if reflect.DeepEqual(previous, item) { return nil } // Function results are Session inputs, not AgentOutputItem variants. if item.Type == "function_call_output" { - index.Valid = false + outputIndex = nil } base := v1.SessionEvent{TurnID: item.TurnID} - if index.Valid { - base.OutputIndex = &index.Int32 + if outputIndex != nil { + index := *outputIndex + base.OutputIndex = &index } - emit := func(kind string, event v1.SessionEvent) error { + var events []v1.SessionEvent + emit := func(kind string, event v1.SessionEvent) { event.Type = "agent.session.turn." + kind - return recordSessionChange(ctx, q, session, SessionChange{Event: event}) + events = append(events, event) } textMessage := item.Type == "message" && item.Role == "assistant" && len(item.Content) == 1 && item.Content[0].Text != nil if previous.ID == "" { @@ -35,16 +38,12 @@ func recordItemChange(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, } event := base event.Item = &initial - if err := emit("item.added", event); err != nil { - return err - } + emit("item.added", event) if textMessage { zero, empty := 0, "" event = base event.ItemID, event.ContentIndex, event.Part = item.ID, &zero, &v1.ItemContent{Type: item.Content[0].Type, Text: &empty} - if err := emit("content_part.added", event); err != nil { - return err - } + emit("content_part.added", event) if delta == nil { // A first observation without its own fragment, such as a // non-streamed native final, carries its unchanged text in one @@ -53,14 +52,14 @@ func recordItemChange(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, } } } - if !index.Valid { - return nil + if outputIndex == nil { + return events } if item.Type == "command_execution" && delta != nil { event := base event.Type = "agent.output.command_execution_output.delta" event.ItemID, event.Delta = item.ID, delta - return recordSessionChange(ctx, q, session, SessionChange{Event: event}) + return append(events, event) } if textMessage { zero := 0 @@ -68,26 +67,20 @@ func recordItemChange(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, event.ItemID, event.ContentIndex = item.ID, &zero if delta != nil && *delta != "" { event.Delta = delta - if err := emit("output_text.delta", event); err != nil { - return err - } + emit("output_text.delta", event) event.Delta = nil } if item.Status != "in_progress" { event.Text = item.Content[0].Text - if err := emit("output_text.done", event); err != nil { - return err - } + emit("output_text.done", event) event.Text, event.Part = nil, &item.Content[0] - if err := emit("content_part.done", event); err != nil { - return err - } + emit("content_part.done", event) } } if item.Status != "in_progress" { event := base event.Item = &item - return emit("item.done", event) + emit("item.done", event) } - return nil + return events } diff --git a/services/core/internal/items/events_test.go b/services/core/internal/items/events_test.go new file mode 100644 index 000000000..2053d739e --- /dev/null +++ b/services/core/internal/items/events_test.go @@ -0,0 +1,115 @@ +package items + +import ( + "encoding/json" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func assistantText(status, text string) v1.Item { + return v1.Item{ID: "item", TurnID: testTurn, Type: "message", Role: "assistant", Status: status, Content: []v1.ItemContent{{Type: "output_text", Text: &text}}} +} + +func TestEventsReportItemChangesInOrder(t *testing.T) { + index := int32(2) + fragment := " more" + command := v1.Item{ID: "item", TurnID: testTurn, Type: "command_execution", Status: "in_progress", Command: "ls"} + commandOutput := command + commandOutput.Output = fragment + result := v1.Item{ID: "item", TurnID: testTurn, Type: "function_call_output", CallID: "call", Status: "completed", Output: json.RawMessage(`"ok"`)} + user := v1.Item{ID: "item", TurnID: testTurn, Type: "message", Role: "user", Status: "completed", Content: []v1.ItemContent{{Type: "input_text"}}} + tool := v1.Item{ID: "item", TurnID: testTurn, Type: "web_search_call", Status: "in_progress"} + doneTool := tool + doneTool.Status = "completed" + for _, test := range []struct { + name string + change Change + index *int32 + want []string + deltas []string + }{ + { + name: "a new final text is framed by one delta", + change: Change{Item: assistantText("completed", "answer")}, index: &index, + want: []string{"item.added", "content_part.added", "output_text.delta", "output_text.done", "content_part.done", "item.done"}, + deltas: []string{"answer"}, + }, + { + name: "a new streaming text carries its own fragment", + change: Change{Item: assistantText("in_progress", "draft"), Delta: &fragment}, index: &index, + want: []string{"item.added", "content_part.added", "output_text.delta"}, + deltas: []string{fragment}, + }, + { + name: "a text update publishes only its fragment", + change: Change{Previous: assistantText("in_progress", "draft"), Item: assistantText("in_progress", "draft more"), Delta: &fragment}, index: &index, + want: []string{"output_text.delta"}, + deltas: []string{fragment}, + }, + { + name: "an unfinished text that ends is done without a delta", + change: Change{Previous: assistantText("in_progress", "partial"), Item: assistantText("incomplete", "partial")}, index: &index, + want: []string{"output_text.done", "content_part.done", "item.done"}, + }, + { + name: "an unchanged Item reports nothing", + change: Change{Previous: assistantText("completed", "same"), Item: assistantText("completed", "same")}, index: &index, + }, + { + name: "command output is a command delta", + change: Change{Previous: command, Item: commandOutput, Delta: &fragment}, index: &index, + want: []string{"agent.output.command_execution_output.delta"}, + deltas: []string{fragment}, + }, + { + name: "a function result is an input without an index or item.done", + change: Change{Item: result}, index: &index, + want: []string{"item.added"}, + }, + { + name: "an input without an output index is only added", + change: Change{Item: user}, + want: []string{"item.added"}, + }, + { + name: "a tool call that ends is done", + change: Change{Previous: tool, Item: doneTool}, index: &index, + want: []string{"item.done"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + events := Events(test.change, test.index) + var kinds, deltas []string + for _, event := range events { + kind := event.Type + if kind != "agent.output.command_execution_output.delta" { + kind = kind[len("agent.session.turn."):] + } + kinds = append(kinds, kind) + if event.Delta != nil { + deltas = append(deltas, *event.Delta) + } + if event.TurnID != testTurn { + t.Fatalf("%s lost its Turn: %+v", kind, event) + } + wantIndex := test.index != nil && test.change.Item.Type != "function_call_output" + if (event.OutputIndex != nil) != wantIndex || (wantIndex && *event.OutputIndex != index) { + t.Fatalf("%s output index %v", kind, event.OutputIndex) + } + } + if !reflect.DeepEqual(kinds, test.want) || !reflect.DeepEqual(deltas, test.deltas) { + t.Fatalf("events %q deltas %q, want %q and %q", kinds, deltas, test.want, test.deltas) + } + }) + } +} + +func TestEventsAddAssistantTextEmptyAndInProgress(t *testing.T) { + events := Events(Change{Item: assistantText("completed", "answer")}, nil) + added := events[0].Item + if added.Status != "in_progress" || len(added.Content) != 0 || *events[1].Part.Text != "" { + t.Fatalf("added %+v, part %+v", added, events[1].Part) + } +} diff --git a/services/core/internal/items/messages.go b/services/core/internal/items/messages.go index e33b6269d..50c439f45 100644 --- a/services/core/internal/items/messages.go +++ b/services/core/internal/items/messages.go @@ -1,4 +1,3 @@ -// Package items projects execution observations to the supported public Item variants. package items import ( diff --git a/services/core/internal/persistence/postgres/sessionpg/changes.go b/services/core/internal/persistence/postgres/sessionpg/changes.go new file mode 100644 index 000000000..fbe499ac0 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/changes.go @@ -0,0 +1,36 @@ +package sessionpg + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// AppendChanges journals public changes in order. Each gets a new event ID and +// the Session's next sequence position, and commits with the caller's state. +// Text PostgreSQL cannot store is textvalue.ErrUnstorable. +func AppendChanges(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, changes ...sessions.SessionChange) error { + for _, change := range changes { + change.Event.EventID = uuid.NewString() + change.Event.SessionID = uuid.UUID(session.Bytes).String() + payload, err := json.Marshal(change) + if err != nil { + return err + } + if err := q.AppendSessionEvent(ctx, sqlc.AppendSessionEventParams{ID: session, Payload: payload}); err != nil { + return storable(err) + } + } + return nil +} + +// PruneChanges drops the Session's journaled changes beyond the sessions +// retention bounds. Callers run it after the transaction's last change. +func PruneChanges(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + return q.PruneSessionEvents(ctx, sqlc.PruneSessionEventsParams{SessionID: session, RetainedEvents: sessions.RetainedChanges, RetainedBytes: sessions.RetainedChangeBytes}) +} diff --git a/services/core/internal/persistence/postgres/sessionpg/doc.go b/services/core/internal/persistence/postgres/sessionpg/doc.go new file mode 100644 index 000000000..9f0c83f12 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/doc.go @@ -0,0 +1,32 @@ +// Package sessionpg writes the Session changes that the sessions and items +// packages decide to PostgreSQL. Its functions run inside the caller's Session +// transaction, under the Session lock, on the transaction-bound queries: they +// load the facts a decision reads, then apply the decision, allocating event +// sequence positions, event IDs and Item positions, writing the public change +// journal, Items, Turn usage and Artifacts, and pruning the journal. They decide +// nothing. +package sessionpg + +import ( + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" +) + +// storable returns textvalue.ErrUnstorable in place of PostgreSQL rejecting +// client text it cannot represent, and any other error unchanged. +func storable(err error) error { + if pgunit.IsUnstorableText(err) { + return textvalue.ErrUnstorable + } + return err +} + +func outputIndex(value pgtype.Int4) *int32 { + if !value.Valid { + return nil + } + index := value.Int32 + return &index +} diff --git a/services/core/internal/persistence/postgres/sessionpg/items.go b/services/core/internal/persistence/postgres/sessionpg/items.go new file mode 100644 index 000000000..222ec3b7b --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/items.go @@ -0,0 +1,67 @@ +package sessionpg + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" +) + +// LoadItem reads what the Session holds for update's Item: the stored Item and, +// when update asks for them, whether the Turn has another assistant message and +// the result the application saved for the call. +func LoadItem(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, update items.Update) (items.Stored, error) { + var stored items.Stored + id, err := pgunit.ParseID(update.Item.ID) + if err != nil { + return stored, err + } + if update.NeedsNativeMessage() { + if stored.NativeMessage, err = q.HasNativeMessageItem(ctx, sqlc.HasNativeMessageItemParams{TurnID: turn, ID: id}); err != nil { + return stored, err + } + } + row, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) + if err == nil { + err = json.Unmarshal(row.Payload, &stored.Item) + } else if errors.Is(err, pgx.ErrNoRows) { + err = nil + } + if err != nil { + return stored, err + } + if call, ok := update.ResultCall(); ok { + stored.FunctionResult, err = q.FunctionItemResult(ctx, sqlc.FunctionItemResultParams{SessionID: session, TurnID: turn, CallID: call}) + if errors.Is(err, pgx.ErrNoRows) { + err = nil + } + } + return stored, err +} + +// PutItem stores a decided Item change. A new Item takes the Session's next +// position and, when it is output, the Turn's next output index; an update +// keeps both, and its first terminal status records when it settled. PutItem +// returns the Item's output index, nil when it has none, and +// textvalue.ErrUnstorable for text PostgreSQL cannot store. +func PutItem(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, created pgtype.Timestamptz, change items.Change) (*int32, error) { + id, err := pgunit.ParseID(change.Item.ID) + if err != nil { + return nil, err + } + payload, err := change.Item.MarshalStored() + if err != nil { + return nil, err + } + row, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: change.Output}) + if err != nil { + return nil, storable(err) + } + return outputIndex(row.OutputIndex), nil +} diff --git a/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go new file mode 100644 index 000000000..91dc37d93 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go @@ -0,0 +1,223 @@ +package sessionpg + +import ( + "errors" + "reflect" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" +) + +// newTurn stores a fresh Session with one Turn of status and returns their IDs. +func newTurn(t *testing.T, pool *pgxpool.Pool, status string) (pgtype.UUID, pgtype.UUID) { + t.Helper() + session, turn := uuid.New(), uuid.New() + if _, err := pool.Exec(t.Context(), `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash) VALUES ($1, $2, 'codex', 'key', 'hash')`, session, uuid.New()); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), `INSERT INTO turns(id, session_id, status, completed_at) + VALUES ($1, $2, $3::text, CASE WHEN $3::text IN ('completed', 'failed', 'cancelled') THEN clock_timestamp() END)`, turn, session, status); err != nil { + t.Fatal(err) + } + return pgtype.UUID{Bytes: session, Valid: true}, pgtype.UUID{Bytes: turn, Valid: true} +} + +// inTx runs apply in one committed transaction on transaction-bound queries. +func inTx(t *testing.T, pool *pgxpool.Pool, apply func(*sqlc.Queries) error) { + t.Helper() + if err := pgx.BeginFunc(t.Context(), pool, func(tx pgx.Tx) error { return apply(sqlc.New(tx)) }); err != nil { + t.Fatal(err) + } +} + +func journal(t *testing.T, pool *pgxpool.Pool, session pgtype.UUID) ([]int64, []sessions.SessionChange) { + t.Helper() + rows, err := pool.Query(t.Context(), `SELECT sequence, payload FROM session_events WHERE session_id = $1 ORDER BY sequence`, session) + if err != nil { + t.Fatal(err) + } + var sequences []int64 + var changes []sessions.SessionChange + for rows.Next() { + var sequence int64 + var change sessions.SessionChange + if err := rows.Scan(&sequence, &change); err != nil { + t.Fatal(err) + } + sequences, changes = append(sequences, sequence), append(changes, change) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return sequences, changes +} + +func TestAppendChangesSequencesAndPruneKeepsTheNewest(t *testing.T) { + pool := pgtest.Open(t) + session, _ := newTurn(t, pool, sessions.TurnInProgress) + total := sessions.RetainedChanges + 2 + inTx(t, pool, func(q *sqlc.Queries) error { + for range total { + if err := AppendChanges(t.Context(), q, session, sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.in_progress"}}); err != nil { + return err + } + } + return PruneChanges(t.Context(), q, session) + }) + sequences, changes := journal(t, pool, session) + if len(sequences) != sessions.RetainedChanges || sequences[0] != 3 || sequences[len(sequences)-1] != int64(total) { + t.Fatalf("retained %d changes from %d to %d", len(sequences), sequences[0], sequences[len(sequences)-1]) + } + ids := map[string]bool{} + for _, change := range changes { + if change.Event.SessionID != uuid.UUID(session.Bytes).String() || uuid.Validate(change.Event.EventID) != nil || ids[change.Event.EventID] { + t.Fatalf("change identity %+v", change.Event) + } + ids[change.Event.EventID] = true + } +} + +func TestItemsTakePositionsAndOutputIndexes(t *testing.T) { + pool := pgtest.Open(t) + session, turn := newTurn(t, pool, sessions.TurnInProgress) + turnID := uuid.UUID(turn.Bytes).String() + created := pgtype.Timestamptz{Time: time.Now(), Valid: true} + text := func(value string) *string { return &value } + input := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "message", Role: "user", Status: "completed", Content: []v1.ItemContent{{Type: "input_text", Text: text("hi")}}} + answer := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "message", Role: "assistant", Status: "in_progress", Content: []v1.ItemContent{{Type: "output_text", Text: text("draft")}}} + search := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "web_search_call", Status: "in_progress"} + var indexes []*int32 + inTx(t, pool, func(q *sqlc.Queries) error { + for _, change := range []items.Change{{Item: input}, {Item: answer, Output: true}, {Item: search, Output: true}, {Previous: answer, Item: answer, Output: true}} { + index, err := PutItem(t.Context(), q, session, turn, created, change) + if err != nil { + return err + } + indexes = append(indexes, index) + } + return nil + }) + if indexes[0] != nil || *indexes[1] != 0 || *indexes[2] != 1 || *indexes[3] != 0 { + t.Fatalf("output indexes %v", indexes) + } + rows, err := pool.Query(t.Context(), `SELECT id::text FROM session_items WHERE session_id = $1 ORDER BY position`, session) + if err != nil { + t.Fatal(err) + } + positions, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil || !reflect.DeepEqual(positions, []string{input.ID, answer.ID, search.ID}) { + t.Fatalf("Session positions %q, %v", positions, err) + } + inTx(t, pool, func(q *sqlc.Queries) error { + stored, err := LoadItem(t.Context(), q, session, turn, items.Update{Item: v1.Item{ID: answer.ID}}) + if err != nil || !reflect.DeepEqual(stored, items.Stored{Item: answer}) { + t.Fatalf("stored %+v, %v", stored, err) + } + legacy, err := LoadItem(t.Context(), q, session, turn, items.Update{Item: v1.Item{ID: uuid.NewString()}, LegacyFinal: true}) + if err != nil || !legacy.NativeMessage || legacy.Item.ID != "" { + t.Fatalf("legacy aggregate %+v, %v", legacy, err) + } + return nil + }) +} + +func TestUnstorableTextIsTheSharedError(t *testing.T) { + pool := pgtest.Open(t) + session, turn := newTurn(t, pool, sessions.TurnInProgress) + nul := "a\x00b" + item := v1.Item{ID: uuid.NewString(), TurnID: uuid.UUID(turn.Bytes).String(), Type: "message", Role: "user", Status: "completed", Content: []v1.ItemContent{{Type: "input_text", Text: &nul}}} + for name, write := range map[string]func(*sqlc.Queries) error{ + "item": func(q *sqlc.Queries) error { + _, err := PutItem(t.Context(), q, session, turn, pgtype.Timestamptz{Time: time.Now(), Valid: true}, items.Change{Item: item}) + return err + }, + "change": func(q *sqlc.Queries) error { + return AppendChanges(t.Context(), q, session, sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn.item.added", Item: &item}}) + }, + } { + err := pgx.BeginFunc(t.Context(), pool, func(tx pgx.Tx) error { return write(sqlc.New(tx)) }) + if !errors.Is(err, textvalue.ErrUnstorable) || pgunit.IsUnstorableText(err) { + t.Fatalf("%s: %v", name, err) + } + } +} + +// A Turn that ends reports its finished Items in position order, then the Turn, +// then the settled Session activity, and its finished Items share one +// settlement time. +func TestTurnEndAppliesItemsTurnAndActivityInOrder(t *testing.T) { + pool := pgtest.Open(t) + session, turn := newTurn(t, pool, sessions.TurnCompleted) + turnID := uuid.UUID(turn.Bytes).String() + created := pgtype.Timestamptz{Time: time.Now().Add(-time.Minute), Valid: true} + text := "partial" + answer := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "message", Role: "assistant", Status: "in_progress", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}} + search := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "web_search_call", Status: "in_progress"} + done := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "web_search_call", Status: "completed"} + inTx(t, pool, func(q *sqlc.Queries) error { + for _, item := range []v1.Item{answer, search, done} { + if _, err := PutItem(t.Context(), q, session, turn, created, items.Change{Item: item, Output: true}); err != nil { + return err + } + } + return nil + }) + inTx(t, pool, func(q *sqlc.Queries) error { + ending, err := LoadEnding(t.Context(), q, session, turn) + if err != nil { + return err + } + if len(ending.Unfinished) != 2 { + t.Fatalf("unfinished %+v", ending.Unfinished) + } + ended := sessions.Turn{ID: turnID, SessionID: uuid.UUID(session.Bytes).String(), Status: sessions.TurnCompleted, CompletedAt: time.Now()} + return ApplyTurnEnd(t.Context(), q, session, turn, sessions.EndTurn(ended, ending)) + }) + _, changes := journal(t, pool, session) + var kinds []string + for _, change := range changes { + kinds = append(kinds, change.Event.Type) + } + want := []string{ + "agent.session.turn.output_text.done", "agent.session.turn.content_part.done", "agent.session.turn.item.done", + "agent.session.turn.item.done", "agent.session.turn.completed", "agent.session.idle", + } + if !reflect.DeepEqual(kinds, want) || changes[2].Event.Item.ID != answer.ID || changes[3].Event.Item.ID != search.ID || !changes[5].Settled { + t.Fatalf("journal %q", kinds) + } + rows, err := pool.Query(t.Context(), `SELECT id, payload->>'status', settled_at FROM session_items WHERE turn_id = $1`, turn) + if err != nil { + t.Fatal(err) + } + statuses, settled := map[string]string{}, map[string]time.Time{} + for rows.Next() { + var id uuid.UUID + var status string + var at time.Time + if err := rows.Scan(&id, &status, &at); err != nil { + t.Fatal(err) + } + statuses[id.String()], settled[id.String()] = status, at + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if statuses[answer.ID] != "incomplete" || statuses[search.ID] != "incomplete" || statuses[done.ID] != "completed" { + t.Fatalf("statuses %v", statuses) + } + if !settled[answer.ID].Equal(settled[search.ID]) || !settled[answer.ID].After(created.Time) || !settled[done.ID].Equal(created.Time.Truncate(time.Microsecond)) { + t.Fatalf("settlement times %v", settled) + } +} diff --git a/services/core/internal/persistence/postgres/sessionpg/turn_end.go b/services/core/internal/persistence/postgres/sessionpg/turn_end.go new file mode 100644 index 000000000..d354b990f --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/turn_end.go @@ -0,0 +1,79 @@ +package sessionpg + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgtype" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// LoadEnding reads the facts sessions.EndTurn settles a Turn that ended with: +// its Items still in progress and the Session's public usage. +func LoadEnding(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID) (sessions.Ending, error) { + rows, err := q.ListUnfinishedSessionItems(ctx, sqlc.ListUnfinishedSessionItemsParams{SessionID: session, TurnID: turn}) + if err != nil { + return sessions.Ending{}, err + } + ending := sessions.Ending{Unfinished: make([]sessions.UnfinishedItem, len(rows))} + for i, row := range rows { + var item v1.Item + if err := json.Unmarshal(row.Payload, &item); err != nil { + return sessions.Ending{}, err + } + ending.Unfinished[i] = sessions.UnfinishedItem{Position: row.Position, OutputIndex: outputIndex(row.OutputIndex), Item: item} + } + ending.Usage, err = LoadUsage(ctx, q, session) + return ending, err +} + +// ApplyTurnEnd writes what a Turn that ended settles, in the order +// sessions.TurnEnd lists: terminal activity, Artifacts, finished Items and then +// the public changes. +func ApplyTurnEnd(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, end sessions.TurnEnd) error { + if end.TerminalActivity { + if err := RecordTerminalActivity(ctx, q, session); err != nil { + return err + } + } + switch end.Artifacts { + case sessions.PublishArtifacts: + // The Session lock orders Artifact deletion and allows one active Turn, + // so the comparison sees exactly the Artifacts that remain when the Turn + // ends. + if err := q.DeleteUnchangedTurnArtifacts(ctx, sqlc.DeleteUnchangedTurnArtifactsParams{SessionID: session, TurnID: turn}); err != nil { + return err + } + if err := q.PublishTurnArtifacts(ctx, sqlc.PublishTurnArtifactsParams{SessionID: session, TurnID: turn, CreatedAt: pgtype.Timestamptz{Time: end.PublishedAt, Valid: !end.PublishedAt.IsZero()}}); err != nil { + return err + } + case sessions.DiscardArtifacts: + if err := q.DeleteUnpublishedTurnArtifacts(ctx, sqlc.DeleteUnpublishedTurnArtifactsParams{SessionID: session, TurnID: turn}); err != nil { + return err + } + } + if len(end.FinishedItems) > 0 { + ids := make([]pgtype.UUID, len(end.FinishedItems)) + for i, value := range end.FinishedItems { + id, err := pgunit.ParseID(value) + if err != nil { + return err + } + ids[i] = id + } + if err := q.FinishSessionItems(ctx, sqlc.FinishSessionItemsParams{Status: end.FinishedStatus, SessionID: session, Ids: ids}); err != nil { + return err + } + } + return AppendChanges(ctx, q, session, end.Changes...) +} + +// RecordTerminalActivity marks the Session's running managed compute active at +// the database's commit clock. +func RecordTerminalActivity(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + return q.RecordRuntimeTerminalActivity(ctx, session) +} diff --git a/services/core/internal/persistence/postgres/sessionpg/usage.go b/services/core/internal/persistence/postgres/sessionpg/usage.go new file mode 100644 index 000000000..aac89f32f --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/usage.go @@ -0,0 +1,26 @@ +package sessionpg + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgtype" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +// LoadUsage reads the Session's public usage: the sum of its root Turns once +// every one has ended with recorded usage, and null otherwise. +func LoadUsage(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (json.RawMessage, error) { + return q.SessionTokenUsage(ctx, session) +} + +// PutTurnUsage replaces a root Turn's recorded usage with a measurement. +func PutTurnUsage(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, usage v1.TokenUsage) error { + payload, err := json.Marshal(usage) + if err != nil { + return err + } + return q.PutTurnUsage(ctx, sqlc.PutTurnUsageParams{SessionID: session, ID: turn, TokenUsage: payload}) +} diff --git a/services/core/internal/sessions/doc.go b/services/core/internal/sessions/doc.go new file mode 100644 index 000000000..4af577523 --- /dev/null +++ b/services/core/internal/sessions/doc.go @@ -0,0 +1,5 @@ +// Package sessions owns the Session change vocabulary, including Turns and +// their statuses, and decides what Session writes publish: the public changes +// that report Turn and Session transitions, what a Turn that ends settles, +// measured Turn usage and the Session activity each change reports. +package sessions diff --git a/services/core/internal/sessions/environment.go b/services/core/internal/sessions/environment.go new file mode 100644 index 000000000..b7a8333ff --- /dev/null +++ b/services/core/internal/sessions/environment.go @@ -0,0 +1,27 @@ +package sessions + +import "time" + +// EnvironmentInputActivity is the reservation-owned override before a newer Turn exists. +type EnvironmentInputActivity struct { + Status string `json:"status"` + EnvironmentID string `json:"environment_id,omitempty"` + Failure string `json:"failure,omitempty"` + LastActiveAt time.Time `json:"last_active_at"` +} + +// EnvironmentFailure is an Environment's recorded provisioning failure. It +// makes the Session failed with this reason and last activity time. +type EnvironmentFailure struct { + Reason string `json:"reason"` + FailedAt time.Time `json:"failed_at"` + Detail *ProvisioningFailureDetail `json:"-"` +} + +// ProvisioningFailureDetail is private, fixed-category evidence from a confirmed +// initialization receipt. It never contains command text, paths or Runtime output. +type ProvisioningFailureDetail struct { + Step *string `json:"step"` + Index *int `json:"index"` + ExitCode *int `json:"exit_code"` +} diff --git a/services/core/internal/sessions/session_change.go b/services/core/internal/sessions/session_change.go new file mode 100644 index 000000000..7d96e2e18 --- /dev/null +++ b/services/core/internal/sessions/session_change.go @@ -0,0 +1,47 @@ +package sessions + +import ( + "encoding/json" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" +) + +// A Session keeps at most RetainedChanges public changes and +// RetainedChangeBytes of their payloads for live readers after each +// transaction, and always keeps its newest change even when it is larger. +const ( + RetainedChanges = 256 + RetainedChangeBytes = 64 << 20 +) + +// SessionChange keeps transition snapshots separate from public response rendering. +type SessionChange struct { + Sequence int64 `json:"-"` + Event v1.SessionEvent `json:"event"` + Turn *Turn `json:"turn,omitempty"` + SessionUsage json.RawMessage `json:"session_usage,omitempty"` + RequiredActions []v1.FunctionCallAction `json:"required_actions,omitempty"` + EnvironmentInputActivity *EnvironmentInputActivity `json:"environment_input_activity,omitempty"` + // EnvironmentFailure is set on the agent.session.failed snapshot of a hosted + // provisioning failure, which also ends live event streams. + EnvironmentFailure *EnvironmentFailure `json:"environment_failure,omitempty"` + // Settled marks an idle or failed snapshot recorded when a Turn ends, or when + // the latest input reservation stops being pending (expired, cancelled or + // failed). A reservation made while the ending Turn captured Artifacts can + // still be pending and start a later Turn. It is internal, never a wire + // field; snapshots recorded without it read as unsettled. + Settled bool `json:"settled,omitempty"` +} + +// ItemChanges returns the public changes that report an Item change, in +// publication order. outputIndex is the Item's output index, nil when it has +// none. +func ItemChanges(change items.Change, outputIndex *int32) []SessionChange { + events := items.Events(change, outputIndex) + changes := make([]SessionChange, len(events)) + for i, event := range events { + changes[i] = SessionChange{Event: event} + } + return changes +} diff --git a/services/core/internal/sessions/turn.go b/services/core/internal/sessions/turn.go new file mode 100644 index 000000000..3851093a5 --- /dev/null +++ b/services/core/internal/sessions/turn.go @@ -0,0 +1,38 @@ +package sessions + +import ( + "encoding/json" + "time" +) + +const ( + TurnQueued = "queued" + TurnInProgress = "in_progress" + TurnWaiting = "waiting" + TurnCompleted = "completed" + TurnFailed = "failed" + TurnCancelled = "cancelled" +) + +// Turn is a root Turn from the Core work queue and uses its Session's immutable +// execution configuration; Subagent Turns have a native writer and their own +// table. Zero timestamps mean the corresponding event has not occurred. Outcome +// is adapter-owned data, not an upstream response; the API must project +// supported wire types explicitly. +type Turn struct { + ID, SessionID, Status string + CreatedAt time.Time + StartedAt time.Time + CompletedAt time.Time + CancelRequestedAt time.Time + Usage json.RawMessage + Outcome json.RawMessage + // ArtifactCaptureStarted is private Runtime coordination, never a wire field. + ArtifactCaptureStarted bool `json:"-"` +} + +// TerminalStatus reports whether a Turn status has ended the Turn. A terminal +// Turn is never reopened and its outcome is never overwritten. +func TerminalStatus(status string) bool { + return status == TurnCompleted || status == TurnFailed || status == TurnCancelled +} diff --git a/services/core/internal/sessions/turn_changes.go b/services/core/internal/sessions/turn_changes.go new file mode 100644 index 000000000..789233f9f --- /dev/null +++ b/services/core/internal/sessions/turn_changes.go @@ -0,0 +1,125 @@ +package sessions + +import ( + "cmp" + "encoding/json" + "slices" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" +) + +// TurnChanges returns the public change that reports a Turn's new status: +// agent.session.turn.created for a newly admitted Turn and +// agent.session.turn. otherwise. A waiting Turn reports nothing; its +// Session activity carries the required actions. EndTurn reports a Turn that +// ended together with what it settles. +func TurnChanges(turn Turn, created bool) []SessionChange { + if turn.Status == TurnWaiting { + return nil + } + turn.Outcome = nil + kind := turn.Status + if created { + kind = "created" + } + return []SessionChange{{Event: v1.SessionEvent{Type: "agent.session.turn." + kind, TurnID: turn.ID}, Turn: &turn}} +} + +// ActivityChange returns the Session activity snapshot after a Turn change: +// requires_action while the Turn waits on actions, idle once it ended, failed +// when it failed, and in_progress otherwise. usage is the Session's public +// usage. The snapshot of a Turn that ended is settled: it marks that Turn's +// end, while an input reserved during its Artifact capture is newer work. +func ActivityChange(turn Turn, usage json.RawMessage, actions []v1.FunctionCallAction) SessionChange { + turn.Outcome = nil + status := "in_progress" + if TerminalStatus(turn.Status) { + status = "idle" + if turn.Status == TurnFailed { + status = "failed" + } + } else if len(actions) > 0 { + status = "requires_action" + } + return SessionChange{ + Event: v1.SessionEvent{Type: "agent.session." + status}, Turn: &turn, + SessionUsage: usage, RequiredActions: actions, Settled: TerminalStatus(turn.Status), + } +} + +// ArtifactSettlement is what a Turn that ended does with the Artifacts it +// staged. +type ArtifactSettlement string + +const ( + // PublishArtifacts publishes the staged Artifacts at the Turn's completion + // time. A path whose newest remaining Artifact has the same bytes keeps that + // Artifact instead; new paths, changed bytes and paths whose newest Artifact + // was deleted publish. + PublishArtifacts ArtifactSettlement = "publish" + // DiscardArtifacts deletes the staged Artifacts. + DiscardArtifacts ArtifactSettlement = "discard" +) + +// UnfinishedItem is an Item still in progress when its Turn ends. +type UnfinishedItem struct { + // Position is the Item's Session position. + Position int32 + // OutputIndex is the Item's output index, nil when it has none. + OutputIndex *int32 + Item v1.Item +} + +// Ending holds the facts a Turn that ended is settled with, read under the +// Session lock in the transaction that ends it. +type Ending struct { + // Unfinished are the Turn's Items still in progress, in any order. + Unfinished []UnfinishedItem + // Usage is the Session's public usage with the Turn ended. + Usage json.RawMessage +} + +// TurnEnd is what a Turn that ended writes in the transaction that ends it, +// applied in field order. +type TurnEnd struct { + // TerminalActivity marks managed compute activity when Core commits the + // end, so managed idle time starts then and never on a Runtime clock. + TerminalActivity bool + Artifacts ArtifactSettlement + // PublishedAt is the publication time of PublishArtifacts. + PublishedAt time.Time + // FinishedItems are the IDs of the Turn's unfinished Items. Each takes + // FinishedStatus, and all of them share one settlement time. + FinishedItems []string + FinishedStatus string + // Changes are the public changes: each finished Item's events in Session + // position order, the Turn's event, then the Session's settled activity. + Changes []SessionChange +} + +// EndTurn decides what a Turn that ended settles. A completed Turn publishes +// its Artifacts and a failed or cancelled one discards them. Its unfinished +// Items become incomplete, keeping their partial content, so none is reported +// as a success the Turn never produced. +func EndTurn(turn Turn, ending Ending) TurnEnd { + end := TurnEnd{TerminalActivity: true, FinishedStatus: "incomplete"} + switch turn.Status { + case TurnCompleted: + end.Artifacts, end.PublishedAt = PublishArtifacts, turn.CompletedAt + case TurnFailed, TurnCancelled: + end.Artifacts = DiscardArtifacts + } + unfinished := slices.Clone(ending.Unfinished) + slices.SortStableFunc(unfinished, func(a, b UnfinishedItem) int { return cmp.Compare(a.Position, b.Position) }) + for _, open := range unfinished { + finished := open.Item + finished.Status = end.FinishedStatus + end.FinishedItems = append(end.FinishedItems, open.Item.ID) + end.Changes = append(end.Changes, ItemChanges(items.Change{Previous: open.Item, Item: finished}, open.OutputIndex)...) + } + end.Changes = append(end.Changes, TurnChanges(turn, false)...) + end.Changes = append(end.Changes, ActivityChange(turn, ending.Usage, nil)) + return end +} diff --git a/services/core/internal/sessions/turn_changes_test.go b/services/core/internal/sessions/turn_changes_test.go new file mode 100644 index 000000000..8b25b172c --- /dev/null +++ b/services/core/internal/sessions/turn_changes_test.go @@ -0,0 +1,133 @@ +package sessions + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +const ( + testSession = "8c6f1fd1-7f7d-4bd8-9a55-5b0f1c1f4d31" + testTurn = "bff31a40-9a63-4a49-aebe-89dfe9dd5268" +) + +func turnWith(status string) Turn { + return Turn{ID: testTurn, SessionID: testSession, Status: status, CompletedAt: time.Unix(1700000000, 0), Outcome: json.RawMessage(`{"private":true}`)} +} + +func types(changes []SessionChange) []string { + var kinds []string + for _, change := range changes { + kinds = append(kinds, change.Event.Type) + } + return kinds +} + +func TestTerminalStatus(t *testing.T) { + for status, want := range map[string]bool{ + TurnQueued: false, TurnInProgress: false, TurnWaiting: false, + TurnCompleted: true, TurnFailed: true, TurnCancelled: true, "": false, + } { + if TerminalStatus(status) != want { + t.Errorf("%q: want %v", status, want) + } + } +} + +func TestTurnChangesReportTheNewStatus(t *testing.T) { + for _, test := range []struct { + status string + created bool + want []string + }{ + {TurnQueued, true, []string{"agent.session.turn.created"}}, + {TurnInProgress, false, []string{"agent.session.turn.in_progress"}}, + {TurnWaiting, false, nil}, + {TurnCancelled, false, []string{"agent.session.turn.cancelled"}}, + } { + turn := turnWith(test.status) + changes := TurnChanges(turn, test.created) + if !reflect.DeepEqual(types(changes), test.want) { + t.Fatalf("%s: %q", test.status, types(changes)) + } + for _, change := range changes { + if change.Turn.Outcome != nil || change.Event.TurnID != testTurn || change.Turn.Status != test.status { + t.Fatalf("%s: snapshot %+v", test.status, change) + } + } + if turn.Outcome == nil { + t.Fatal("the caller's Turn lost its outcome") + } + } +} + +func TestActivityChangeDerivesSessionActivity(t *testing.T) { + usage := json.RawMessage(`{"total_tokens":3}`) + actions := []v1.FunctionCallAction{{Type: "function_call", CallID: "call"}} + for _, test := range []struct { + status string + actions []v1.FunctionCallAction + want string + settled bool + }{ + {TurnQueued, nil, "agent.session.in_progress", false}, + {TurnInProgress, nil, "agent.session.in_progress", false}, + {TurnWaiting, actions, "agent.session.requires_action", false}, + {TurnWaiting, nil, "agent.session.in_progress", false}, + {TurnCompleted, nil, "agent.session.idle", true}, + {TurnCancelled, nil, "agent.session.idle", true}, + {TurnFailed, nil, "agent.session.failed", true}, + } { + change := ActivityChange(turnWith(test.status), usage, test.actions) + if change.Event.Type != test.want || change.Settled != test.settled || change.Turn.Outcome != nil || + string(change.SessionUsage) != string(usage) || !reflect.DeepEqual(change.RequiredActions, test.actions) { + t.Fatalf("%s: %+v", test.status, change) + } + } +} + +func TestEndTurnSettlesArtifactsItemsAndActivity(t *testing.T) { + text := func(value string) *string { return &value } + zero, index := int32(0), int32(1) + second := UnfinishedItem{Position: 9, Item: v1.Item{ID: "second", TurnID: testTurn, Type: "web_search_call", Status: "in_progress"}, OutputIndex: &index} + first := UnfinishedItem{Position: 4, OutputIndex: &zero, Item: v1.Item{ID: "first", TurnID: testTurn, Type: "message", Role: "assistant", Status: "in_progress", Content: []v1.ItemContent{{Type: "output_text", Text: text("partial")}}}} + usage := json.RawMessage(`{"total_tokens":3}`) + for _, test := range []struct { + status string + artifacts ArtifactSettlement + activity string + }{ + {TurnCompleted, PublishArtifacts, "agent.session.idle"}, + {TurnFailed, DiscardArtifacts, "agent.session.failed"}, + {TurnCancelled, DiscardArtifacts, "agent.session.idle"}, + } { + turn := turnWith(test.status) + end := EndTurn(turn, Ending{Unfinished: []UnfinishedItem{second, first}, Usage: usage}) + if !end.TerminalActivity || end.Artifacts != test.artifacts || (test.artifacts == PublishArtifacts) != end.PublishedAt.Equal(turn.CompletedAt) { + t.Fatalf("%s: settlement %+v", test.status, end) + } + if !reflect.DeepEqual(end.FinishedItems, []string{"first", "second"}) || end.FinishedStatus != "incomplete" { + t.Fatalf("%s: items %q %q", test.status, end.FinishedItems, end.FinishedStatus) + } + want := []string{ + "agent.session.turn.output_text.done", "agent.session.turn.content_part.done", "agent.session.turn.item.done", + "agent.session.turn.item.done", + "agent.session.turn." + test.status, + test.activity, + } + if !reflect.DeepEqual(types(end.Changes), want) { + t.Fatalf("%s: changes %q", test.status, types(end.Changes)) + } + if end.Changes[2].Event.Item.ID != "first" || end.Changes[2].Event.Item.Status != "incomplete" || *end.Changes[2].Event.Item.Content[0].Text != "partial" || + end.Changes[3].Event.Item.ID != "second" || *end.Changes[3].Event.OutputIndex != 1 { + t.Fatalf("%s: finished Items %+v %+v", test.status, end.Changes[2].Event, end.Changes[3].Event) + } + activity := end.Changes[len(end.Changes)-1] + if !activity.Settled || string(activity.SessionUsage) != string(usage) { + t.Fatalf("%s: activity %+v", test.status, activity) + } + } +} diff --git a/services/core/internal/sessions/usage.go b/services/core/internal/sessions/usage.go new file mode 100644 index 000000000..7e0229de6 --- /dev/null +++ b/services/core/internal/sessions/usage.go @@ -0,0 +1,66 @@ +package sessions + +import ( + "encoding/json" + "math" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +// MeasuredUsage interprets the token measurement an execution observation of +// kind carries: a usage report, a done payload, a terminal execution outcome or +// an applied cancellation receipt. It returns nil unless the breakdown is +// complete and consistent: every counter present and non-negative, cached +// tokens within input, reasoning tokens within output and the total the sum of +// input and output. An explicit zero is a measurement. +func MeasuredUsage(kind string, raw json.RawMessage) *v1.TokenUsage { + var object map[string]json.RawMessage + if json.Unmarshal(raw, &object) != nil { + return nil + } + if kind == "cancel_receipt" { + var applied bool + if json.Unmarshal(object["applied"], &applied) != nil || !applied { + return nil + } + raw = object["outcome"] + object = nil + if json.Unmarshal(raw, &object) != nil { + return nil + } + kind = "done" + } + if strings.HasPrefix(kind, "execution_") { + raw = object["done"] + object = nil + if json.Unmarshal(raw, &object) != nil { + return nil + } + kind = "done" + } + if kind == "done" { + raw = object["usage"] + object = nil + if json.Unmarshal(raw, &object) != nil { + return nil + } + } else if kind != "usage" { + return nil + } + + var tokens map[string]*int64 + if json.Unmarshal(object["tokens"], &tokens) != nil { + return nil + } + for _, key := range []string{"input_tokens", "output_tokens", "cached_input_tokens", "reasoning_output_tokens", "total_tokens"} { + if tokens[key] == nil || *tokens[key] < 0 { + return nil + } + } + input, output, cached, reasoning, total := *tokens["input_tokens"], *tokens["output_tokens"], *tokens["cached_input_tokens"], *tokens["reasoning_output_tokens"], *tokens["total_tokens"] + if cached > input || reasoning > output || input > math.MaxInt64-output || total != input+output { + return nil + } + return &v1.TokenUsage{InputTokens: input, OutputTokens: output, TotalTokens: total, InputTokensDetails: v1.InputTokenDetails{CachedTokens: cached}, OutputTokensDetails: v1.OutputTokenDetails{ReasoningTokens: reasoning}} +} diff --git a/services/core/internal/store/token_usage_test.go b/services/core/internal/sessions/usage_test.go similarity index 87% rename from services/core/internal/store/token_usage_test.go rename to services/core/internal/sessions/usage_test.go index e66ff741e..0e6fe71d4 100644 --- a/services/core/internal/store/token_usage_test.go +++ b/services/core/internal/sessions/usage_test.go @@ -1,4 +1,4 @@ -package store +package sessions import "testing" @@ -14,7 +14,7 @@ func TestMeasuredUsageRequiresCompleteConsistentCounts(t *testing.T) { } else if kind != "usage" && kind != "done" { raw = `{"done":` + raw + `}` } - got := measuredUsage(kind, []byte(raw)) + got := MeasuredUsage(kind, []byte(raw)) if got == nil || got.InputTokens != 10 || got.InputTokensDetails.CachedTokens != 4 || got.OutputTokensDetails.ReasoningTokens != 2 { t.Fatalf("%s: %+v", kind, got) } @@ -27,14 +27,14 @@ func TestMeasuredUsageRequiresCompleteConsistentCounts(t *testing.T) { `{"tokens":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":3}}`, `{"tokens":{"input_tokens":9223372036854775807,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":0}}`, } { - if got := measuredUsage("usage", []byte(raw)); got != nil { + if got := MeasuredUsage("usage", []byte(raw)); got != nil { t.Fatalf("invalid measurement accepted: %s", raw) } } - if got := measuredUsage("cancel_receipt", []byte(`{"applied":false,"outcome":{"usage":`+good+`}}`)); got != nil { + if got := MeasuredUsage("cancel_receipt", []byte(`{"applied":false,"outcome":{"usage":`+good+`}}`)); got != nil { t.Fatal("unapplied receipt projected") } - zero := measuredUsage("usage", []byte(`{"tokens":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":0}}`)) + zero := MeasuredUsage("usage", []byte(`{"tokens":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":0}}`)) if zero == nil { t.Fatal("explicit zero is measured") } diff --git a/services/core/internal/store/admin_delete_audit_test.go b/services/core/internal/store/admin_delete_audit_test.go index 4e98ffcba..3ee6c27e2 100644 --- a/services/core/internal/store/admin_delete_audit_test.go +++ b/services/core/internal/store/admin_delete_audit_test.go @@ -12,6 +12,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -166,7 +167,7 @@ func prepareAdminHistoryDelete(t *testing.T, s *Store, name string) (string, res if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(archive)); err != nil { t.Fatal(err) } - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session, turn, sessions.TurnInProgress, sessions.TurnCompleted) page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) if err != nil || len(page.Artifacts) != 2 { t.Fatal("artifact fixture", err) diff --git a/services/core/internal/store/admin_session_archive_test.go b/services/core/internal/store/admin_session_archive_test.go index 1b9bdfbb1..1b5d05d6b 100644 --- a/services/core/internal/store/admin_session_archive_test.go +++ b/services/core/internal/store/admin_session_archive_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -130,7 +131,7 @@ func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { t.Fatal(err) } input := submitMessage(t, s, tenant, session.ID, "completed") - transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if err := w.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained"}`)}}); err != nil { t.Fatal(err) } @@ -138,7 +139,7 @@ func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { if err := s.StageTurnArtifacts(t.Context(), tenant, session.ID, input.TurnID, session.Environment.ID, bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/result.txt": body}))); err != nil { t.Fatal(err) } - transition(t, w, tenant, session.ID, input.TurnID, TurnInProgress, TurnCompleted) + transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnInProgress, sessions.TurnCompleted) history := adminMutationSnapshot(t, s, "sessions", "turns", "session_items", "session_artifacts", "source_files", "pg_largeobject", "pg_largeobject_metadata") request := uuid.NewString() result, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, request), tenant, session.ID, 1) @@ -163,7 +164,7 @@ func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { t.Fatal(err) } current, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || current.EnvironmentFailure != nil || current.LastTurn == nil || current.LastTurn.Status != TurnCompleted || current.Environment.Status != "expired" { + if err != nil || current.EnvironmentFailure != nil || current.LastTurn == nil || current.LastTurn.Status != sessions.TurnCompleted || current.Environment.Status != "expired" { t.Fatal("cleanup rewrote completed outcome", current, err) } if _, err := w.SettleRuntimeCreation(t.Context(), owner); err != nil { @@ -204,7 +205,7 @@ func TestManagedSessionArchiveAuditFailureRollsBack(t *testing.T) { tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) archiveAllocation(t, w, tenant, session, installation) input := submitMessage(t, s, tenant, session.ID, "running") - transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) rejectAdminAuditInsert(t, s) tables := []string{"sessions", "environments", "turns", "session_events", "devices", "runtime_allocations", "runtime_placements", "environment_input_reservations", "admin_audit_log"} before := adminMutationSnapshot(t, s, tables...) @@ -218,7 +219,7 @@ func TestManagedSessionArchiveAuditFailureRollsBack(t *testing.T) { t.Fatal(err) } turn, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) - if err != nil || turn.Status != TurnInProgress || turn.CancelRequestedAt.IsZero() { + if err != nil || turn.Status != sessions.TurnInProgress || turn.CancelRequestedAt.IsZero() { t.Fatal("archive did not request cancellation or fabricated settlement", turn, err) } } diff --git a/services/core/internal/store/admin_session_archive_worker_http_test.go b/services/core/internal/store/admin_session_archive_worker_http_test.go index 9ebbebff1..9500daf68 100644 --- a/services/core/internal/store/admin_session_archive_worker_http_test.go +++ b/services/core/internal/store/admin_session_archive_worker_http_test.go @@ -20,6 +20,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -114,7 +115,7 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { t.Fatal("archive did not retain cleanup ownership", allocation, err) } turn, err := s.GetTurn(t.Context(), project.TenantID, active.ID, input.TurnID) - if err != nil || turn.Status != store.TurnCancelled { + if err != nil || turn.Status != sessions.TurnCancelled { t.Fatal("archive did not cancel queued work", turn, err) } var audits int diff --git a/services/core/internal/store/archive_cancellation_test.go b/services/core/internal/store/archive_cancellation_test.go index 9cfe0d185..a91592276 100644 --- a/services/core/internal/store/archive_cancellation_test.go +++ b/services/core/internal/store/archive_cancellation_test.go @@ -19,6 +19,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -126,7 +127,7 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { input := store.InputReceipt{TurnID: start.RunID} h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "pending", Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"42"}`)}) state := functionState(t, h, 1) - if state.LastTurn.Status != store.TurnWaiting { + if state.LastTurn.Status != sessions.TurnWaiting { t.Fatal(state.LastTurn) } @@ -171,7 +172,7 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { } current, err := s.GetTurn(t.Context(), h.tenant, session.ID, input.TurnID) - if err != nil || current.Status != store.TurnWaiting || current.CancelRequestedAt.IsZero() { + if err != nil || current.Status != sessions.TurnWaiting || current.CancelRequestedAt.IsZero() { t.Fatal("archive must request rather than invent cancellation", current, err) } if _, err := runtimegateway.NewAuthenticator(s).AuthenticateBearer(t.Context(), owner.DeviceID, secret); !errors.Is(err, runtimegateway.ErrAuthUnknownDevice) { @@ -279,15 +280,15 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { } got := awaitPreparedDispatch(t, result) - wantStatus := store.TurnCancelled + wantStatus := sessions.TurnCancelled if closedCase || scenario == "negative_ack" || scenario == "missing_outcome" { - wantStatus = store.TurnFailed + wantStatus = sessions.TurnFailed } if got.err != nil || got.run.Turn.Status != wantStatus { t.Fatal(got.run.Turn.Status, got.err, string(got.run.Turn.Outcome)) } var outcome execution.Result - if err := json.Unmarshal(got.run.Turn.Outcome, &outcome); err != nil || (wantStatus == store.TurnCancelled && outcome.Done.Usage.InputTokens != 17) { + if err := json.Unmarshal(got.run.Turn.Outcome, &outcome); err != nil || (wantStatus == sessions.TurnCancelled && outcome.Done.Usage.InputTokens != 17) { t.Fatal("receipt lost usage", string(got.run.Turn.Outcome), err) } diff --git a/services/core/internal/store/archived_cancellation_migration_test.go b/services/core/internal/store/archived_cancellation_migration_test.go index 0ffa52a2d..d829806de 100644 --- a/services/core/internal/store/archived_cancellation_migration_test.go +++ b/services/core/internal/store/archived_cancellation_migration_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" @@ -16,8 +17,8 @@ func TestArchivedCancellationMigrationDoesNotAdoptOldRevocations(t *testing.T) { tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) owner := archiveAllocation(t, w, tenant, session, installation) input := submitMessage(t, s, tenant, session.ID, "waiting") - transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) - transition(t, w, tenant, session.ID, input.TurnID, TurnInProgress, TurnWaiting) + transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnInProgress, sessions.TurnWaiting) if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1); err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/artifact_capture.go b/services/core/internal/store/artifact_capture.go index 54e83d140..1bd336866 100644 --- a/services/core/internal/store/artifact_capture.go +++ b/services/core/internal/store/artifact_capture.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -66,7 +67,7 @@ func (s *Store) StageTurnArtifacts(ctx context.Context, tenantID, sessionID, tur if err != nil { return err } - if turn.Status != TurnInProgress || turn.CancelRequestedAt.Valid { + if turn.Status != sessions.TurnInProgress || turn.CancelRequestedAt.Valid { return ErrTurnConflict } for _, row := range rows { diff --git a/services/core/internal/store/artifact_lifecycle.go b/services/core/internal/store/artifact_lifecycle.go index 3b8492bbd..e15576957 100644 --- a/services/core/internal/store/artifact_lifecycle.go +++ b/services/core/internal/store/artifact_lifecycle.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -27,7 +28,7 @@ func (s *Store) BeginTurnArtifactCapture(ctx context.Context, tenantID, sessionI if err != nil { return err } - if turn.Status != TurnInProgress || turn.CancelRequestedAt.Valid { + if turn.Status != sessions.TurnInProgress || turn.CancelRequestedAt.Valid { return ErrTurnConflict } pending, err := q.HasUnappliedMessages(ctx, sqlc.HasUnappliedMessagesParams{SessionID: session, TurnID: lookup.ID, Sequence: appliedThrough}) @@ -44,18 +45,3 @@ func (s *Store) BeginTurnArtifactCapture(ctx context.Context, tenantID, sessionI return err }) } - -// settleTurnArtifacts runs in the Turn's terminal transaction under the Session -// lock, which also orders Artifact deletion and allows one active Turn. The -// republication decision therefore sees exactly the Artifacts that remain when -// the Turn completes: new paths, changed bytes and paths whose newest Artifact -// was deleted are published; unchanged paths keep their existing Artifact IDs. -func settleTurnArtifacts(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn) error { - if turn.Status == TurnCompleted { - if err := q.DeleteUnchangedTurnArtifacts(ctx, sqlc.DeleteUnchangedTurnArtifactsParams{SessionID: turn.SessionID, TurnID: turn.ID}); err != nil { - return err - } - return q.PublishTurnArtifacts(ctx, sqlc.PublishTurnArtifactsParams{SessionID: turn.SessionID, TurnID: turn.ID, CreatedAt: turn.CompletedAt}) - } - return q.DeleteUnpublishedTurnArtifacts(ctx, sqlc.DeleteUnpublishedTurnArtifactsParams{SessionID: turn.SessionID, TurnID: turn.ID}) -} diff --git a/services/core/internal/store/claude_execution_test.go b/services/core/internal/store/claude_execution_test.go index 7353e0a45..a28d11fca 100644 --- a/services/core/internal/store/claude_execution_test.go +++ b/services/core/internal/store/claude_execution_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -68,7 +69,7 @@ func TestClaudeWorkerSelectsStoredEngineAndRestrictiveCapabilities(t *testing.T) queued := func() { time.Sleep(650 * time.Millisecond) turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal(turn, err) } if !prebound { @@ -89,7 +90,7 @@ func TestClaudeWorkerSelectsStoredEngineAndRestrictiveCapabilities(t *testing.T) t.Fatal(prompt) } h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "claude-native"}}) - waitTurn(t, h, input.TurnID, store.TurnCompleted) + waitTurn(t, h, input.TurnID, sessions.TurnCompleted) bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) if err != nil || bound.NativeSessionID != "claude-native" { t.Fatal(bound, err) @@ -112,7 +113,7 @@ func TestClaudeDispatcherRejectsUnsupportedConfigurationBeforeClaim(t *testing.T t.Fatal("unsupported configuration claimed") } turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal(turn, err) } }) @@ -125,7 +126,7 @@ func TestClaudeInvalidImageResultRejectsWholeBatchBeforePersistence(t *testing.T worker := startWorker(t, t.Context(), h.db, h.d) defer func() { ctx, cancel := context.WithCancel(context.Background()); cancel(); _ = worker.Run(ctx) }() input := h.message("start", "Run") - if _, err := h.s.TransitionTurn(t.Context(), h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err := h.s.TransitionTurn(t.Context(), h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } call := store.FunctionCall{CallID: "public-call", ExecutorCallID: "native-call", Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"42"}`)} @@ -148,7 +149,7 @@ func TestClaudeInvalidImageResultRejectsWholeBatchBeforePersistence(t *testing.T t.Fatal(saved, err) } turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnWaiting || !turn.CancelRequestedAt.IsZero() { + if err != nil || turn.Status != sessions.TurnWaiting || !turn.CancelRequestedAt.IsZero() { t.Fatal(turn, err) } history, err := h.s.ListTurnInputs(t.Context(), h.tenant, h.session.ID, input.TurnID, 0, 100) diff --git a/services/core/internal/store/claude_mcp_test.go b/services/core/internal/store/claude_mcp_test.go index 287ed009e..364ebc55f 100644 --- a/services/core/internal/store/claude_mcp_test.go +++ b/services/core/internal/store/claude_mcp_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -56,7 +57,7 @@ func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { }() time.Sleep(650 * time.Millisecond) turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal("incapable runtime claimed work", turn, err) } if !prebound { @@ -83,7 +84,7 @@ func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { t.Fatal("dispatch lost scoped authentication or authenticated an anonymous server") } h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) - waitTurn(t, h, input.TurnID, store.TurnCompleted) + waitTurn(t, h, input.TurnID, sessions.TurnCompleted) }) } } @@ -122,7 +123,7 @@ func TestClaudeMCPUnsupportedSnapshotRejectedBeforeClaim(t *testing.T) { t.Fatal("unsupported MCP configuration claimed") } turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal(turn, err) } }) diff --git a/services/core/internal/store/command_output_test.go b/services/core/internal/store/command_output_test.go index af7c6b9d2..7e4c42781 100644 --- a/services/core/internal/store/command_output_test.go +++ b/services/core/internal/store/command_output_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -25,7 +26,7 @@ func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } event := func(kind, raw string) store.ExecutionEvent { @@ -69,7 +70,7 @@ func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, final); err != nil { t.Fatal(err) } - if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { + if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { t.Fatal(err) } // Reopening the Store recovers committed Items without creating events. @@ -143,7 +144,7 @@ func TestExecutionJournalsCommandOutputBeforeCancellation(t *testing.T) { t.Fatal(err) } h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) - h.finished(result, store.TurnCancelled) + h.finished(result, sessions.TurnCancelled) page, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) if err != nil || len(page.Items) != 2 || page.Items[1].Status != "incomplete" || page.Items[1].Output != "partial" { t.Fatalf("journal/cancellation lost partial output: %+v %v", page, err) diff --git a/services/core/internal/store/deployment_provider_observation_lock_test.go b/services/core/internal/store/deployment_provider_observation_lock_test.go index a266138c5..39d3ef4bb 100644 --- a/services/core/internal/store/deployment_provider_observation_lock_test.go +++ b/services/core/internal/store/deployment_provider_observation_lock_test.go @@ -7,11 +7,12 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) func TestDeploymentObservationClockSampleFollowsRowLock(t *testing.T) { f := newProviderObservationFixture(t) - turn := f.terminal(t, TurnCompleted, "", "") + turn := f.terminal(t, sessions.TurnCompleted, "", "") tx, err := f.pool.Begin(t.Context()) if err != nil { t.Fatal(err) @@ -60,7 +61,7 @@ func TestDeploymentObservationClockSampleFollowsRowLock(t *testing.T) { func TestDeploymentObservationWinningLockIsClearedByPUT(t *testing.T) { f := newProviderObservationFixture(t) - turn := f.terminal(t, TurnCompleted, "", "") + turn := f.terminal(t, sessions.TurnCompleted, "", "") tx, err := f.pool.Begin(t.Context()) if err != nil { t.Fatal(err) @@ -109,11 +110,11 @@ func TestDeploymentObservationWinningLockIsClearedByPUT(t *testing.T) { func TestDeploymentObservationWaitingAndNonRootTurnCannotWrite(t *testing.T) { f := newProviderObservationFixture(t) receipt := submitMessage(t, f.s, f.tenant, f.session.ID, "waiting") - f.observe(t, Turn{ID: receipt.TurnID}, 0) - transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, TurnQueued, TurnInProgress) - transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, TurnInProgress, TurnWaiting) - f.observe(t, Turn{ID: receipt.TurnID}, 0) + f.observe(t, sessions.Turn{ID: receipt.TurnID}, 0) + transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, sessions.TurnInProgress, sessions.TurnWaiting) + f.observe(t, sessions.Turn{ID: receipt.TurnID}, 0) // Child Turn identifiers live outside turns. An absent root identifier is // rejected by the same SQL ownership join, without a child-history lookup. - f.observe(t, Turn{ID: "ffffffff-ffff-4fff-bfff-ffffffffffff"}, 0) + f.observe(t, sessions.Turn{ID: "ffffffff-ffff-4fff-bfff-ffffffffffff"}, 0) } diff --git a/services/core/internal/store/deployment_provider_observations_test.go b/services/core/internal/store/deployment_provider_observations_test.go index 4fb9ea38e..4cd42f0be 100644 --- a/services/core/internal/store/deployment_provider_observations_test.go +++ b/services/core/internal/store/deployment_provider_observations_test.go @@ -13,6 +13,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" @@ -52,10 +53,10 @@ func newProviderObservationFixture(t *testing.T) providerObservationFixture { } return providerObservationFixture{s, pool, tenant, input, session} } -func (f providerObservationFixture) terminal(t *testing.T, status, coreCode, nativeCode string) Turn { +func (f providerObservationFixture) terminal(t *testing.T, status, coreCode, nativeCode string) sessions.Turn { t.Helper() receipt := submitMessage(t, f.s, f.tenant, f.session.ID, uuid.NewString()) - transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) outcome, _ := json.Marshal(map[string]string{"error_code": coreCode, "engine_error_code": nativeCode}) turn, err := f.s.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, status, outcome, "", receipt.Sequence) if err != nil { @@ -63,7 +64,7 @@ func (f providerObservationFixture) terminal(t *testing.T, status, coreCode, nat } return turn } -func (f providerObservationFixture) observe(t *testing.T, turn Turn, want int64) { +func (f providerObservationFixture) observe(t *testing.T, turn sessions.Turn, want int64) { t.Helper() n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) if err != nil || n != want { @@ -110,7 +111,7 @@ func TestDeploymentObservationSnapshotReplacementAndRetry(t *testing.T) { if err != nil || frozen == nil || *frozen != *before.Provider { t.Fatal("frozen tuple bundle changed", err) } - staleFixture.observe(t, staleFixture.terminal(t, TurnFailed, "engine_failed", "authentication_error"), 0) + staleFixture.observe(t, staleFixture.terminal(t, sessions.TurnFailed, "engine_failed", "authentication_error"), 0) current, _ := f.s.DeploymentModelProvider(t.Context(), "codex") retry := f.input retry.ModelProvider = current.Provider @@ -133,7 +134,7 @@ func TestDeploymentObservationSnapshotReplacementAndRetry(t *testing.T) { if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { t.Fatal("migration/replacement changed Session bundle", err) } - f.observe(t, f.terminal(t, TurnCompleted, "", ""), 0) + f.observe(t, f.terminal(t, sessions.TurnCompleted, "", ""), 0) } func TestDeploymentObservationEligibilityAndReset(t *testing.T) { allowed := []string{"authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"} @@ -142,12 +143,12 @@ func TestDeploymentObservationEligibilityAndReset(t *testing.T) { if _, err := f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { t.Fatal(err) } - f.observe(t, f.terminal(t, TurnFailed, "engine_failed", code), 1) + f.observe(t, f.terminal(t, sessions.TurnFailed, "engine_failed", code), 1) } - for _, tc := range []struct{ status, core, code string }{{TurnFailed, "engine_failed", "context_length_exceeded"}, {TurnFailed, "engine_failed", "cyber_policy"}, {TurnFailed, "engine_failed", "harness_error"}, {TurnFailed, "engine_failed", "untrusted raw text"}, {TurnFailed, "input_not_applied", "authentication_error"}, {TurnFailed, "device_disconnected", "authentication_error"}, {TurnCancelled, "engine_failed", "authentication_error"}} { + for _, tc := range []struct{ status, core, code string }{{sessions.TurnFailed, "engine_failed", "context_length_exceeded"}, {sessions.TurnFailed, "engine_failed", "cyber_policy"}, {sessions.TurnFailed, "engine_failed", "harness_error"}, {sessions.TurnFailed, "engine_failed", "untrusted raw text"}, {sessions.TurnFailed, "input_not_applied", "authentication_error"}, {sessions.TurnFailed, "device_disconnected", "authentication_error"}, {sessions.TurnCancelled, "engine_failed", "authentication_error"}} { f.observe(t, f.terminal(t, tc.status, tc.core, tc.code), 0) } - success := f.terminal(t, TurnCompleted, "", "") + success := f.terminal(t, sessions.TurnCompleted, "", "") f.observe(t, success, 1) n, err := f.s.ObserveDeploymentModelProvider(t.Context(), uuid.NewString(), f.session.ID, success.ID) if n != 0 || err != nil { @@ -207,16 +208,16 @@ func TestDeploymentObservationSourceAndHistoricalExclusion(t *testing.T) { } func TestDeploymentObservationConcurrentThrottleAndRecovery(t *testing.T) { f := newProviderObservationFixture(t) - successes := []Turn{} - failures := []Turn{} + successes := []sessions.Turn{} + failures := []sessions.Turn{} for range 8 { - successes = append(successes, f.terminal(t, TurnCompleted, "", "")) + successes = append(successes, f.terminal(t, sessions.TurnCompleted, "", "")) } for i := range 8 { code := []string{"authentication_error", "rate_limit_exceeded"}[i%2] - failures = append(failures, f.terminal(t, TurnFailed, "engine_failed", code)) + failures = append(failures, f.terminal(t, sessions.TurnFailed, "engine_failed", code)) } - concurrent := func(turns []Turn, want int64) { + concurrent := func(turns []sessions.Turn, want int64) { t.Helper() var wg sync.WaitGroup counts := make(chan int64, len(turns)) @@ -268,12 +269,12 @@ func (db *observationClockDB) Exec(ctx context.Context, sql string, args ...any) } func TestDeploymentObservationClockBoundariesRollbackAndPlan(t *testing.T) { f := newProviderObservationFixture(t) - success := f.terminal(t, TurnCompleted, "", "") - failure := f.terminal(t, TurnFailed, "engine_failed", "authentication_error") + success := f.terminal(t, sessions.TurnCompleted, "", "") + failure := f.terminal(t, sessions.TurnFailed, "engine_failed", "authentication_error") base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) db := &observationClockDB{Pool: f.pool} q := sqlc.New(db) - observe := func(turn Turn, offset time.Duration, want int64) { + observe := func(turn sessions.Turn, offset time.Duration, want int64) { t.Helper() db.at = base.Add(offset) lookup, _ := turnLookup(f.tenant, f.session.ID, turn.ID) @@ -327,7 +328,7 @@ func TestDeploymentObservationReplacementLockRecheckAndTimeout(t *testing.T) { for _, remove := range []bool{false, true} { t.Run(fmt.Sprint("delete=", remove), func(t *testing.T) { f := newProviderObservationFixture(t) - turn := f.terminal(t, TurnCompleted, "", "") + turn := f.terminal(t, sessions.TurnCompleted, "", "") tx, err := f.pool.Begin(t.Context()) if err != nil { t.Fatal(err) @@ -420,5 +421,5 @@ func TestDeploymentObservationMigrationRoundTrip(t *testing.T) { if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { t.Fatal("migration/replacement changed Session bundle", err) } - f.observe(t, f.terminal(t, TurnCompleted, "", ""), 0) + f.observe(t, f.terminal(t, sessions.TurnCompleted, "", ""), 0) } diff --git a/services/core/internal/store/dispatch_test.go b/services/core/internal/store/dispatch_test.go index b1b071a39..50cc73f74 100644 --- a/services/core/internal/store/dispatch_test.go +++ b/services/core/internal/store/dispatch_test.go @@ -17,6 +17,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -164,7 +165,7 @@ func (h *dispatchHarness) read(kind string) proto.Envelope { } type runResult struct { - turn store.Turn + turn sessions.Turn err error } @@ -174,7 +175,7 @@ func (h *dispatchHarness) run(ctx context.Context, turn string) <-chan runResult return out } -func (h *dispatchHarness) finished(result <-chan runResult, status string) store.Turn { +func (h *dispatchHarness) finished(result <-chan runResult, status string) sessions.Turn { h.t.Helper() select { case got := <-result: @@ -185,7 +186,7 @@ func (h *dispatchHarness) finished(result <-chan runResult, status string) store case <-time.After(10 * time.Second): h.t.Fatal("execution did not finish") } - return store.Turn{} + return sessions.Turn{} } func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { @@ -219,7 +220,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { } h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Accepted: true}) h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "Finished", Usage: proto.Usage{InputTokens: 7, OutputTokens: 3}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-thread-1"}}) - done := h.finished(result, store.TurnCompleted) + done := h.finished(result, sessions.TurnCompleted) var outcome execution.Result _ = json.Unmarshal(done.Outcome, &outcome) if outcome.AppliedThrough != second.Sequence || outcome.Done.Usage.InputTokens != 7 { @@ -241,7 +242,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { t.Fatal("native continuity lost") } h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "Continued"}) - h.finished(result, store.TurnCompleted) + h.finished(result, sessions.TurnCompleted) } func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) { @@ -262,7 +263,7 @@ func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) t.Fatal("cancellation has no receipt identity") } current, err := h.s.GetTurn(context.Background(), h.tenant, h.session.ID, first.TurnID) - if err != nil || current.Status != store.TurnInProgress { + if err != nil || current.Status != sessions.TurnInProgress { t.Fatal("cancel finished before receipt") } if withDone { @@ -270,7 +271,7 @@ func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) } outcome := &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "cancelled-native"}, Usage: proto.Usage{InputTokens: 10}} h.write(first.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: outcome}) - h.finished(result, store.TurnCancelled) + h.finished(result, sessions.TurnCancelled) next := h.message("next", "Continue after cancellation") result = h.run(context.Background(), next.TurnID) env = h.read(testExecutionRequest) @@ -280,7 +281,7 @@ func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) t.Fatal("cancellation lost native continuity") } h.write(next.TurnID, proto.TypeDone, proto.DonePayload{}) - h.finished(result, store.TurnCompleted) + h.finished(result, sessions.TurnCompleted) }) } h := newDispatchHarness(t) @@ -289,7 +290,7 @@ func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) result := h.run(ctx, first.TurnID) h.read(testExecutionRequest) cancel() - done := h.finished(result, store.TurnFailed) + done := h.finished(result, sessions.TurnFailed) var outcome execution.Result _ = json.Unmarshal(done.Outcome, &outcome) if outcome.ErrorCode != "execution_interrupted" { @@ -319,7 +320,7 @@ func TestExecutionFailureDoesNotBecomeSuccessOrReplay(t *testing.T) { h.read(proto.TypePromptSteer) h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) } - done := h.finished(result, store.TurnFailed) + done := h.finished(result, sessions.TurnFailed) if kind == "engine" { var outcome execution.Result _ = json.Unmarshal(done.Outcome, &outcome) @@ -340,12 +341,12 @@ func TestExecutionOutcomeAndNativeBindingCommitTogether(t *testing.T) { h := newDispatchHarness(t) first := h.message("first", "Run") ctx := context.Background() - _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } late := h.message("second", "Late") - if _, err := h.s.CompleteExecution(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnCompleted, []byte(`{}`), "native-one", first.Sequence); !errors.Is(err, store.ErrUnappliedInputs) { + if _, err := h.s.CompleteExecution(ctx, h.tenant, h.session.ID, first.TurnID, sessions.TurnCompleted, []byte(`{}`), "native-one", first.Sequence); !errors.Is(err, store.ErrUnappliedInputs) { t.Fatalf("unapplied completion: %v", err) } bound, _ := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) @@ -358,7 +359,7 @@ func TestExecutionOutcomeAndNativeBindingCommitTogether(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, err := h.s.CompleteExecution(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnCompleted, []byte(`{}`), native, late.Sequence) + _, err := h.s.CompleteExecution(ctx, h.tenant, h.session.ID, first.TurnID, sessions.TurnCompleted, []byte(`{}`), native, late.Sequence) errs <- err }() } @@ -410,7 +411,7 @@ func TestExecutionRejectsRuntimeMissingCapabilityBeforeClaim(t *testing.T) { t.Fatalf("Run error = %v, want %q", err, tc.message) } turn, err := h.s.GetTurn(context.Background(), h.tenant, h.session.ID, first.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal("Runtime without a required capability claimed work") } }) diff --git a/services/core/internal/store/environment_admission_test.go b/services/core/internal/store/environment_admission_test.go index a8fb049d7..1161b7711 100644 --- a/services/core/internal/store/environment_admission_test.go +++ b/services/core/internal/store/environment_admission_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -153,7 +154,7 @@ func TestEnvironmentAdmissionWaitsForPreparedClaimAndRetainsRetry(t *testing.T) h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "admitted-native"}}) completeEmptyArtifactExport(t, h) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) - if run.Turn.Status != store.TurnCompleted { + if run.Turn.Status != sessions.TurnCompleted { t.Fatal("completion", run.Turn) } assertPreparationReleased(t, h, frame.ID, handle) diff --git a/services/core/internal/store/environment_claim_worker_test.go b/services/core/internal/store/environment_claim_worker_test.go index 79b49742f..b55853550 100644 --- a/services/core/internal/store/environment_claim_worker_test.go +++ b/services/core/internal/store/environment_claim_worker_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -32,7 +33,7 @@ func TestWorkerReconcilesEnvironmentPromotionBeforeStart(t *testing.T) { } } turn, err := s.GetTurn(t.Context(), tenant, pending.SessionID, turnID) - if err != nil || turn.Status != store.TurnInProgress || (deleted && turn.CancelRequestedAt.IsZero()) { + if err != nil || turn.Status != sessions.TurnInProgress || (deleted && turn.CancelRequestedAt.IsZero()) { t.Fatal("promotion did not retain the active claim", turn, err) } // Simulate owner loss after commit, without sending any daemon Start. @@ -53,7 +54,7 @@ func TestWorkerReconcilesEnvironmentPromotionBeforeStart(t *testing.T) { var outcome struct { ErrorCode string `json:"error_code"` } - if err != nil || turn.Status != store.TurnFailed || json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.ErrorCode != "execution_interrupted" { + if err != nil || turn.Status != sessions.TurnFailed || json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.ErrorCode != "execution_interrupted" { t.Fatal("restart failed to settle the original claim", turn, err) } var turns, inputs, queued int diff --git a/services/core/internal/store/environment_connections.go b/services/core/internal/store/environment_connections.go index 9c3b3d809..c22933b9a 100644 --- a/services/core/internal/store/environment_connections.go +++ b/services/core/internal/store/environment_connections.go @@ -8,6 +8,8 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -127,7 +129,7 @@ func recordEnvironmentState(ctx context.Context, q *sqlc.Queries, row sqlc.GetSe if status == "failed" { state.Error = &v1.StreamError{Type: "environment_error", Code: "environment_connection_failed", Message: "The environment failed to connect."} } - return recordSessionChange(ctx, q, row.Environment.SessionID, SessionChange{Event: v1.SessionEvent{ + return sessionpg.AppendChanges(ctx, q, row.Environment.SessionID, sessions.SessionChange{Event: v1.SessionEvent{ Type: "agent.session.environment." + status, Environment: state, }}) diff --git a/services/core/internal/store/environment_connections_test.go b/services/core/internal/store/environment_connections_test.go index f4d375585..b5d448477 100644 --- a/services/core/internal/store/environment_connections_test.go +++ b/services/core/internal/store/environment_connections_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -34,13 +35,13 @@ func connectionSnapshot(t *testing.T, pool *pgxpool.Pool, id string) string { return value } -func connectionChanges(t *testing.T, s *Store, tenant, session string) []SessionChange { +func connectionChanges(t *testing.T, s *Store, tenant, session string) []sessions.SessionChange { t.Helper() all, err := s.ListSessionEvents(t.Context(), tenant, session, 0) if err != nil { t.Fatal(err) } - var changes []SessionChange + var changes []sessions.SessionChange for _, change := range all { if change.Event.Environment != nil { changes = append(changes, change) diff --git a/services/core/internal/store/environment_directory_active_test.go b/services/core/internal/store/environment_directory_active_test.go index bc7d128f5..fbfbf2100 100644 --- a/services/core/internal/store/environment_directory_active_test.go +++ b/services/core/internal/store/environment_directory_active_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -37,7 +38,7 @@ func TestEnvironmentDirectoryActiveRunUsesExistingOwner(t *testing.T) { h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "finished"}) completeEmptyArtifactExport(t, h) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) - if run.Turn.Status != store.TurnCompleted { + if run.Turn.Status != sessions.TurnCompleted { t.Fatal("active read changed Turn outcome") } assertPreparationReleased(t, h, prepare.ID, handle) diff --git a/services/core/internal/store/environment_expiry_dispatch_test.go b/services/core/internal/store/environment_expiry_dispatch_test.go index 7f36117d3..5a788ce6c 100644 --- a/services/core/internal/store/environment_expiry_dispatch_test.go +++ b/services/core/internal/store/environment_expiry_dispatch_test.go @@ -6,6 +6,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -21,28 +22,28 @@ func TestWorkerEnvironmentExpiryAtFullExecutionCapacity(t *testing.T) { enableEnvironmentExpiryDispatch(h) worker, stop := startEnvironmentExpiryWorker(t, h.db, h.d) var requests []proto.Envelope - var sessions []store.Session + var active []store.Session for _, key := range []string{"one", "two", "three", "four"} { session := publicSession(t, h, key) if _, err := worker.SubmitInputs(t.Context(), h.tenant, session.ID, key, []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"remain active"}`)}}); err != nil { t.Fatal(err) } requests = append(requests, h.read(testExecutionRequest)) - sessions = append(sessions, session) + active = append(active, session) } tenant, due := newEnvironmentExpiryReservation(t, h.s) makeEnvironmentExpiryDue(t, pool, &due) waitEnvironmentExpiry(t, h.s, tenant, due) for i, request := range requests { - turn, err := h.s.GetTurn(t.Context(), h.tenant, sessions[i].ID, request.ID) - if err != nil || turn.Status != store.TurnInProgress { + turn, err := h.s.GetTurn(t.Context(), h.tenant, active[i].ID, request.ID) + if err != nil || turn.Status != sessions.TurnInProgress { t.Fatal("expiry was not observed at full capacity", turn, err) } } for i, request := range requests { h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "finished"}) - h.session = sessions[i] - waitTurn(t, h, request.ID, store.TurnCompleted) + h.session = active[i] + waitTurn(t, h, request.ID, sessions.TurnCompleted) } stop() assertEnvironmentExpiryHasNoHistory(t, pool, due.SessionID) @@ -76,7 +77,7 @@ func TestWorkerEnvironmentExpirySkipsBusySessionAndAllowsDispatch(t *testing.T) t.Fatal("unrelated dispatch mismatch", request.ID) } h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "finished"}) - waitTurn(t, h, request.ID, store.TurnCompleted) + waitTurn(t, h, request.ID, sessions.TurnCompleted) var state string if err := pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE id=$1", locked.ID).Scan(&state); err != nil || state != store.EnvironmentInputPending { t.Fatal("sweep did not honor Session lock", state, err) diff --git a/services/core/internal/store/environment_initial_input_test.go b/services/core/internal/store/environment_initial_input_test.go index 55c1838b6..025dbc046 100644 --- a/services/core/internal/store/environment_initial_input_test.go +++ b/services/core/internal/store/environment_initial_input_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -159,7 +160,7 @@ func TestEnvironmentInitialInputCreationRetainsCursorIdentityAndPromotion(t *tes t.Fatal("initial batch did not promote", promoted, err) } active := requireEnvironmentInputActivity(t, s, tenant, session.ID, "", "") - if active.LastTurn == nil || active.LastTurn.Status != TurnInProgress || active.PendingInput { + if active.LastTurn == nil || active.LastTurn.Status != sessions.TurnInProgress || active.PendingInput { t.Fatal("promotion did not claim its Turn", active.LastTurn) } replay, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID) diff --git a/services/core/internal/store/environment_input_activity.go b/services/core/internal/store/environment_input_activity.go index 22538e80b..6b0b07763 100644 --- a/services/core/internal/store/environment_input_activity.go +++ b/services/core/internal/store/environment_input_activity.go @@ -3,24 +3,17 @@ package store import ( "context" "errors" - "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) -// EnvironmentInputActivity is the reservation-owned override before a newer Turn exists. -type EnvironmentInputActivity struct { - Status string `json:"status"` - EnvironmentID string `json:"environment_id,omitempty"` - Failure string `json:"failure,omitempty"` - LastActiveAt time.Time `json:"last_active_at"` -} - -func environmentInputActivity(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (*EnvironmentInputActivity, error) { +func environmentInputActivity(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (*sessions.EnvironmentInputActivity, error) { activity, _, err := environmentInputState(ctx, q, session) return activity, err } @@ -29,7 +22,7 @@ func environmentInputActivity(ctx context.Context, q *sqlc.Queries, session pgty // pending and can start a Turn, including a provisioning hosted initial input // that has no public activity. While a Turn is active or newer than it, the // reservation is not reported. -func environmentInputState(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (*EnvironmentInputActivity, bool, error) { +func environmentInputState(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (*sessions.EnvironmentInputActivity, bool, error) { row, err := q.GetEnvironmentInputActivity(ctx, session) if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil @@ -38,7 +31,7 @@ func environmentInputState(ctx context.Context, q *sqlc.Queries, session pgtype. return nil, false, err } pending := row.State == EnvironmentInputPending - activity := &EnvironmentInputActivity{Status: "idle", LastActiveAt: row.CreatedAt.Time} + activity := &sessions.EnvironmentInputActivity{Status: "idle", LastActiveAt: row.CreatedAt.Time} if row.SettledAt.Valid { activity.LastActiveAt = row.SettledAt.Time } @@ -81,11 +74,11 @@ func withEnvironmentInputActivity(ctx context.Context, q *sqlc.Queries, session if before != nil && before.Status == after.Status && before.EnvironmentID == after.EnvironmentID && before.Failure == after.Failure { return nil } - usage, err := q.SessionTokenUsage(ctx, session) + usage, err := sessionpg.LoadUsage(ctx, q, session) if err != nil { return err } - return recordSessionChange(ctx, q, session, SessionChange{ + return sessionpg.AppendChanges(ctx, q, session, sessions.SessionChange{ Event: v1.SessionEvent{Type: "agent.session." + after.Status}, EnvironmentInputActivity: after, SessionUsage: usage, Settled: !pending, }) diff --git a/services/core/internal/store/environment_input_activity_test.go b/services/core/internal/store/environment_input_activity_test.go index 62490b224..8fb106e3e 100644 --- a/services/core/internal/store/environment_input_activity_test.go +++ b/services/core/internal/store/environment_input_activity_test.go @@ -6,6 +6,7 @@ import ( "reflect" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -87,7 +88,7 @@ func TestEnvironmentInputActivityWaitsBeforeTurnAndClearsOnConnection(t *testing t.Fatal(err) } active := requireEnvironmentInputActivity(t, s, tenant, session.ID, "", "") - if active.LastTurn == nil || active.LastTurn.Status != TurnInProgress { + if active.LastTurn == nil || active.LastTurn.Status != sessions.TurnInProgress { t.Fatal("normal Turn did not take ownership") } if _, err := s.GetSession(t.Context(), uuid.NewString(), session.ID); !errors.Is(err, ErrNotFound) { @@ -105,12 +106,12 @@ func TestEnvironmentInputActivitySettlementAndNewerWork(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := writer.TransitionTurn(t.Context(), tenant, session.ID, prior[0].TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnFailed, Outcome: []byte(`{}`)}); err != nil { + if _, err := writer.TransitionTurn(t.Context(), tenant, session.ID, prior[0].TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnFailed, Outcome: []byte(`{}`)}); err != nil { t.Fatal(err) } reservation := reserveEnvironmentInput(t, s, tenant, session.ID, "waiting") value, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || value.LastTurn.Status != TurnFailed || value.EnvironmentInputActivity.Status != "requires_action" || !value.PendingInput { + if err != nil || value.LastTurn.Status != sessions.TurnFailed || value.EnvironmentInputActivity.Status != "requires_action" || !value.PendingInput { t.Fatal("prior failure hid waiting input", err) } waitingCursor, err := s.SessionEventCursor(t.Context(), tenant, session.ID) diff --git a/services/core/internal/store/environment_input_claim_test.go b/services/core/internal/store/environment_input_claim_test.go index bcc75f0ef..7c4cc8a7f 100644 --- a/services/core/internal/store/environment_input_claim_test.go +++ b/services/core/internal/store/environment_input_claim_test.go @@ -1,6 +1,7 @@ package store import ( + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "sync" "testing" ) @@ -52,7 +53,7 @@ func TestEnvironmentInputConcurrentPromotionClaimsOnce(t *testing.T) { t.Fatal("promotion authorized multiple starts", received, fresh) } turn, err := s.GetTurn(t.Context(), tenant, session.ID, turnID) - if err != nil || turn.Status != TurnInProgress || turn.StartedAt.IsZero() { + if err != nil || turn.Status != sessions.TurnInProgress || turn.StartedAt.IsZero() { t.Fatal("promotion did not persist its execution claim", turn, err) } environmentInputHistory(t, pool, session.ID, 1, 2) @@ -61,7 +62,7 @@ func TestEnvironmentInputConcurrentPromotionClaimsOnce(t *testing.T) { t.Fatal("missing promotion events", changes, err) } created, claimed := changes[0], changes[len(changes)-1] - if created.Event.Type != "agent.session.turn.created" || created.Turn == nil || created.Turn.Status != TurnQueued || claimed.Event.Type != "agent.session.turn.in_progress" || claimed.Turn == nil || claimed.Turn.ID != turnID || claimed.Turn.Status != TurnInProgress { + if created.Event.Type != "agent.session.turn.created" || created.Turn == nil || created.Turn.Status != sessions.TurnQueued || claimed.Event.Type != "agent.session.turn.in_progress" || claimed.Turn == nil || claimed.Turn.ID != turnID || claimed.Turn.Status != sessions.TurnInProgress { t.Fatal("claim reordered or replaced admission snapshots", created, claimed) } claimEvents := 0 @@ -73,7 +74,7 @@ func TestEnvironmentInputConcurrentPromotionClaimsOnce(t *testing.T) { if claimEvents != 1 { t.Fatal("retry published another claim", claimEvents) } - transition(t, writer, tenant, session.ID, turnID, TurnInProgress, TurnCompleted) + transition(t, writer, tenant, session.ID, turnID, sessions.TurnInProgress, sessions.TurnCompleted) later := reserveEnvironmentInput(t, s, tenant, session.ID, "later") cursor, err := s.SessionEventCursor(t.Context(), tenant, session.ID) if err != nil { diff --git a/services/core/internal/store/environment_input_expiry.go b/services/core/internal/store/environment_input_expiry.go index 6e0548ae8..7578c3be4 100644 --- a/services/core/internal/store/environment_input_expiry.go +++ b/services/core/internal/store/environment_input_expiry.go @@ -4,6 +4,7 @@ import ( "context" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/jackc/pgx/v5" ) @@ -27,7 +28,7 @@ func (s *Store) ExpireEnvironmentInputs(ctx context.Context) (int64, error) { if err != nil { return err } - if err := q.PruneSessionEvents(ctx, row.SessionID); err != nil { + if err := sessionpg.PruneChanges(ctx, q, row.SessionID); err != nil { return err } expired++ diff --git a/services/core/internal/store/environment_inputs.go b/services/core/internal/store/environment_inputs.go index 1d4e3625d..26ffab496 100644 --- a/services/core/internal/store/environment_inputs.go +++ b/services/core/internal/store/environment_inputs.go @@ -13,6 +13,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) const ( @@ -262,7 +263,7 @@ func settleEnvironmentInput(ctx context.Context, q *sqlc.Queries, tenantID strin return EnvironmentInputReservation{}, err } if _, err := transitionTurn(ctx, q, params, TurnTransition{ - ExpectedStatus: TurnQueued, Status: TurnInProgress, Outcome: json.RawMessage(`{}`), + ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress, Outcome: json.RawMessage(`{}`), }); err != nil { return EnvironmentInputReservation{}, err } diff --git a/services/core/internal/store/environment_inputs_test.go b/services/core/internal/store/environment_inputs_test.go index 30d82df8e..e49368ca2 100644 --- a/services/core/internal/store/environment_inputs_test.go +++ b/services/core/internal/store/environment_inputs_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) @@ -186,8 +187,8 @@ func TestEnvironmentInputReservationKeepsEarlierDirectIdentity(t *testing.T) { if err != nil { t.Fatal(err) } - transition(t, s, tenant, session.ID, receipts[0].TurnID, TurnQueued, TurnInProgress) - transition(t, s, tenant, session.ID, receipts[0].TurnID, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session.ID, receipts[0].TurnID, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, s, tenant, session.ID, receipts[0].TurnID, sessions.TurnInProgress, sessions.TurnCompleted) pending := reserveEnvironmentInput(t, s, tenant, session.ID, "new") got, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "direct", []Input{input}) if err != nil || got.State != EnvironmentInputAdmitted || got.ID != "" || !got.Deadline.IsZero() || len(got.Receipts) != 1 || got.Receipts[0].Sequence != receipts[0].Sequence { diff --git a/services/core/internal/store/environment_steering_order_test.go b/services/core/internal/store/environment_steering_order_test.go index f275b431c..d3bc6e4d6 100644 --- a/services/core/internal/store/environment_steering_order_test.go +++ b/services/core/internal/store/environment_steering_order_test.go @@ -3,6 +3,7 @@ package store import ( "context" "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "testing" "time" ) @@ -18,7 +19,7 @@ func TestEnvironmentActiveInputSerializesWithCompletion(t *testing.T) { writer := executionWriter(t, s) tenant, session := environmentInputSession(t, s) original := submitMessage(t, s, tenant, session.ID, "original") - transition(t, s, tenant, session.ID, original.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, original.TurnID, sessions.TurnQueued, sessions.TurnInProgress) ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() blocker, err := pool.Begin(ctx) @@ -54,7 +55,7 @@ func TestEnvironmentActiveInputSerializesWithCompletion(t *testing.T) { admitted <- admission{value, err} } complete := func() { - _, err := writer.CompleteExecution(ctx, tenant, session.ID, original.TurnID, TurnCompleted, nil, "", original.Sequence) + _, err := writer.CompleteExecution(ctx, tenant, session.ID, original.TurnID, sessions.TurnCompleted, nil, "", original.Sequence) completed <- err } first, second := input, complete @@ -85,7 +86,7 @@ func TestEnvironmentActiveInputSerializesWithCompletion(t *testing.T) { if err != nil || retry.ID != got.value.ID || !retry.Deadline.Equal(got.value.Deadline) || len(retry.Receipts) != 2 || !retry.Receipts[0].Replayed || retry.Receipts[0].TurnID != prepared.Receipts[0].TurnID { t.Fatal("active retry replaced its original reservation", retry, err) } - if _, err := writer.CompleteExecution(ctx, tenant, session.ID, prepared.Receipts[0].TurnID, TurnCompleted, nil, "", prepared.Receipts[1].Sequence); err != nil { + if _, err := writer.CompleteExecution(ctx, tenant, session.ID, prepared.Receipts[0].TurnID, sessions.TurnCompleted, nil, "", prepared.Receipts[1].Sequence); err != nil { t.Fatal(err) } } else { @@ -93,7 +94,7 @@ func TestEnvironmentActiveInputSerializesWithCompletion(t *testing.T) { t.Fatal("admitted input escaped the original Turn or application fence", completionErr, got.value) } environmentInputHistory(t, pool, session.ID, 1, 3) - if _, err := writer.CompleteExecution(ctx, tenant, session.ID, original.TurnID, TurnCompleted, nil, "", got.value.Receipts[1].Sequence); err != nil { + if _, err := writer.CompleteExecution(ctx, tenant, session.ID, original.TurnID, sessions.TurnCompleted, nil, "", got.value.Receipts[1].Sequence); err != nil { t.Fatal("completion after controlled application failed", err) } } diff --git a/services/core/internal/store/environment_worker_helpers_test.go b/services/core/internal/store/environment_worker_helpers_test.go index 97a45d3aa..eaf17064f 100644 --- a/services/core/internal/store/environment_worker_helpers_test.go +++ b/services/core/internal/store/environment_worker_helpers_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -125,7 +126,7 @@ func awaitWorkerEnvironmentRun(t *testing.T, ctx context.Context, s *store.Store if err != nil { t.Fatal(err) } - return run.Turn.Status == store.TurnCompleted || run.Turn.Status == store.TurnFailed || run.Turn.Status == store.TurnCancelled + return run.Turn.Status == sessions.TurnCompleted || run.Turn.Status == sessions.TurnFailed || run.Turn.Status == sessions.TurnCancelled }) return run } diff --git a/services/core/internal/store/environment_worker_scan_test.go b/services/core/internal/store/environment_worker_scan_test.go index f07eddbe6..b517a9921 100644 --- a/services/core/internal/store/environment_worker_scan_test.go +++ b/services/core/internal/store/environment_worker_scan_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -30,7 +31,7 @@ func TestWorkerEnvironmentRetriesNewlyReadyAtNextScan(t *testing.T) { } awaitFixtureCapabilities(t, runtime, workerEnvironmentCapabilities()) h.write(barrier.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) - waitTurn(t, h, barrier.ID, store.TurnCompleted) + waitTurn(t, h, barrier.ID, sessions.TurnCompleted) prepare := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) if elapsed := time.Since(scanned); elapsed < 750*time.Millisecond || elapsed > 3*time.Second { @@ -75,7 +76,7 @@ func TestWorkerEnvironmentPaginationReachesReadyTail(t *testing.T) { t.Fatal("unexpected page barrier") } h.write(barrier.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) - waitTurn(t, h, barrier.ID, store.TurnCompleted) + waitTurn(t, h, barrier.ID, sessions.TurnCompleted) // The first 100 unbound inputs must not pin the cursor, and the ready // tail must wait for its own bounded page rather than an unbounded drain. prepare := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) diff --git a/services/core/internal/store/environment_worker_test.go b/services/core/internal/store/environment_worker_test.go index 86742602e..d28a0dcad 100644 --- a/services/core/internal/store/environment_worker_test.go +++ b/services/core/internal/store/environment_worker_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -116,7 +117,7 @@ func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { for _, request := range normal { h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "ordinary complete"}) h.session = ordinary[request.ID] - waitTurn(t, h, request.ID, store.TurnCompleted) + waitTurn(t, h, request.ID, sessions.TurnCompleted) } firstRuntime.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "local complete"}) completeEmptyArtifactExport(t, firstRuntime, frames) @@ -145,7 +146,7 @@ func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { t.Fatal("preparation failure blocked ordinary work") } h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) - waitTurn(t, h, request.ID, store.TurnCompleted) + waitTurn(t, h, request.ID, sessions.TurnCompleted) second := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) if elapsed := time.Since(started); elapsed < 750*time.Millisecond || elapsed > 3*time.Second || first.ID == second.ID { t.Fatal("pending preparation missed its next scan or reused a released owner") @@ -175,7 +176,7 @@ func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { runtime.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) completeEmptyArtifactExport(t, runtime, frames) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) - if run.Turn.Status != store.TurnCompleted || !run.Reservation.Deadline.Equal(pending.Deadline) { + if run.Turn.Status != sessions.TurnCompleted || !run.Reservation.Deadline.Equal(pending.Deadline) { t.Fatal("restarted worker did not complete original work", run) } nextWorkerFrame(t, frames, proto.TypeExecutionRelease) diff --git a/services/core/internal/store/environment_write_audit_test.go b/services/core/internal/store/environment_write_audit_test.go index 5fc200b51..eaee546bd 100644 --- a/services/core/internal/store/environment_write_audit_test.go +++ b/services/core/internal/store/environment_write_audit_test.go @@ -8,6 +8,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" ) @@ -80,7 +81,7 @@ func TestArtifactDeleteWriteAuditAndRollback(t *testing.T) { if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, env, bytes.NewReader(archive)); err != nil { t.Fatal(err) } - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session, turn, sessions.TurnInProgress, sessions.TurnCompleted) page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) if err != nil || len(page.Artifacts) != 1 { t.Fatal(page, err) diff --git a/services/core/internal/store/execution_events_test.go b/services/core/internal/store/execution_events_test.go index a671df488..43b39d69f 100644 --- a/services/core/internal/store/execution_events_test.go +++ b/services/core/internal/store/execution_events_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -48,7 +49,7 @@ func TestExecutionPersistsLiveAndCancelledPartialOutput(t *testing.T) { h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "片段", Sequence: uint64(i + 2)}) } h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) - h.finished(result, store.TurnCancelled) + h.finished(result, sessions.TurnCancelled) events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) if err != nil { t.Fatal(err) @@ -87,7 +88,7 @@ func TestExecutionDoesNotCompleteAfterEventPersistenceFailure(t *testing.T) { } h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "cannot be stored", Sequence: 1}) h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "Do not report success", Usage: proto.Usage{InputTokens: 13}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "failed-native"}}) - turn := h.finished(result, store.TurnFailed) + turn := h.finished(result, sessions.TurnFailed) var outcome execution.Result _ = json.Unmarshal(turn.Outcome, &outcome) if outcome.ErrorCode != "event_persistence_failed" { diff --git a/services/core/internal/store/execution_messages_test.go b/services/core/internal/store/execution_messages_test.go index d12e7072e..ccb8f31eb 100644 --- a/services/core/internal/store/execution_messages_test.go +++ b/services/core/internal/store/execution_messages_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -23,7 +24,7 @@ func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { t.Fatal("unadvertised observation capability requested") } h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) - h.finished(result, store.TurnCompleted) + h.finished(result, sessions.TurnCompleted) h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, Resume: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}}}) deadline := time.Now().Add(3 * time.Second) for { @@ -60,7 +61,7 @@ func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { var cancel proto.PromptCancelPayload _ = env.DecodePayload(&cancel) h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) - h.finished(result, store.TurnCancelled) + h.finished(result, sessions.TurnCancelled) reopened, pool := store.NewTestStore(t) defer pool.Close() events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) diff --git a/services/core/internal/store/execution_test.go b/services/core/internal/store/execution_test.go index 1382472e8..cf533bd4b 100644 --- a/services/core/internal/store/execution_test.go +++ b/services/core/internal/store/execution_test.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) // executionWriter acquires the execution lease on the shared test database and @@ -49,7 +50,7 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { writer := executionWriter(t, s) tenant, active := newTurnSession(t, s) input := submitMessage(t, s, tenant, active.ID, "active") - transition(t, writer, tenant, active.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, writer, tenant, active.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) host, err := s.CreateDevice(t.Context(), tenant, "owner test", runtimedevice.HashCredential(uuid.NewString())) if err != nil { t.Fatal(err) @@ -67,7 +68,7 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { t.Fatal(err) } waitInput := submitMessage(t, s, tenant, waiting.ID, "waiting") - transition(t, writer, tenant, waiting.ID, waitInput.TurnID, TurnQueued, TurnInProgress) + transition(t, writer, tenant, waiting.ID, waitInput.TurnID, sessions.TurnQueued, sessions.TurnInProgress) call := functionCallFixture("saved") if err = writer.RecordFunctionCall(t.Context(), tenant, waiting.ID, waitInput.TurnID, call); err != nil { t.Fatal(err) @@ -96,14 +97,14 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { } } mustReject("binding", writer.BindSessionDevice(t.Context(), tenant, queued.ID, host.ID)) - _, err = writer.TransitionTurn(t.Context(), tenant, queued.ID, pending.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + _, err = writer.TransitionTurn(t.Context(), tenant, queued.ID, pending.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) mustReject("claim", err) mustReject("journal", writer.AppendTurnEvents(t.Context(), tenant, active.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"stale"}`)}})) mustReject("callback", writer.RecordFunctionCall(t.Context(), tenant, active.ID, input.TurnID, functionCallFixture("late"))) mustReject("receipt", writer.ConfirmFunctionResult(t.Context(), tenant, waiting.ID, waitInput.TurnID, call.CallID)) - _, err = writer.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, TurnCompleted, json.RawMessage(`{"done":{"content":"stale"}}`), "stale-native", input.Sequence) + _, err = writer.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{"content":"stale"}}`), "stale-native", input.Sequence) mustReject("completion", err) - _, err = writer.TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed}) + _, err = writer.TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed}) mustReject("reconciliation", err) _, err = writer.ExpireEnvironmentInputs(t.Context()) mustReject("input expiry", err) @@ -128,7 +129,7 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { t.Fatal("stale binding persisted", err) } queuedTurn, err := s.GetTurn(t.Context(), tenant, queued.ID, pending.TurnID) - if err != nil || queuedTurn.Status != TurnQueued { + if err != nil || queuedTurn.Status != sessions.TurnQueued { t.Fatal("queued work changed", queuedTurn, err) } // Public admission remains usable with a dead owner connection. @@ -136,14 +137,14 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { if err = successor.lease.CheckOwnership(t.Context()); err != nil { t.Fatal(err) } - if _, err = successor.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, TurnCompleted, json.RawMessage(`{"done":{"content":"accepted"}}`), "successor-native", input.Sequence); err != nil { + if _, err = successor.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{"content":"accepted"}}`), "successor-native", input.Sequence); err != nil { t.Fatal(err) } bound, err := s.GetSessionExecutionBinding(t.Context(), tenant, active.ID) if err != nil || bound.NativeSessionID != "successor-native" { t.Fatal(bound, err) } - _, err = successor.TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed}) + _, err = successor.TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed}) if !errors.Is(err, ErrTurnConflict) { t.Fatal("terminal CAS changed", err) } @@ -180,7 +181,7 @@ func TestExecutionWriterSerializesWritesOnItsLease(t *testing.T) { results := make(chan error, len(tasks)*2) for _, task := range tasks { group.Go(func() { - _, err := writer.TransitionTurn(t.Context(), task.tenant, task.session, task.turn, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + _, err := writer.TransitionTurn(t.Context(), task.tenant, task.session, task.turn, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err == nil { err = writer.AppendTurnEvents(t.Context(), task.tenant, task.session, task.turn, 1, []ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"accepted"}`)}}) } diff --git a/services/core/internal/store/execution_tools_test.go b/services/core/internal/store/execution_tools_test.go index f3525c455..fe1484b4d 100644 --- a/services/core/internal/store/execution_tools_test.go +++ b/services/core/internal/store/execution_tools_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -46,7 +47,7 @@ func TestExecutionNegotiatesAndPersistsToolObservations(t *testing.T) { var cancel proto.PromptCancelPayload _ = env.DecodePayload(&cancel) h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) - h.finished(result, store.TurnCancelled) + h.finished(result, sessions.TurnCancelled) reopened, pool := store.NewTestStore(t) defer pool.Close() events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) diff --git a/services/core/internal/store/executor_recovery_test.go b/services/core/internal/store/executor_recovery_test.go index 17160f043..f8c511338 100644 --- a/services/core/internal/store/executor_recovery_test.go +++ b/services/core/internal/store/executor_recovery_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -34,7 +34,7 @@ func TestExecutorRecoveryRetriesOnlyConfirmedUnsubmittedInput(t *testing.T) { first, start := readyExecutorAttempt(t, h) h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: proto.TypeExecutionStart, ErrorCode: code}) if code != "executor_unavailable" { - h.finished(result, store.TurnFailed) + h.finished(result, sessions.TurnFailed) return } next, replacement := readyExecutorAttempt(t, h) @@ -43,7 +43,7 @@ func TestExecutorRecoveryRetriesOnlyConfirmedUnsubmittedInput(t *testing.T) { } h.write(next.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: replacement.Handle, ExecutorID: replacement.ExecutorID, Revision: 2, State: "started", RunID: replacement.RunID}) h.write(replacement.RunID, proto.TypeDone, proto.DonePayload{Content: "once"}) - h.finished(result, store.TurnCompleted) + h.finished(result, sessions.TurnCompleted) }) } } @@ -54,5 +54,5 @@ func TestExecutorReadinessRejectsChangedOwner(t *testing.T) { result := h.run(t.Context(), receipt.TurnID) frame, start := readyExecutorAttempt(t, h) h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: start.Handle, ExecutorID: uuid.NewString(), Revision: 2, State: "started", RunID: start.RunID}) - h.finished(result, store.TurnFailed) + h.finished(result, sessions.TurnFailed) } diff --git a/services/core/internal/store/function_calls.go b/services/core/internal/store/function_calls.go index bdfb4ba0e..adbbb5d52 100644 --- a/services/core/internal/store/function_calls.go +++ b/services/core/internal/store/function_calls.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -110,5 +111,5 @@ func functionCallFromRow(row sqlc.FunctionCall) FunctionCall { func validFunctionIdentity(id string) bool { return strings.TrimSpace(id) != "" && len(id) <= 512 } func acceptsFunctionResult(turn sqlc.Turn) bool { - return (turn.Status == TurnInProgress || turn.Status == TurnWaiting) && !turn.CancelRequestedAt.Valid + return (turn.Status == sessions.TurnInProgress || turn.Status == sessions.TurnWaiting) && !turn.CancelRequestedAt.Valid } diff --git a/services/core/internal/store/function_calls_test.go b/services/core/internal/store/function_calls_test.go index ea224b6ab..3ecee374c 100644 --- a/services/core/internal/store/function_calls_test.go +++ b/services/core/internal/store/function_calls_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -22,8 +23,8 @@ func TestFunctionCallsPersistCompleteResultsAndReceipts(t *testing.T) { s, pool := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) - transition(t, s, tenant, session.ID, turn, TurnInProgress, TurnWaiting) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnInProgress, sessions.TurnWaiting) results := []string{ `{"success":true,"output":"answer"}`, `{"success":true,"output":""}`, @@ -101,7 +102,7 @@ func TestFunctionCallsAreScopedAndImmutable(t *testing.T) { if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, call); !errors.Is(err, ErrTurnConflict) { t.Fatal("queued turn accepted callback", err) } - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, call); err != nil { t.Fatal(err) } @@ -147,12 +148,12 @@ func TestFunctionCallsAreScopedAndImmutable(t *testing.T) { } func TestFunctionResultsCannotApplyAfterCancellationOrCompletion(t *testing.T) { - for _, terminal := range []string{TurnCancelled, TurnCompleted, TurnFailed} { + for _, terminal := range []string{sessions.TurnCancelled, sessions.TurnCompleted, sessions.TurnFailed} { t.Run(terminal, func(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) for _, id := range []string{"submitted", "pending"} { if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture(id)); err != nil { t.Fatal(err) @@ -162,7 +163,7 @@ func TestFunctionResultsCannotApplyAfterCancellationOrCompletion(t *testing.T) { if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "submitted", result); err != nil { t.Fatal(err) } - if terminal == TurnCancelled { + if terminal == sessions.TurnCancelled { if _, err := s.RequestCancel(t.Context(), tenant, session.ID, "cancel"); err != nil { t.Fatal(err) } @@ -174,7 +175,7 @@ func TestFunctionResultsCannotApplyAfterCancellationOrCompletion(t *testing.T) { t.Fatal(err) } } - transition(t, s, tenant, session.ID, turn, TurnWaiting, terminal) + transition(t, s, tenant, session.ID, turn, sessions.TurnWaiting, terminal) assertNoPendingFunctions(t, s, tenant, session.ID, turn) if err := s.ConfirmFunctionResult(t.Context(), tenant, session.ID, turn, "submitted"); !errors.Is(err, ErrTurnConflict) { t.Fatal(err) @@ -193,7 +194,7 @@ func TestFunctionResultsCannotApplyAfterCancellationOrCompletion(t *testing.T) { t.Fatal("historical result lost or falsely applied", row, err) } next := submitMessage(t, s, tenant, session.ID, "next").TurnID - transition(t, s, tenant, session.ID, next, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, next, sessions.TurnQueued, sessions.TurnInProgress) if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, next, functionCallFixture("pending")); err != nil { t.Fatal("call identity leaked across Turns", err) } @@ -214,7 +215,7 @@ func TestFunctionResultConcurrentSubmissionsChooseOneValue(t *testing.T) { other, _ := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture("call")); err != nil { t.Fatal(err) } @@ -249,7 +250,7 @@ func TestFunctionResultInvalidStorageInputDoesNotConsumeCall(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture("call")); err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/function_execution_native_test.go b/services/core/internal/store/function_execution_native_test.go index 8880a11e8..f4c773b84 100644 --- a/services/core/internal/store/function_execution_native_test.go +++ b/services/core/internal/store/function_execution_native_test.go @@ -6,6 +6,7 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -27,14 +28,14 @@ func TestNativeFunctionExecutionPersistsCallsResultsAndContinuity(t *testing.T) running := h.run(ctx, input.TurnID) state := functionState(t, h, 1) action := state.RequiredActions[0] - if action.Name != "lookup_ticket" || action.TurnID != input.TurnID || state.LastTurn.Status != store.TurnWaiting { + if action.Name != "lookup_ticket" || action.TurnID != input.TurnID || state.LastTurn.Status != sessions.TurnWaiting { t.Fatal(action, state.LastTurn) } if index == 2 { if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "native-cancel"); err != nil { t.Fatal(err) } - h.finished(running, store.TurnCancelled) + h.finished(running, sessions.TurnCancelled) break } value := map[string]any{"success": index == 0, "output": output} @@ -47,7 +48,7 @@ func TestNativeFunctionExecutionPersistsCallsResultsAndContinuity(t *testing.T) t.Fatal(err) } } - h.finished(running, store.TurnCompleted) + h.finished(running, sessions.TurnCompleted) saved, err := h.s.GetFunctionCall(ctx, h.tenant, h.session.ID, input.TurnID, action.CallID) if err != nil || !saved.Applied { t.Fatal(saved, err) diff --git a/services/core/internal/store/function_execution_test.go b/services/core/internal/store/function_execution_test.go index 19feffd7b..ef3f85063 100644 --- a/services/core/internal/store/function_execution_test.go +++ b/services/core/internal/store/function_execution_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -75,7 +76,7 @@ func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { } } state := functionState(t, h, 2) - if state.LastTurn.Status != store.TurnWaiting { + if state.LastTurn.Status != sessions.TurnWaiting { t.Fatal(state.LastTurn) } for _, id := range []string{"a", "b"} { @@ -104,7 +105,7 @@ func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: reply.DeliveryID, Applied: true}) } } - h.finished(result, store.TurnCompleted) + h.finished(result, sessions.TurnCompleted) functionState(t, h, 0) next := h.message("next", "Resume") result = h.run(t.Context(), next.TurnID) @@ -113,7 +114,7 @@ func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { t.Fatal(prompt) } h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) - h.finished(result, store.TurnCompleted) + h.finished(result, sessions.TurnCompleted) } func TestExecutionFunctionsCancellationAndUnconfirmedResults(t *testing.T) { @@ -134,7 +135,7 @@ func TestExecutionFunctionsCancellationAndUnconfirmedResults(t *testing.T) { if reply.Success || len(reply.Content) != 1 || *reply.Content[0].Text != "tool failed" { t.Fatal(reply) } - status := store.TurnFailed + status := sessions.TurnFailed if cancel { if _, err := h.s.RequestCancel(t.Context(), h.tenant, h.session.ID, "cancel"); err != nil { t.Fatal(err) @@ -148,7 +149,7 @@ func TestExecutionFunctionsCancellationAndUnconfirmedResults(t *testing.T) { case <-time.After(40 * time.Millisecond): } h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled-functions"}}}) - status = store.TurnCancelled + status = sessions.TurnCancelled } else { h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: reply.DeliveryID, ErrorCode: "not_pending"}) } @@ -182,7 +183,7 @@ func TestExecutionFunctionsRejectUndeclaredCallsAndPrematureDone(t *testing.T) { h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: name, Arguments: json.RawMessage(`{}`)}) h.write(input.TurnID, proto.TypeDone, proto.DonePayload{}) - h.finished(result, store.TurnFailed) + h.finished(result, sessions.TurnFailed) }) } } @@ -203,7 +204,7 @@ func TestExecutionFunctionsRequireAdvertisedCapability(t *testing.T) { t.Fatal(result) } turn, err := h.s.GetTurn(t.Context(), h.tenant, session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal(turn, err) } } diff --git a/services/core/internal/store/function_input_execution_test.go b/services/core/internal/store/function_input_execution_test.go index ad347cd89..5975ab697 100644 --- a/services/core/internal/store/function_input_execution_test.go +++ b/services/core/internal/store/function_input_execution_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -51,7 +52,7 @@ func TestExecutionFunctionInputBatchStillSteersMessages(t *testing.T) { } } h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) - h.finished(running, store.TurnCompleted) + h.finished(running, sessions.TurnCompleted) saved, err := h.s.GetFunctionCall(t.Context(), h.tenant, h.session.ID, input.TurnID, state.RequiredActions[0].CallID) if err != nil || !saved.Applied { t.Fatal(saved, err) diff --git a/services/core/internal/store/function_inputs_public_test.go b/services/core/internal/store/function_inputs_public_test.go index 9613a3b3f..665a9e747 100644 --- a/services/core/internal/store/function_inputs_public_test.go +++ b/services/core/internal/store/function_inputs_public_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -37,7 +38,7 @@ func TestFunctionInputsOfficialClientAtomicAdmission(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } for _, id := range []string{"a", "b", "c", "rollback", "late"} { @@ -106,7 +107,7 @@ func TestFunctionInputsOfficialClientAtomicAdmission(t *testing.T) { if err != nil || len(history) != 6 { t.Fatal(history, err) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnWaiting, Status: store.TurnFailed}); err != nil { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnWaiting, Status: sessions.TurnFailed}); err != nil { t.Fatal(err) } next, err := s.SubmitMessage(ctx, tenant, session.ID, "next", json.RawMessage(`{"text":"next"}`)) @@ -124,7 +125,7 @@ func TestFunctionInputsOfficialClientAtomicAdmission(t *testing.T) { t.Fatal(history, err) } current, err := s.GetTurn(ctx, tenant, session.ID, next.TurnID) - if err != nil || current.Status != store.TurnQueued || !current.CancelRequestedAt.IsZero() { + if err != nil || current.Status != sessions.TurnQueued || !current.CancelRequestedAt.IsZero() { t.Fatal(current, err) } } diff --git a/services/core/internal/store/function_inputs_test.go b/services/core/internal/store/function_inputs_test.go index b9eb35a12..23361ba75 100644 --- a/services/core/internal/store/function_inputs_test.go +++ b/services/core/internal/store/function_inputs_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -25,7 +26,7 @@ func functionInputFixture(t *testing.T, s *Store) (string, Session, string) { t.Helper() tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) for _, id := range []string{"a", "b"} { if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture(id)); err != nil { t.Fatal(err) @@ -55,7 +56,7 @@ func TestFunctionInputBatchesPersistAndReplayWithoutRetargeting(t *testing.T) { t.Fatal(call, err) } // A result retry and its messages stay attached to their first Turn after restart. - transition(t, s, tenant, session.ID, turn, TurnWaiting, TurnFailed) + transition(t, s, tenant, session.ID, turn, sessions.TurnWaiting, sessions.TurnFailed) next := submitMessage(t, s, tenant, session.ID, "next").TurnID pool.Close() s, _ = testStore(t) @@ -81,7 +82,7 @@ func TestFunctionInputBatchesPersistAndReplayWithoutRetargeting(t *testing.T) { t.Fatal(future, err) } current, err := s.GetTurn(t.Context(), tenant, session.ID, next) - if err != nil || !current.CancelRequestedAt.IsZero() || current.Status != TurnQueued { + if err != nil || !current.CancelRequestedAt.IsZero() || current.Status != sessions.TurnQueued { t.Fatal(current, err) } changed := []Input{batch[1], batch[0], batch[2], batch[3]} @@ -121,7 +122,7 @@ func TestFunctionInputBatchFailureRollsBackEveryWrite(t *testing.T) { t.Fatal(err) } otherTurn := submitMessage(t, s, otherTenant, other.ID, "start").TurnID - transition(t, s, otherTenant, other.ID, otherTurn, TurnQueued, TurnInProgress) + transition(t, s, otherTenant, other.ID, otherTurn, sessions.TurnQueued, sessions.TurnInProgress) if err := s.RecordFunctionCall(t.Context(), otherTenant, other.ID, otherTurn, functionCallFixture("a")); err != nil { t.Fatal(err) } @@ -142,7 +143,7 @@ func TestFunctionInputBatchFailureRollsBackEveryWrite(t *testing.T) { t.Fatal(call, err) } state, err := s.GetTurn(t.Context(), tenant, session.ID, turn) - if err != nil || !state.CancelRequestedAt.IsZero() || state.Status != TurnWaiting { + if err != nil || !state.CancelRequestedAt.IsZero() || state.Status != sessions.TurnWaiting { t.Fatal(state, err) } history, err := s.ListTurnInputs(t.Context(), tenant, session.ID, turn, 0, 100) @@ -223,12 +224,12 @@ func TestFunctionInputsRejectInvalidTargetsAndStorageObjects(t *testing.T) { t.Fatal(target, err) } } - transition(t, s, tenant, session.ID, turn, TurnWaiting, TurnFailed) + transition(t, s, tenant, session.ID, turn, sessions.TurnWaiting, sessions.TurnFailed) if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "late", []Input{input}); !errors.Is(err, ErrTurnConflict) { t.Fatal(err) } current, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || current.LastTurn.ID != turn || current.LastTurn.Status != TurnFailed { + if err != nil || current.LastTurn.ID != turn || current.LastTurn.Status != sessions.TurnFailed { t.Fatal(current, err) } } diff --git a/services/core/internal/store/function_item_events_test.go b/services/core/internal/store/function_item_events_test.go index a6322cee6..7220af875 100644 --- a/services/core/internal/store/function_item_events_test.go +++ b/services/core/internal/store/function_item_events_test.go @@ -3,6 +3,7 @@ package store import ( "encoding/json" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "reflect" "testing" ) @@ -11,7 +12,7 @@ func TestFunctionResultEventsAreInputs(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) events := []ExecutionEvent{ {Kind: "tool_call", Payload: json.RawMessage(`{"id":"call","stage":"after","observation":{"status":"completed","kind":"function","name":"lookup","arguments":{},"content":[{"type":"input_text","text":"result"}]}}`)}, {Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"answer"}`)}, @@ -65,7 +66,7 @@ func TestFunctionResultItemsRetainSubmittedFields(t *testing.T) { s, pool := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) call := functionCallFixture(items.Identity(turn, "tool:call")) if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, call); err != nil { t.Fatal(err) diff --git a/services/core/internal/store/function_state.go b/services/core/internal/store/function_state.go index 4a5f23ffa..0423e07c9 100644 --- a/services/core/internal/store/function_state.go +++ b/services/core/internal/store/function_state.go @@ -6,6 +6,8 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -29,42 +31,22 @@ func recordFunctionState(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn) e if err != nil { return err } - status := TurnInProgress + status := sessions.TurnInProgress if len(actions) > 0 { - status = TurnWaiting + status = sessions.TurnWaiting } if turn.Status != status { turn, err = q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ID: turn.ID, SessionID: turn.SessionID, ExpectedStatus: turn.Status, NewStatus: status, Outcome: []byte(`{}`)}) if err != nil { return err } - if err := recordTurnChange(ctx, q, turn, false); err != nil { + if err := sessionpg.AppendChanges(ctx, q, turn.SessionID, sessions.TurnChanges(turnFromRow(turn), false)...); err != nil { return err } } - return recordSessionActivity(ctx, q, turn, actions) -} - -func recordSessionActivity(ctx context.Context, q *sqlc.Queries, row sqlc.Turn, actions []v1.FunctionCallAction) error { - turn := turnFromRow(row) - turn.Outcome = nil - status := "in_progress" - if terminalStatus(row.Status) { - status = "idle" - if row.Status == TurnFailed { - status = "failed" - } - } else if len(actions) > 0 { - status = "requires_action" - } - usage, err := q.SessionTokenUsage(ctx, row.SessionID) + usage, err := sessionpg.LoadUsage(ctx, q, turn.SessionID) if err != nil { return err } - // Mark the idle or failure of an ending Turn; a reservation made during its - // Artifact capture is newer work. - return recordSessionChange(ctx, q, row.SessionID, SessionChange{ - Event: v1.SessionEvent{Type: "agent.session." + status}, Turn: &turn, - SessionUsage: usage, RequiredActions: actions, Settled: terminalStatus(row.Status), - }) + return sessionpg.AppendChanges(ctx, q, turn.SessionID, sessions.ActivityChange(turnFromRow(turn), usage, actions)) } diff --git a/services/core/internal/store/function_state_public_test.go b/services/core/internal/store/function_state_public_test.go index 561c5f76b..63b974094 100644 --- a/services/core/internal/store/function_state_public_test.go +++ b/services/core/internal/store/function_state_public_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -34,7 +35,7 @@ func TestFunctionStateOfficialClientReadsAndLiveEvents(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } record := func(id string) { @@ -77,7 +78,7 @@ func TestFunctionStateOfficialClientReadsAndLiveEvents(t *testing.T) { t.Fatal(err) } } - if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCompleted, nil, "", input.Sequence); err != nil { + if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCompleted, nil, "", input.Sequence); err != nil { t.Fatal(err) } if err := command.Wait(); err != nil { diff --git a/services/core/internal/store/function_state_test.go b/services/core/internal/store/function_state_test.go index 18a66c40e..76236ae35 100644 --- a/services/core/internal/store/function_state_test.go +++ b/services/core/internal/store/function_state_test.go @@ -7,6 +7,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -14,7 +15,7 @@ func TestFunctionStateSnapshotsRecoveryAndRetries(t *testing.T) { s, pool := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) before, err := s.SessionEventCursor(t.Context(), tenant, session.ID) if err != nil { t.Fatal(err) @@ -26,17 +27,17 @@ func TestFunctionStateSnapshotsRecoveryAndRetries(t *testing.T) { } } } - assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 2) + assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 2) for _, id := range []string{"first", "second"} { if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(`{"success":true,"output":"private result"}`)); err != nil { t.Fatal(err) } } - assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 2) + assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 2) pool.Close() s, _ = testStore(t) - assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 2) - if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, turn, TurnCompleted, nil, "", 1); !errors.Is(err, ErrTurnConflict) { + assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 2) + if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, turn, sessions.TurnCompleted, nil, "", 1); !errors.Is(err, ErrTurnConflict) { t.Fatal("waiting execution completed", err) } for i, id := range []string{"first", "second"} { @@ -45,9 +46,9 @@ func TestFunctionStateSnapshotsRecoveryAndRetries(t *testing.T) { t.Fatal(err) } } - status := TurnWaiting + status := sessions.TurnWaiting if i == 1 { - status = TurnInProgress + status = sessions.TurnInProgress } assertFunctionState(t, s, tenant, session.ID, status, 1-i) } @@ -69,7 +70,7 @@ func TestFunctionStateSnapshotsRecoveryAndRetries(t *testing.T) { if err != nil || string(raw) != `{"ticket":9007199254740993}` { t.Fatal("argument precision lost", string(raw), err) } - if change.Turn.Status != TurnWaiting { + if change.Turn.Status != sessions.TurnWaiting { t.Fatal(change.Turn) } } @@ -83,30 +84,30 @@ func TestFunctionStateSnapshotsRecoveryAndRetries(t *testing.T) { } func TestFunctionStateCancellationAndTerminalCleanup(t *testing.T) { - for _, status := range []string{TurnCancelled, TurnFailed, TurnCompleted} { + for _, status := range []string{sessions.TurnCancelled, sessions.TurnFailed, sessions.TurnCompleted} { t.Run(status, func(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) input := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, input.TurnID, functionCallFixture("call")); err != nil { t.Fatal(err) } - if status == TurnCancelled { + if status == sessions.TurnCancelled { before, _ := s.SessionEventCursor(t.Context(), tenant, session.ID) for _, key := range []string{"cancel", "cancel", "another-cancel"} { if _, err := s.RequestCancel(t.Context(), tenant, session.ID, key); err != nil { t.Fatal(err) } } - assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 0) + assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 0) changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, before) if err != nil || len(changes) != 1 || changes[0].Event.Type != "agent.session.in_progress" || len(changes[0].RequiredActions) != 0 || changes[0].Turn.CancelRequestedAt.IsZero() { t.Fatal(changes, err) } } - if status == TurnCompleted { - transition(t, s, tenant, session.ID, input.TurnID, TurnWaiting, status) + if status == sessions.TurnCompleted { + transition(t, s, tenant, session.ID, input.TurnID, sessions.TurnWaiting, status) } else if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, status, nil, "", input.Sequence); err != nil { t.Fatal(err) } @@ -118,7 +119,7 @@ func TestFunctionStateCancellationAndTerminalCleanup(t *testing.T) { if next.TurnID == input.TurnID { t.Fatal("terminal turn reused") } - assertFunctionState(t, s, tenant, session.ID, TurnQueued, 0) + assertFunctionState(t, s, tenant, session.ID, sessions.TurnQueued, 0) }) } } @@ -127,7 +128,7 @@ func TestFunctionStateReadsRemainConsistentDuringReceipts(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) var wg sync.WaitGroup done := make(chan struct{}) wg.Go(func() { @@ -154,7 +155,7 @@ func TestFunctionStateReadsRemainConsistentDuringReceipts(t *testing.T) { if err != nil { t.Fatal(err) } - if (current.LastTurn.Status == TurnWaiting) != (len(current.RequiredActions) > 0) { + if (current.LastTurn.Status == sessions.TurnWaiting) != (len(current.RequiredActions) > 0) { t.Fatalf("torn activity snapshot: %+v", current) } select { diff --git a/services/core/internal/store/function_worker_test.go b/services/core/internal/store/function_worker_test.go index ad31a2358..65d9d16d4 100644 --- a/services/core/internal/store/function_worker_test.go +++ b/services/core/internal/store/function_worker_test.go @@ -12,6 +12,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -78,7 +79,7 @@ func TestWorkerWaitsForToolCapabilities(t *testing.T) { }() time.Sleep(650 * time.Millisecond) current, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) - if err != nil || current.Status != store.TurnQueued { + if err != nil || current.Status != sessions.TurnQueued { t.Fatal(current, err) } if !prebound { @@ -109,7 +110,7 @@ func TestWorkerWaitsForToolCapabilities(t *testing.T) { t.Fatal(prompt) } h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) - waitTurn(t, h, input.TurnID, store.TurnCompleted) + waitTurn(t, h, input.TurnID, sessions.TurnCompleted) }) } } diff --git a/services/core/internal/store/harness_onboarding_test.go b/services/core/internal/store/harness_onboarding_test.go index 5ec4ee61c..69440c8dd 100644 --- a/services/core/internal/store/harness_onboarding_test.go +++ b/services/core/internal/store/harness_onboarding_test.go @@ -21,7 +21,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine/enginetest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -98,7 +98,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { t.Fatal(first) } request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"finish"}]}]}]}`, 202) - waitTurn(t, h, first.RunID, store.TurnCompleted) + waitTurn(t, h, first.RunID, sessions.TurnCompleted) turn, err := h.s.GetTurn(ctx, h.tenant, created.ID, first.RunID) if err != nil { t.Fatal(err) @@ -121,7 +121,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { t.Fatal(next) } request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.cancel"}]}`, 202) - waitTurn(t, h, next.RunID, store.TurnCancelled) + waitTurn(t, h, next.RunID, sessions.TurnCancelled) // A missing mandatory receipt capability must prevent claiming queued work. peer, _ := h.registry.LookupDevice(h.device.ID) // Mutate the actual wire declaration, not its lossy persisted boolean projection. @@ -152,7 +152,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { case <-time.After(700 * time.Millisecond): } queued, err := h.s.GetSession(ctx, h.tenant, created.ID) - if err != nil || queued.LastTurn == nil || queued.LastTurn.Status != store.TurnQueued { + if err != nil || queued.LastTurn == nil || queued.LastTurn.Status != sessions.TurnQueued { t.Fatal(queued, err) } } diff --git a/services/core/internal/store/input_batches_test.go b/services/core/internal/store/input_batches_test.go index b1051a52f..16a75011d 100644 --- a/services/core/internal/store/input_batches_test.go +++ b/services/core/internal/store/input_batches_test.go @@ -10,6 +10,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -108,11 +109,11 @@ func TestBatchRetriesCompareTheWholeRequestAndRetainTargets(t *testing.T) { t.Fatalf("cancellation targets: %+v", first) } cancelled, err := s.GetTurn(ctx, tenant, session.ID, first[1].TurnID) - if err != nil || cancelled.Status != TurnCancelled { + if err != nil || cancelled.Status != sessions.TurnCancelled { t.Fatalf("cancelled turn=%+v err=%v", cancelled, err) } - transition(t, s, tenant, session.ID, first[3].TurnID, TurnQueued, TurnInProgress) - transition(t, s, tenant, session.ID, first[3].TurnID, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session.ID, first[3].TurnID, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, s, tenant, session.ID, first[3].TurnID, sessions.TurnInProgress, sessions.TurnCompleted) next := submitMessage(t, s, tenant, session.ID, "next") for _, changed := range [][]Input{batch[:3], append(append([]Input{}, batch...), cancel), {batch[0], batch[3], batch[2], batch[1]}} { if _, err := s.SubmitInputs(ctx, tenant, session.ID, "mixed", changed); !errors.Is(err, ErrIdempotencyConflict) { @@ -130,7 +131,7 @@ func TestBatchRetriesCompareTheWholeRequestAndRetainTargets(t *testing.T) { t.Fatalf("restart changed receipts: %+v, %v", retry, err) } current, err := restarted.GetTurn(ctx, tenant, session.ID, next.TurnID) - if err != nil || current.Status != TurnQueued || !current.CancelRequestedAt.IsZero() { + if err != nil || current.Status != sessions.TurnQueued || !current.CancelRequestedAt.IsZero() { t.Fatalf("retry cancelled later work: %+v, %v", current, err) } otherTenant, _ := newTurnSession(t, restarted) @@ -144,7 +145,7 @@ func TestFailedBatchRollsBackEarlierCancellationAndInputs(t *testing.T) { tenant, session := newTurnSession(t, s) ctx := context.Background() initial := submitMessage(t, s, tenant, session.ID, "initial") - transition(t, s, tenant, session.ID, initial.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, initial.TurnID, sessions.TurnQueued, sessions.TurnInProgress) key := uuid.NewString() constraint := "batch_failure_" + strings.ReplaceAll(key, "-", "") // Inject a storage error on the second insert, after cancellation has run. @@ -158,7 +159,7 @@ func TestFailedBatchRollsBackEarlierCancellationAndInputs(t *testing.T) { t.Fatalf("partial batch succeeded: %+v %v", got, err) } turn, err := s.GetTurn(ctx, tenant, session.ID, initial.TurnID) - if err != nil || turn.Status != TurnInProgress || !turn.CancelRequestedAt.IsZero() { + if err != nil || turn.Status != sessions.TurnInProgress || !turn.CancelRequestedAt.IsZero() { t.Fatalf("cancellation escaped rollback: %+v %v", turn, err) } inputs, err := s.ListTurnInputs(ctx, tenant, session.ID, initial.TurnID, 0, 100) diff --git a/services/core/internal/store/input_conflicts_public_test.go b/services/core/internal/store/input_conflicts_public_test.go index dfb46f234..f9955f059 100644 --- a/services/core/internal/store/input_conflicts_public_test.go +++ b/services/core/internal/store/input_conflicts_public_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -72,7 +73,7 @@ func TestSessionInputConflictsAndResultTargetsPostgres(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := s.TransitionTurn(ctx, tenant, session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err := s.TransitionTurn(ctx, tenant, session, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } if err := s.RecordFunctionCall(ctx, tenant, session, receipt.TurnID, store.FunctionCall{CallID: call, ExecutorCallID: "native-" + call, Name: "lookup", Arguments: json.RawMessage(`{}`)}); err != nil { @@ -85,7 +86,7 @@ func TestSessionInputConflictsAndResultTargetsPostgres(t *testing.T) { if err := s.ConfirmFunctionResult(ctx, tenant, session, receipt.TurnID, call); err != nil { t.Fatal(err) } - if _, err := s.CompleteExecution(ctx, tenant, session, receipt.TurnID, store.TurnCompleted, nil, "", receipt.Sequence); err != nil { + if _, err := s.CompleteExecution(ctx, tenant, session, receipt.TurnID, sessions.TurnCompleted, nil, "", receipt.Sequence); err != nil { t.Fatal(err) } } diff --git a/services/core/internal/store/item_order_migration_test.go b/services/core/internal/store/item_order_migration_test.go index d700c4e43..64d02f5b7 100644 --- a/services/core/internal/store/item_order_migration_test.go +++ b/services/core/internal/store/item_order_migration_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -149,7 +150,7 @@ func TestItemOrderMigrationPreservesIndexedHistory(t *testing.T) { } t.Cleanup(migratedPool.Close) s := store.New(migratedPool) - if _, err = s.TransitionTurn(ctx, tenant, session, turn, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = s.TransitionTurn(ctx, tenant, session, turn, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } if err = s.AppendTurnEvents(ctx, tenant, session, turn, 1, []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-upgrade","delta":"continued"}`)}}); err != nil { diff --git a/services/core/internal/store/item_order_test.go b/services/core/internal/store/item_order_test.go index d6f04bd82..e26a6e2c0 100644 --- a/services/core/internal/store/item_order_test.go +++ b/services/core/internal/store/item_order_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -25,7 +26,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -126,7 +127,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) t.Fatalf("output index = %d, want %d", output.Int32, index) } } - if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { t.Fatal(err) } checkOrder() @@ -134,7 +135,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) if err != nil { t.Fatal(err) } - if _, err = s.TransitionTurn(ctx, tenant, session.ID, next.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = s.TransitionTurn(ctx, tenant, session.ID, next.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } if err = s.AppendTurnEvents(ctx, tenant, session.ID, next.TurnID, 1, []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"new","delta":"new turn"}`)}}); err != nil { diff --git a/services/core/internal/store/item_projection.go b/services/core/internal/store/item_projection.go index db5bdea11..d4fa2f857 100644 --- a/services/core/internal/store/item_projection.go +++ b/services/core/internal/store/item_projection.go @@ -3,14 +3,13 @@ package store import ( "context" "encoding/json" - "errors" "fmt" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -20,48 +19,22 @@ func projectItemSource(ctx context.Context, q *sqlc.Queries, session, turn pgtyp return fmt.Errorf("project execution item: %w", err) } for _, update := range updates { - id, _ := parseID(update.Item.ID) - if update.LegacyFinal { - native, err := q.HasNativeMessageItem(ctx, sqlc.HasNativeMessageItemParams{TurnID: turn, ID: id}) - if err != nil { - return err - } - if native { - continue - } - } - old, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return err - } - var previous v1.Item - if err == nil { - if err = json.Unmarshal(old.Payload, &previous); err != nil { - return err - } - } - item, err := items.Merge(update, previous) + stored, err := sessionpg.LoadItem(ctx, q, session, turn, update) if err != nil { return err } - if err := restoreFunctionItemResult(ctx, q, session, turn, &item); err != nil { - return err - } - payload, err := item.MarshalStored() + change, ok, err := items.Observe(kind, update, stored) if err != nil { return err } - stored, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: kind != "message" && item.Type != "function_call_output"}) + if !ok { + continue + } + index, err := sessionpg.PutItem(ctx, q, session, turn, created, change) if err != nil { return err } - var delta *string - if kind == "delta" { - delta = update.Item.Content[0].Text - } else if kind == "command_output" { - delta = update.CommandOutputDelta - } - if err := recordItemChange(ctx, q, session, stored.OutputIndex, previous, item, delta); err != nil { + if err := sessionpg.AppendChanges(ctx, q, session, sessions.ItemChanges(change, index)...); err != nil { return err } } @@ -91,32 +64,3 @@ func indexEvents(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID } return nil } - -func restoreFunctionItemResult(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, item *v1.Item) error { - if item.Type != "function_call_output" { - return nil - } - result, err := q.FunctionItemResult(ctx, sqlc.FunctionItemResultParams{SessionID: session, TurnID: turn, CallID: item.CallID}) - if errors.Is(err, pgx.ErrNoRows) { - return nil - } - if err != nil { - return err - } - if len(result) == 0 { - return nil - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(result, &fields); err != nil { - return err - } - // Native results may normalize content; public Items retain the saved submission. - item.Output, item.Error = nil, nil - if value, ok := fields["output"]; ok { - item.Output = value - } - if value, ok := fields["error"]; ok { - item.Error = value - } - return nil -} diff --git a/services/core/internal/store/item_reads_test.go b/services/core/internal/store/item_reads_test.go index 8d546f42d..a33b99777 100644 --- a/services/core/internal/store/item_reads_test.go +++ b/services/core/internal/store/item_reads_test.go @@ -9,6 +9,7 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -25,7 +26,7 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -55,7 +56,7 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) if page.Items[4].Status != "in_progress" || page.Items[5].Status != "in_progress" { t.Fatal(page.Items) } - _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence) + _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence) if err != nil { t.Fatal(err) } @@ -124,7 +125,7 @@ func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -140,7 +141,7 @@ func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) if err != nil || len(page.Items) != 1 { t.Fatal(page, err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnCompleted, Outcome: json.RawMessage(`{"done":{"content":"legacy answer","metadata":{"private":"SECRET"}}}`)}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnCompleted, Outcome: json.RawMessage(`{"done":{"content":"legacy answer","metadata":{"private":"SECRET"}}}`)}) if err != nil { t.Fatal(err) } @@ -160,7 +161,7 @@ func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -170,7 +171,7 @@ func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { t.Fatal(err) } } - _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{"done":{"content":"retained cancellation text"}}`), "", input.Sequence) + _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{"done":{"content":"retained cancellation text"}}`), "", input.Sequence) if err != nil { t.Fatal(err) } @@ -193,7 +194,7 @@ func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -207,7 +208,7 @@ func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } - _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnFailed, json.RawMessage(`{"done":{"content":"provider failure"},"error_code":"engine_failed"}`), "", input.Sequence) + _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnFailed, json.RawMessage(`{"done":{"content":"provider failure"},"error_code":"engine_failed"}`), "", input.Sequence) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/item_serialization_test.go b/services/core/internal/store/item_serialization_test.go index 43004d8ce..f08e1de24 100644 --- a/services/core/internal/store/item_serialization_test.go +++ b/services/core/internal/store/item_serialization_test.go @@ -2,6 +2,7 @@ package store import ( "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "reflect" "strings" "testing" @@ -69,7 +70,7 @@ func TestAssistantMessageEventsFollowOfficialSequence(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID - transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, sessions.TurnQueued, sessions.TurnInProgress) cursor, err := s.SessionEventCursor(t.Context(), tenant, session.ID) if err != nil { t.Fatal(err) diff --git a/services/core/internal/store/list_cursor_public_test.go b/services/core/internal/store/list_cursor_public_test.go index 7b949357a..977e457c3 100644 --- a/services/core/internal/store/list_cursor_public_test.go +++ b/services/core/internal/store/list_cursor_public_test.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -63,7 +64,7 @@ func seedCursorFixture(t *testing.T, s *store.Store, writer *store.Store, client f.session = client.created(token, "/v1/agents/sessions", newSession) f.turn = first("/v1/agents/sessions/" + f.session + "/turns") f.item = first("/v1/agents/sessions/" + f.session + "/items") - if _, err := s.TransitionTurn(ctx, tenant, f.session, f.turn, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnCancelled}); err != nil { + if _, err := s.TransitionTurn(ctx, tenant, f.session, f.turn, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnCancelled}); err != nil { t.Fatal(err) } if _, err := s.SubmitMessage(ctx, tenant, f.session, label+"-second", json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"second"}]}]}`)); err != nil { @@ -99,7 +100,7 @@ func seedCursorFixture(t *testing.T, s *store.Store, writer *store.Store, client if err = writer.BindSessionDevice(ctx, tenant, created.ID, host.ID); err != nil { t.Fatal(err) } - if _, err = writer.TransitionTurn(ctx, tenant, created.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = writer.TransitionTurn(ctx, tenant, created.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } opened := int64(1700000001000) @@ -108,7 +109,7 @@ func seedCursorFixture(t *testing.T, s *store.Store, writer *store.Store, client } // Distinct creation times keep child-turn before later-child-turn. turn := func(child, id string, created int64) store.ExecutionEvent { - return subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: child, TurnID: id, Status: store.TurnInProgress, CreatedAtMS: created, StartedAtMS: &created}) + return subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: child, TurnID: id, Status: sessions.TurnInProgress, CreatedAtMS: created, StartedAtMS: &created}) } message := func(child, turn, id string, position int32) store.ExecutionEvent { text := "answer " + id diff --git a/services/core/internal/store/local_environment_worker_test.go b/services/core/internal/store/local_environment_worker_test.go index 53e73c0ca..be1b01036 100644 --- a/services/core/internal/store/local_environment_worker_test.go +++ b/services/core/internal/store/local_environment_worker_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -141,7 +142,7 @@ func TestLocalEnvironmentWorkerSchedulesPreparationWithoutRemoteResolver(t *test completeLocalArtifactExport(t, h, worker, environment) awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "local completion", func() bool { turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, start.RunID) - return err == nil && turn.Status == store.TurnCompleted + return err == nil && turn.Status == sessions.TurnCompleted }) settled, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, h.session.ID, reservation.ID) if err != nil || settled.State != store.EnvironmentInputAdmitted || len(settled.Receipts) != 1 { diff --git a/services/core/internal/store/mcp_credential_selection_public_test.go b/services/core/internal/store/mcp_credential_selection_public_test.go index 3ec19653a..92952c459 100644 --- a/services/core/internal/store/mcp_credential_selection_public_test.go +++ b/services/core/internal/store/mcp_credential_selection_public_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -281,7 +282,7 @@ func TestMCPCredentialSelectionPublicPostgres(t *testing.T) { if err := pool.QueryRow(t.Context(), "SELECT id FROM turns WHERE session_id=$1", streamed).Scan(&turn); err != nil { t.Fatal(err) } - if _, err := s.TransitionTurn(t.Context(), tenantA, streamed, turn, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnCancelled}); err != nil { + if _, err := s.TransitionTurn(t.Context(), tenantA, streamed, turn, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnCancelled}); err != nil { t.Fatal(err) } _, tools = snapshot(stream, "agent.session.idle") diff --git a/services/core/internal/store/native_environment_test.go b/services/core/internal/store/native_environment_test.go index 2796c084b..cb28243d5 100644 --- a/services/core/internal/store/native_environment_test.go +++ b/services/core/internal/store/native_environment_test.go @@ -12,6 +12,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -106,13 +107,13 @@ func TestNativeNoExecutionEnvironment(t *testing.T) { t.Fatal(err) } first := h.message("first", "Return an answer.") - h.finished(h.run(ctx, first.TurnID), store.TurnCompleted) + h.finished(h.run(ctx, first.TurnID), sessions.TurnCompleted) bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) if err != nil || bound.NativeSessionID == "" { t.Fatal(bound, err) } second := h.message("second", "Continue the same conversation.") - h.finished(h.run(ctx, second.TurnID), store.TurnCompleted) + h.finished(h.run(ctx, second.TurnID), sessions.TurnCompleted) again, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) if err != nil || again.NativeSessionID != bound.NativeSessionID { t.Fatal(again, err) diff --git a/services/core/internal/store/native_recovery_test.go b/services/core/internal/store/native_recovery_test.go index 611c0cd05..baa2033c6 100644 --- a/services/core/internal/store/native_recovery_test.go +++ b/services/core/internal/store/native_recovery_test.go @@ -2,6 +2,7 @@ package store import ( "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "testing" ) @@ -26,9 +27,9 @@ func TestSessionExecutionBindingRetainsStartedExecutionRequirement(t *testing.T) assertStarted(s, false) first := submitMessage(t, s, tenant, session.ID, "first") assertStarted(s, false) - transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) assertStarted(s, true) - transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnFailed) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnInProgress, sessions.TurnFailed) pool.Close() restarted, _ := testStore(t) assertStarted(restarted, true) diff --git a/services/core/internal/store/no_environment_test.go b/services/core/internal/store/no_environment_test.go index cf26b73f3..fb8384fef 100644 --- a/services/core/internal/store/no_environment_test.go +++ b/services/core/internal/store/no_environment_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -23,7 +24,7 @@ func TestNoEnvironmentRejectsUnadvertisedDeviceBeforeClaim(t *testing.T) { t.Fatal("unsupported environment admitted") } turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal(turn, err) } } diff --git a/services/core/internal/store/prepared_dispatch_failure_test.go b/services/core/internal/store/prepared_dispatch_failure_test.go index 7394d99c3..c97670008 100644 --- a/services/core/internal/store/prepared_dispatch_failure_test.go +++ b/services/core/internal/store/prepared_dispatch_failure_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -102,9 +103,9 @@ func TestPreparedDispatchHandlesStartRejectionAndPendingStartCancellation(t *tes h.write(start.RunID, proto.TypeInteractionDecisionAck, ack) } got := awaitPreparedDispatch(t, result) - want := store.TurnFailed + want := sessions.TurnFailed if action == "cancel" { - want = store.TurnCancelled + want = sessions.TurnCancelled } if got.err != nil || got.run.Turn.Status != want || got.run.Turn.ID != start.RunID { t.Fatal("Start control did not settle through ordinary completion", got) @@ -168,9 +169,9 @@ func TestPreparedDispatchCancellationReceiptSurvivesStartFailure(t *testing.T) { if json.Unmarshal(got.run.Turn.Outcome, &outcome) != nil { t.Fatal("invalid stored cancellation outcome") } - want, code := store.TurnFailed, "cancel_outcome_unavailable" + want, code := sessions.TurnFailed, "cancel_outcome_unavailable" if withOutcome { - want, code = store.TurnCancelled, "" + want, code = sessions.TurnCancelled, "" } if got.err != nil || got.run.Turn.Status != want || outcome.ErrorCode != code { t.Fatal("preparation failure replaced the cancellation receipt", got) diff --git a/services/core/internal/store/prepared_dispatch_test.go b/services/core/internal/store/prepared_dispatch_test.go index 8e4c7a4a0..2a095364e 100644 --- a/services/core/internal/store/prepared_dispatch_test.go +++ b/services/core/internal/store/prepared_dispatch_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -67,7 +68,7 @@ func readyPreparedDispatch(t *testing.T, h *dispatchHarness, request, handle str t.Fatal("Start changed preparation or original batch", frame.ID, start) } turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, start.RunID) - if err != nil || turn.Status != store.TurnInProgress { + if err != nil || turn.Status != sessions.TurnInProgress { t.Fatal("Start preceded atomic claim", turn, err) } return start @@ -102,7 +103,7 @@ func TestPreparedDispatchPromotesOriginalBatchAndPersistsCompletion(t *testing.T h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "answer", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "retained-prepared-native"}}) completeEmptyArtifactExport(t, h) got := awaitPreparedDispatch(t, result) - if got.err != nil || got.run.Turn.Status != store.TurnCompleted || len(got.run.Reservation.Receipts) != 2 || got.run.Reservation.Receipts[0].Replayed || got.run.Reservation.Receipts[1].Sequence >= late.Sequence { + if got.err != nil || got.run.Turn.Status != sessions.TurnCompleted || len(got.run.Reservation.Receipts) != 2 || got.run.Reservation.Receipts[0].Replayed || got.run.Reservation.Receipts[1].Sequence >= late.Sequence { t.Fatal("prepared completion", got) } assertPreparationReleased(t, h, frame.ID, handle) @@ -136,7 +137,7 @@ func TestPreparedDispatchOwnerOutlivesReservationDeadline(t *testing.T) { h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "completed after the reservation deadline"}) completeEmptyArtifactExport(t, h) got := awaitPreparedDispatch(t, result) - if got.err != nil || got.run.Turn.Status != store.TurnCompleted { + if got.err != nil || got.run.Turn.Status != sessions.TurnCompleted { t.Fatal("completion did not settle the execution owner", got) } assertPreparationReleased(t, h, frame.ID, handle) diff --git a/services/core/internal/store/provisioning_failure_detail.go b/services/core/internal/store/provisioning_failure_detail.go index baada5920..fc1dc3a23 100644 --- a/services/core/internal/store/provisioning_failure_detail.go +++ b/services/core/internal/store/provisioning_failure_detail.go @@ -1,22 +1,16 @@ package store -// ProvisioningFailureDetail is private, fixed-category evidence from a confirmed -// initialization receipt. It never contains command text, paths or Runtime output. -type ProvisioningFailureDetail struct { - Step *string `json:"step"` - Index *int `json:"index"` - ExitCode *int `json:"exit_code"` -} +import "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" -func (f ProvisioningFailure) detail() *ProvisioningFailureDetail { - return (ProvisioningFailureDetail{Step: &f.Step, Index: &f.Index, ExitCode: &f.ExitCode}).sanitized() +func (f ProvisioningFailure) detail() *sessions.ProvisioningFailureDetail { + return sanitizedProvisioningDetail(sessions.ProvisioningFailureDetail{Step: &f.Step, Index: &f.Index, ExitCode: &f.ExitCode}) } -func (f ProvisioningFailureDetail) sanitized() *ProvisioningFailureDetail { +func sanitizedProvisioningDetail(f sessions.ProvisioningFailureDetail) *sessions.ProvisioningFailureDetail { if f.Step == nil { return nil } - result := &ProvisioningFailureDetail{} + result := &sessions.ProvisioningFailureDetail{} switch *f.Step { case ProvisioningSetupCommand: // JSON clients can represent these integer positions exactly. diff --git a/services/core/internal/store/public_execution_test.go b/services/core/internal/store/public_execution_test.go index 8433691b0..1803ff726 100644 --- a/services/core/internal/store/public_execution_test.go +++ b/services/core/internal/store/public_execution_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -68,11 +69,11 @@ func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { t.Fatal(bound, err) } active, err := h.s.GetSession(ctx, h.tenant, h.session.ID) - if err != nil || active.LastTurn == nil || active.LastTurn.Status != store.TurnInProgress { + if err != nil || active.LastTurn == nil || active.LastTurn.Status != sessions.TurnInProgress { t.Fatal(active, err) } h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "Answer", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "worker-native"}}) - waitTurn(t, h, receipts[0].TurnID, store.TurnCompleted) + waitTurn(t, h, receipts[0].TurnID, sessions.TurnCompleted) items, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) if err != nil || len(items.Items) != 3 { t.Fatal(items, err) @@ -87,7 +88,7 @@ func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { t.Fatal(prompt) } cancel() - waitTurn(t, h, next[0].TurnID, store.TurnFailed) + waitTurn(t, h, next[0].TurnID, sessions.TurnFailed) } func waitTurn(t *testing.T, h *dispatchHarness, id, status string) { @@ -111,7 +112,7 @@ func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) { h.session = publicSession(t, h, "interrupted") first := h.message("first", "Already sent") ctx := context.Background() - if _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } // A native measurement committed before process loss must survive startup @@ -154,18 +155,18 @@ func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) { t.Fatal(err) } interrupted, err := h.s.GetSession(ctx, h.tenant, h.session.ID) - if err != nil || interrupted.LastTurn.Status != store.TurnFailed { + if err != nil || interrupted.LastTurn.Status != sessions.TurnFailed { t.Fatal(interrupted, err) } pending, err := h.s.GetSession(ctx, h.tenant, queued.ID) - if err != nil || pending.LastTurn.Status != store.TurnQueued { + if err != nil || pending.LastTurn.Status != sessions.TurnQueued { t.Fatal(pending, err) } if _, err := h.s.RequestCancel(ctx, h.tenant, queued.ID, "stop-before-dispatch"); err != nil { t.Fatal(err) } pending, err = h.s.GetSession(ctx, h.tenant, queued.ID) - if err != nil || pending.LastTurn.Status != store.TurnCancelled { + if err != nil || pending.LastTurn.Status != sessions.TurnCancelled { t.Fatal(pending, err) } restarted, err := startWorkerErr(ctx, h.db, h.d) diff --git a/services/core/internal/store/runtime_allocation_state.go b/services/core/internal/store/runtime_allocation_state.go index d4b637f36..bdaa3f278 100644 --- a/services/core/internal/store/runtime_allocation_state.go +++ b/services/core/internal/store/runtime_allocation_state.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) // ObserveRuntimeRunning requires verified original compute identity. It does not @@ -50,7 +51,7 @@ func (s *Store) ReleaseAbsentRuntimeCreation(ctx context.Context, owner RuntimeA return s.requestRuntimeCleanup(ctx, owner, provisioningFailureReason, nil, true) } -func (s *Store) requestRuntimeCleanup(ctx context.Context, owner RuntimeAllocation, reason string, detail *ProvisioningFailureDetail, absent bool) (RuntimeAllocation, error) { +func (s *Store) requestRuntimeCleanup(ctx context.Context, owner RuntimeAllocation, reason string, detail *sessions.ProvisioningFailureDetail, absent bool) (RuntimeAllocation, error) { return s.mutateRuntimeAllocation(ctx, owner, false, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { if row.State == "released" { return row, nil diff --git a/services/core/internal/store/runtime_environment_terminal.go b/services/core/internal/store/runtime_environment_terminal.go index 5e27d8218..286146f9e 100644 --- a/services/core/internal/store/runtime_environment_terminal.go +++ b/services/core/internal/store/runtime_environment_terminal.go @@ -4,10 +4,11 @@ import ( "context" "encoding/json" "fmt" - "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/jackc/pgx/v5/pgtype" ) @@ -60,22 +61,14 @@ func (f ProvisioningFailure) reason() string { return provisioningFailureReason } -// EnvironmentFailure is an Environment's recorded provisioning failure. It -// makes the Session failed with this reason and last activity time. -type EnvironmentFailure struct { - Reason string `json:"reason"` - FailedAt time.Time `json:"failed_at"` - Detail *ProvisioningFailureDetail `json:"-"` -} - -func environmentFailure(row sqlc.Environment) *EnvironmentFailure { +func environmentFailure(row sqlc.Environment) *sessions.EnvironmentFailure { if row.Status != "failed" || !row.FailureReason.Valid || !row.FailedAt.Valid { return nil } - failure := &EnvironmentFailure{Reason: row.FailureReason.String, FailedAt: row.FailedAt.Time} - var detail ProvisioningFailureDetail + failure := &sessions.EnvironmentFailure{Reason: row.FailureReason.String, FailedAt: row.FailedAt.Time} + var detail sessions.ProvisioningFailureDetail if json.Unmarshal(row.FailureDetail, &detail) == nil { - failure.Detail = detail.sanitized() + failure.Detail = sanitizedProvisioningDetail(detail) } return failure } @@ -84,7 +77,7 @@ func environmentFailure(row sqlc.Environment) *EnvironmentFailure { // Public expiry does not assert compute removal or invent an expired SSE variant. // A first failure records the hosted provisioning failure; an already terminal // Environment only settles remaining input, without repeating events. -func terminateRuntimeEnvironment(ctx context.Context, q *sqlc.Queries, current sqlc.GetRuntimeAllocationRow, reason string, detail *ProvisioningFailureDetail, cancel func() error) error { +func terminateRuntimeEnvironment(ctx context.Context, q *sqlc.Queries, current sqlc.GetRuntimeAllocationRow, reason string, detail *sessions.ProvisioningFailureDetail, cancel func() error) error { row, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: current.TenantID, ID: current.SessionID}) if err != nil { return err @@ -111,7 +104,7 @@ func terminateRuntimeEnvironment(ctx context.Context, q *sqlc.Queries, current s // safe reason, then one agent.session.failed snapshot. The snapshot captures the // settled input activity, Usage and the failure, matching later Session reads. // Pending input settles as failed exactly as before. -func failEnvironment(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow, session pgtype.UUID, reason string, detail *ProvisioningFailureDetail, cancel func() error) error { +func failEnvironment(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow, session pgtype.UUID, reason string, detail *sessions.ProvisioningFailureDetail, cancel func() error) error { var rawDetail []byte if detail != nil { var err error @@ -137,18 +130,18 @@ func failEnvironment(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEn if err != nil { return err } - usage, err := q.SessionTokenUsage(ctx, session) + usage, err := sessionpg.LoadUsage(ctx, q, session) if err != nil { return err } - if err := recordSessionChange(ctx, q, session, SessionChange{Event: v1.SessionEvent{ + if err := sessionpg.AppendChanges(ctx, q, session, sessions.SessionChange{Event: v1.SessionEvent{ Type: "error", Error: &v1.StreamError{Type: "environment_error", Code: "sandbox_error", Message: reason}, }}); err != nil { return err } - return recordSessionChange(ctx, q, session, SessionChange{ + return sessionpg.AppendChanges(ctx, q, session, sessions.SessionChange{ Event: v1.SessionEvent{Type: "agent.session.failed"}, - EnvironmentInputActivity: activity, EnvironmentFailure: &EnvironmentFailure{Reason: reason, FailedAt: failedAt.Time}, + EnvironmentInputActivity: activity, EnvironmentFailure: &sessions.EnvironmentFailure{Reason: reason, FailedAt: failedAt.Time}, SessionUsage: usage, Settled: !pending, }) } diff --git a/services/core/internal/store/runtime_idle_clock_test.go b/services/core/internal/store/runtime_idle_clock_test.go index 71890388d..bdb5fec9d 100644 --- a/services/core/internal/store/runtime_idle_clock_test.go +++ b/services/core/internal/store/runtime_idle_clock_test.go @@ -12,6 +12,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) @@ -84,7 +85,7 @@ func TestManagedIdleClockIgnoresRootHostSkew(t *testing.T) { source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) before := runtimeDatabaseTime(t, s) - completed, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, TurnCompleted, outcome, "", 0) + completed, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, sessions.TurnCompleted, outcome, "", 0) after := runtimeDatabaseTime(t, s) if err != nil || completed.CompletedAt.UnixMilli() != source { t.Fatal("native completion changed or rejected", completed, err) @@ -93,7 +94,7 @@ func TestManagedIdleClockIgnoresRootHostSkew(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, TurnCompleted, outcome, "", 0); !errors.Is(err, ErrTurnConflict) { + if _, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, sessions.TurnCompleted, outcome, "", 0); !errors.Is(err, ErrTurnConflict) { t.Fatal("terminal replay accepted", err) } unchanged, err := w.RuntimeActivity(t.Context(), owner) @@ -118,7 +119,7 @@ func TestManagedIdleClockIgnoresChildHostSkewAndReplay(t *testing.T) { runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal,public_visible) VALUES($1,$2,$3,'codex','child','root',1,$4,1,true)`, child, owner.SessionID, owner.DeviceID, root) source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() created := source - 1000 - payload, _ := json.Marshal(proto.SubagentTurnPayload{NativeID: "child", TurnID: "remote-turn", Status: TurnCompleted, CreatedAtMS: created, StartedAtMS: &created, CompletedAtMS: &source}) + payload, _ := json.Marshal(proto.SubagentTurnPayload{NativeID: "child", TurnID: "remote-turn", Status: sessions.TurnCompleted, CreatedAtMS: created, StartedAtMS: &created, CompletedAtMS: &source}) project := func() error { return w.withSession(t.Context(), owner.TenantID, owner.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { return projectSubagentTurn(ctx, q, session, payload) @@ -171,18 +172,18 @@ func TestUnmanagedRootCompletionPreservesHostSkew(t *testing.T) { t.Fatal(err) } input := submitMessage(t, s, tenant, session.ID, "host-clock") - current := transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + current := transition(t, s, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) source := current.CreatedAt.Add(skew).UnixMilli() outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) - completed, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, TurnCompleted, outcome, "", input.Sequence) + completed, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, sessions.TurnCompleted, outcome, "", input.Sequence) if err != nil || completed.CompletedAt.UnixMilli() != source { t.Fatal("native completion changed or rejected", completed, err) } read, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) - if err != nil || read.Status != TurnCompleted || read.CompletedAt.UnixMilli() != source { + if err != nil || read.Status != sessions.TurnCompleted || read.CompletedAt.UnixMilli() != source { t.Fatal("public native timestamp rewritten", read, err) } - if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, TurnCompleted, outcome, "", input.Sequence); !errors.Is(err, ErrTurnConflict) { + if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, sessions.TurnCompleted, outcome, "", input.Sequence); !errors.Is(err, ErrTurnConflict) { t.Fatal("terminal replay accepted", err) } }) @@ -196,9 +197,9 @@ func TestRootCompletionRejectsNonpositiveSourceTime(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) input := submitMessage(t, s, tenant, session.ID, "invalid-clock") - transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) - if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, TurnCompleted, outcome, "", input.Sequence); !errors.Is(err, ErrInvalidInput) { + if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, sessions.TurnCompleted, outcome, "", input.Sequence); !errors.Is(err, ErrInvalidInput) { t.Fatal("invalid native timestamp accepted", err) } }) diff --git a/services/core/internal/store/runtime_suspension_concurrency_test.go b/services/core/internal/store/runtime_suspension_concurrency_test.go index 252a7ca44..cdf8020ba 100644 --- a/services/core/internal/store/runtime_suspension_concurrency_test.go +++ b/services/core/internal/store/runtime_suspension_concurrency_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -58,7 +59,7 @@ func TestRuntimeSuspensionClaimReadsPhaseAfterSessionLock(t *testing.T) { ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.SessionID) done := make(chan error, 1) go func() { - _, err := w.TransitionTurn(ctx, owner.TenantID, owner.SessionID, turn, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + _, err := w.TransitionTurn(ctx, owner.TenantID, owner.SessionID, turn, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) done <- err }() runtimeSuspensionWaitBlocked(t, ctx, pool, blocker, done) @@ -82,10 +83,10 @@ func TestRuntimeSuspensionClaimReadsPhaseAfterSessionLock(t *testing.T) { if err := pool.QueryRow(ctx, `SELECT count(*) FROM session_events WHERE session_id=$1 AND payload->'event'->>'type'='agent.session.turn.in_progress'`, owner.SessionID).Scan(&startedEvents); err != nil { t.Fatal(err) } - if blocked && (got.Status != TurnQueued || !got.StartedAt.IsZero() || startedEvents != 0) { + if blocked && (got.Status != sessions.TurnQueued || !got.StartedAt.IsZero() || startedEvents != 0) { t.Fatal("blocked claim changed Turn or projected start", got, startedEvents) } - if !blocked && (got.Status != TurnInProgress || got.StartedAt.IsZero() || startedEvents != 1) { + if !blocked && (got.Status != sessions.TurnInProgress || got.StartedAt.IsZero() || startedEvents != 1) { t.Fatal("ordinary compute no longer starts work", got, startedEvents) } }) @@ -202,7 +203,7 @@ func TestRuntimeSuspensionQuiesceCannotOvertakeClaim(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := transitionTurn(ctx, w.queries.WithTx(tx), params, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress, Outcome: json.RawMessage(`{}`)}); err != nil { + if _, err := transitionTurn(ctx, w.queries.WithTx(tx), params, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress, Outcome: json.RawMessage(`{}`)}); err != nil { t.Fatal(err) } if err := tx.Commit(ctx); err != nil { diff --git a/services/core/internal/store/runtime_worker_recovery_test.go b/services/core/internal/store/runtime_worker_recovery_test.go index 417227c6b..0c3327982 100644 --- a/services/core/internal/store/runtime_worker_recovery_test.go +++ b/services/core/internal/store/runtime_worker_recovery_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -125,7 +126,7 @@ func TestWorkerWaitsForComputeAndSurvivesPromotionConflict(t *testing.T) { } awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "original input completed once", func() bool { turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, start.RunID) - return err == nil && turn.Status == store.TurnCompleted + return err == nil && turn.Status == sessions.TurnCompleted }) var turns int if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM turns WHERE session_id=$1`, h.session.ID).Scan(&turns); err != nil || turns != 1 { @@ -155,7 +156,7 @@ func TestWorkerRestartPreservesQueuedTurnWhileComputeWakes(t *testing.T) { t.Fatal(err) } got, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, turn) - if err != nil || got.Status != store.TurnQueued || !got.StartedAt.IsZero() { + if err != nil || got.Status != sessions.TurnQueued || !got.StartedAt.IsZero() { t.Fatal("startup consumed queued work before restore", got, err) } } diff --git a/services/core/internal/store/sandbox_deployment_switch_test.go b/services/core/internal/store/sandbox_deployment_switch_test.go index ed5f5fc8a..9e137dc8f 100644 --- a/services/core/internal/store/sandbox_deployment_switch_test.go +++ b/services/core/internal/store/sandbox_deployment_switch_test.go @@ -12,6 +12,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -321,13 +322,13 @@ func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); err != nil { + if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } if err := w.AppendTurnEvents(t.Context(), tenant, history.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained answer"}`)}}); err != nil { t.Fatal(err) } - if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnCompleted}); err != nil { + if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnCompleted}); err != nil { t.Fatal(err) } items, err := s.ListItems(t.Context(), tenant, history.ID, "", 100, true) diff --git a/services/core/internal/store/sandbox_reset_test.go b/services/core/internal/store/sandbox_reset_test.go index 8ba70531d..ed2ab71b7 100644 --- a/services/core/internal/store/sandbox_reset_test.go +++ b/services/core/internal/store/sandbox_reset_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -184,7 +185,7 @@ func TestSandboxResetAutoRechecksTurnStartedAfterListing(t *testing.T) { // Existing live input remains admitted; Turn transition takes the same // Session lock that the later conditional archive must reacquire. turn := submitMessage(t, s, tenant, session.ID, "after-list") - transition(t, w, tenant, session.ID, turn.TurnID, TurnQueued, TurnInProgress) + transition(t, w, tenant, session.ID, turn.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if _, err := w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt); !errors.Is(err, ErrSandboxResetSessionBusy) { t.Fatal("listed idle candidate cut a new Turn", err) } diff --git a/services/core/internal/store/self_hosted_cancel_public_test.go b/services/core/internal/store/self_hosted_cancel_public_test.go index 911dba4de..9cd9897a8 100644 --- a/services/core/internal/store/self_hosted_cancel_public_test.go +++ b/services/core/internal/store/self_hosted_cancel_public_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -131,7 +132,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { if err != nil { t.Fatal(err) } - transition(input.TurnID, store.TurnQueued, store.TurnInProgress) + transition(input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) settings["turn_id"] = input.TurnID return input.TurnID } @@ -155,7 +156,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { run("active") activeReceipts := receipts(created.ActiveKey, first) turn, err := s.GetTurn(t.Context(), tenant, created.ID, first) - if err != nil || turn.Status != store.TurnInProgress || turn.CancelRequestedAt.IsZero() || !turn.CompletedAt.IsZero() { + if err != nil || turn.Status != sessions.TurnInProgress || turn.CancelRequestedAt.IsZero() || !turn.CompletedAt.IsZero() { t.Fatal("202 must admit cancellation without fabricating native completion", err) } itemsAfter, err := s.ListItems(t.Context(), tenant, created.ID, "", 100, true) @@ -166,7 +167,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { if err != nil || afterCursor != cursor { t.Fatal("cancellation admission fabricated an execution event", err) } - transition(first, store.TurnInProgress, store.TurnCancelled) + transition(first, sessions.TurnInProgress, sessions.TurnCancelled) for _, reopen := range []bool{false, true} { if reopen { stop(false) @@ -187,6 +188,6 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { t.Fatal("rejected cancellation changed pending reservation, deadline or history", "reopened", reopen) } } - transition(next, store.TurnInProgress, store.TurnCompleted) + transition(next, sessions.TurnInProgress, sessions.TurnCompleted) } } diff --git a/services/core/internal/store/session_artifacts_public_test.go b/services/core/internal/store/session_artifacts_public_test.go index 704eb4040..96935da81 100644 --- a/services/core/internal/store/session_artifacts_public_test.go +++ b/services/core/internal/store/session_artifacts_public_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -46,7 +47,7 @@ func completeArtifactTurn(t *testing.T, s *store.Store, tenant, session, environ t.Fatal(err) } } - transition(store.TurnQueued, store.TurnInProgress) + transition(sessions.TurnQueued, sessions.TurnInProgress) var archive bytes.Buffer w := tar.NewWriter(&archive) for name, body := range outputs { @@ -63,7 +64,7 @@ func completeArtifactTurn(t *testing.T, s *store.Store, tenant, session, environ if err := s.StageTurnArtifacts(t.Context(), tenant, session, receipt.TurnID, environment, &archive); err != nil { t.Fatal(err) } - transition(store.TurnInProgress, store.TurnCompleted) + transition(sessions.TurnInProgress, sessions.TurnCompleted) return receipt.TurnID } diff --git a/services/core/internal/store/session_artifacts_test.go b/services/core/internal/store/session_artifacts_test.go index 3e81ae64a..4146706d8 100644 --- a/services/core/internal/store/session_artifacts_test.go +++ b/services/core/internal/store/session_artifacts_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -48,7 +49,7 @@ func artifactTurn(t *testing.T, s *Store, kind string) (tenant, session, environ t.Fatal(err) } input := submitMessage(t, s, tenant, created.ID, "artifact-turn") - transition(t, s, tenant, created.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, created.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) return tenant, created.ID, env.ID, input.TurnID } @@ -71,7 +72,7 @@ func testSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T, kind strin if err != nil || len(page.Artifacts) != 0 { t.Fatalf("private capture visible: %+v %v", page, err) } - completed := transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + completed := transition(t, s, tenant, session, turn, sessions.TurnInProgress, sessions.TurnCompleted) page, err = s.ListSessionArtifacts(t.Context(), tenant, session, environment, "", 100, true) if err != nil || len(page.Artifacts) != 2 { t.Fatalf("published capture: %+v %v", page, err) @@ -105,11 +106,11 @@ func testSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T, kind strin } // A later completed Turn publishes another immutable version of the same path. next := submitMessage(t, s, tenant, session, "version-two") - transition(t, s, tenant, session, next.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session, next.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if err := s.StageTurnArtifacts(t.Context(), tenant, session, next.TurnID, environment, bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/a.bin": []byte("new")}))); err != nil { t.Fatal(err) } - transition(t, s, tenant, session, next.TurnID, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session, next.TurnID, sessions.TurnInProgress, sessions.TurnCompleted) all, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) if err != nil || len(all.Artifacts) != 3 { t.Fatal(all, err) @@ -211,7 +212,7 @@ func testSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T, kind st } func TestSessionArtifactsDiscardTerminalPrivateCapture(t *testing.T) { - for _, status := range []string{TurnFailed, TurnCancelled} { + for _, status := range []string{sessions.TurnFailed, sessions.TurnCancelled} { t.Run(status, func(t *testing.T) { s, pool := testStore(t) tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") @@ -220,7 +221,7 @@ func TestSessionArtifactsDiscardTerminalPrivateCapture(t *testing.T) { if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(body)); err != nil { t.Fatal(err) } - transition(t, s, tenant, session, turn, TurnInProgress, status) + transition(t, s, tenant, session, turn, sessions.TurnInProgress, status) if count := sourceObjectCount(t, pool); count != before { t.Fatalf("terminal capture leaked objects: %d -> %d", before, count) } @@ -281,7 +282,7 @@ func TestSessionArtifactTransferDoesNotBlockDeletionOrCancellation(t *testing.T) func startArtifactTurn(t *testing.T, s *Store, tenant, session, key string) string { t.Helper() input := submitMessage(t, s, tenant, session, key) - transition(t, s, tenant, session, input.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) return input.TurnID } @@ -350,7 +351,7 @@ func TestSessionArtifactsRepublishOnlyNewChangedOrDeletedPaths(t *testing.T) { } turnNumber++ stageArtifactOutputs(t, s, tenant, session, environment, turn, files) - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session, turn, sessions.TurnInProgress, sessions.TurnCompleted) published := publishedByTurn(t, s, tenant, session, turn) sort.Strings(want) if got := publishedPaths(published); strings.Join(got, ",") != strings.Join(want, ",") { @@ -424,7 +425,7 @@ func TestSessionArtifactsRepublishOnlyNewChangedOrDeletedPaths(t *testing.T) { if err := s.DeleteSessionArtifact(t.Context(), tenant, session, two["a.txt"].ID); err != nil { t.Fatal(err) } - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session, turn, sessions.TurnInProgress, sessions.TurnCompleted) if got := publishedPaths(publishedByTurn(t, s, tenant, session, turn)); !reflect.DeepEqual(got, []string{"a.txt"}) { t.Fatalf("deletion during capture: published %v", got) } @@ -441,7 +442,7 @@ func TestSessionArtifactsRepublishOnlyNewChangedOrDeletedPaths(t *testing.T) { } otherTurn := startArtifactTurn(t, s, tenant, other.ID, "artifact-other-turn") stageArtifactOutputs(t, s, tenant, other.ID, otherEnvironment.ID, otherTurn, outputs) - transition(t, s, tenant, other.ID, otherTurn, TurnInProgress, TurnCompleted) + transition(t, s, tenant, other.ID, otherTurn, sessions.TurnInProgress, sessions.TurnCompleted) if got := publishedPaths(publishedByTurn(t, s, tenant, other.ID, otherTurn)); !reflect.DeepEqual(got, []string{"a.txt", "empty.txt", "sub/b.txt"}) { t.Fatalf("other Session first Turn published %v", got) } @@ -472,10 +473,10 @@ func TestSessionArtifactsNewestVersionFollowsTurnOrder(t *testing.T) { // Turn 1 reports a native completion one hour ahead, so its Artifact is // published later than every following Turn's. first := submitMessage(t, s, tenant, session, "artifact-order-1") - transition(t, s, tenant, session, first.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session, first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) stageArtifactOutputs(t, s, tenant, session, env.ID, first.TurnID, map[string]string{"b.txt": "bravo"}) future := time.Now().Add(time.Hour).UnixMilli() - if _, err := s.CompleteExecution(t.Context(), tenant, session, first.TurnID, TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, future)), "", first.Sequence); err != nil { + if _, err := s.CompleteExecution(t.Context(), tenant, session, first.TurnID, sessions.TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, future)), "", first.Sequence); err != nil { t.Fatal(err) } one := publishedByTurn(t, s, tenant, session, first.TurnID)["b.txt"] @@ -483,7 +484,7 @@ func TestSessionArtifactsNewestVersionFollowsTurnOrder(t *testing.T) { t.Helper() turn := startArtifactTurn(t, s, tenant, session, key) stageArtifactOutputs(t, s, tenant, session, env.ID, turn, map[string]string{"b.txt": body}) - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session, turn, sessions.TurnInProgress, sessions.TurnCompleted) return publishedByTurn(t, s, tenant, session, turn) } two := run("artifact-order-2", "bravo-v2")["b.txt"] @@ -506,7 +507,7 @@ func TestSessionArtifactsCompletionWaitsForConcurrentDeletion(t *testing.T) { tenant, session, environment, first := artifactTurn(t, s, "openai_hosted") before := sourceObjectCount(t, pool) stageArtifactOutputs(t, s, tenant, session, environment, first, map[string]string{"a.txt": "alpha"}) - transition(t, s, tenant, session, first, TurnInProgress, TurnCompleted) + transition(t, s, tenant, session, first, sessions.TurnInProgress, sessions.TurnCompleted) newest := publishedByTurn(t, s, tenant, session, first)["a.txt"] turn := startArtifactTurn(t, s, tenant, session, "artifact-concurrent-delete") stageArtifactOutputs(t, s, tenant, session, environment, turn, map[string]string{"a.txt": "alpha"}) @@ -535,7 +536,7 @@ func TestSessionArtifactsCompletionWaitsForConcurrentDeletion(t *testing.T) { } done := make(chan error, 1) go func() { - _, err := s.TransitionTurn(t.Context(), tenant, session, turn, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnCompleted}) + _, err := s.TransitionTurn(t.Context(), tenant, session, turn, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnCompleted}) done <- err }() // Completion must be blocked on the Session lock before the deletion commits. @@ -558,7 +559,7 @@ func TestSessionArtifactsCompletionWaitsForConcurrentDeletion(t *testing.T) { } time.Sleep(10 * time.Millisecond) } - if status, err := s.GetTurn(t.Context(), tenant, session, turn); err != nil || status.Status != TurnInProgress { + if status, err := s.GetTurn(t.Context(), tenant, session, turn); err != nil || status.Status != sessions.TurnInProgress { t.Fatalf("Turn settled while the deletion held the lock: %+v %v", status, err) } if err := tx.Commit(t.Context()); err != nil { diff --git a/services/core/internal/store/session_creation_stream_test.go b/services/core/internal/store/session_creation_stream_test.go index dd50dcf51..1db14f55b 100644 --- a/services/core/internal/store/session_creation_stream_test.go +++ b/services/core/internal/store/session_creation_stream_test.go @@ -7,6 +7,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -48,7 +49,7 @@ func TestCreationStreamStartsBeforeOwnInputsAndRetriesAtUpsertCursor(t *testing. } // The snapshot is the committed post-admission projection; the cursor still // precedes the initial input's events. - if !created.Created || created.Cursor != 0 || created.Session.LastTurn == nil || created.Session.LastTurn.Status != TurnQueued || len(retries) != 7 { + if !created.Created || created.Cursor != 0 || created.Session.LastTurn == nil || created.Session.LastTurn.Status != sessions.TurnQueued || len(retries) != 7 { t.Fatal("invalid post-admission creation snapshot", created, retries) } id := created.Session.ID @@ -61,7 +62,7 @@ func TestCreationStreamStartsBeforeOwnInputsAndRetriesAtUpsertCursor(t *testing. t.Fatal("new Turn events are out of order", i, initial[i].Event.Type) } } - if initial[1].Event.Item == nil || initial[1].Event.Item.Role != "user" || initial[2].Turn == nil || initial[2].Turn.Status != TurnQueued { + if initial[1].Event.Item == nil || initial[1].Event.Item.Role != "user" || initial[2].Turn == nil || initial[2].Turn.Status != sessions.TurnQueued { t.Fatal("invalid initial input or activity snapshot", initial) } encoded := func(value Session) string { @@ -99,8 +100,8 @@ func TestCreationStreamStartsBeforeOwnInputsAndRetriesAtUpsertCursor(t *testing. if err != nil || encoded(snapshot) != encoded(read) || cursor != initial[len(initial)-1].Sequence || initial[2].Settled { t.Fatal("stream snapshot differs from the Session read and its cursor", cursor, err) } - transition(t, s, tenant, id, ordinary.LastTurn.ID, TurnQueued, TurnInProgress) - transition(t, s, tenant, id, ordinary.LastTurn.ID, TurnInProgress, TurnCompleted) + transition(t, s, tenant, id, ordinary.LastTurn.ID, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, s, tenant, id, ordinary.LastTurn.ID, sessions.TurnInProgress, sessions.TurnCompleted) // Completing before the HTTP observer drains does not change its start point. all, err := s.ListSessionEvents(ctx, tenant, id, created.Cursor) if err != nil || len(all) <= len(initial) || all[0].Event.EventID != initial[0].Event.EventID { diff --git a/services/core/internal/store/session_deletion.go b/services/core/internal/store/session_deletion.go index 44ba6772b..72dc16ea8 100644 --- a/services/core/internal/store/session_deletion.go +++ b/services/core/internal/store/session_deletion.go @@ -6,6 +6,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -86,20 +88,28 @@ func requestTurnCancel(ctx context.Context, q *sqlc.Queries, session pgtype.UUID if err := q.RequestTurnCancel(ctx, sqlc.RequestTurnCancelParams{ID: turn.ID, SessionID: session}); err != nil { return err } - if turn.Status == TurnQueued { + if turn.Status == sessions.TurnQueued { cancelled, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) if err != nil { return err } - if err := recordTurnChange(ctx, q, cancelled, false); err != nil { + ending, err := sessionpg.LoadEnding(ctx, q, session, cancelled.ID) + if err != nil { + return err + } + if err := sessionpg.ApplyTurnEnd(ctx, q, session, cancelled.ID, sessions.EndTurn(turnFromRow(cancelled), ending)); err != nil { return err } - } else if turn.Status == TurnWaiting && !turn.CancelRequestedAt.Valid { + } else if turn.Status == sessions.TurnWaiting && !turn.CancelRequestedAt.Valid { cancelling, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) if err != nil { return err } - if err := recordSessionActivity(ctx, q, cancelling, nil); err != nil { + usage, err := sessionpg.LoadUsage(ctx, q, session) + if err != nil { + return err + } + if err := sessionpg.AppendChanges(ctx, q, session, sessions.ActivityChange(turnFromRow(cancelling), usage, nil)); err != nil { return err } } diff --git a/services/core/internal/store/session_deletion_execution_test.go b/services/core/internal/store/session_deletion_execution_test.go index 8f1e138ae..da47e5d7b 100644 --- a/services/core/internal/store/session_deletion_execution_test.go +++ b/services/core/internal/store/session_deletion_execution_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -46,7 +47,7 @@ func TestDeletedSessionWaitingTurnSettlesWithoutStoppingWorker(t *testing.T) { t.Fatal(err) } h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "deleted-native"}}}) - waitTurn(t, h, input.TurnID, store.TurnCancelled) + waitTurn(t, h, input.TurnID, sessions.TurnCancelled) bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) if err != nil || bound.NativeSessionID != "deleted-native" { t.Fatal(bound, err) @@ -61,14 +62,14 @@ func TestDeletedSessionWaitingTurnSettlesWithoutStoppingWorker(t *testing.T) { next := h.message("next", "Unrelated work") h.read(testExecutionRequest) h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "unaffected"}) - waitTurn(t, h, next.TurnID, store.TurnCompleted) + waitTurn(t, h, next.TurnID, sessions.TurnCompleted) } func TestDeletedSessionRestartStillReconcilesHiddenClaim(t *testing.T) { h := newDispatchHarness(t) input := h.message("interrupted", "Run") ctx := t.Context() - if _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } if err := h.s.DeleteSession(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrSessionNotIdle) { @@ -84,7 +85,7 @@ func TestDeletedSessionRestartStillReconcilesHiddenClaim(t *testing.T) { t.Fatal(err) } turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnFailed { + if err != nil || turn.Status != sessions.TurnFailed { t.Fatal(turn, err) } if _, err := h.s.GetSession(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { @@ -116,7 +117,7 @@ func TestWaitingSessionCancelsThenDeletesThroughWorker(t *testing.T) { if err := h.s.DeleteSession(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrSessionNotIdle) { t.Fatal("waiting Session deleted", err) } - if again := functionState(t, h, 1); again.LastTurn == nil || again.LastTurn.Status != store.TurnWaiting || !again.LastTurn.CancelRequestedAt.IsZero() { + if again := functionState(t, h, 1); again.LastTurn == nil || again.LastTurn.Status != sessions.TurnWaiting || !again.LastTurn.CancelRequestedAt.IsZero() { t.Fatal("rejected deletion changed required actions", again) } if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel-before-delete"); err != nil { @@ -131,7 +132,7 @@ func TestWaitingSessionCancelsThenDeletesThroughWorker(t *testing.T) { t.Fatal("Session deleted before cancellation settled", err) } h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "cancelled-native"}}}) - waitTurn(t, h, input.TurnID, store.TurnCancelled) + waitTurn(t, h, input.TurnID, sessions.TurnCancelled) deadline := time.Now().Add(10 * time.Second) for { err := h.s.DeleteSession(ctx, h.tenant, h.session.ID) @@ -157,5 +158,5 @@ func TestWaitingSessionCancelsThenDeletesThroughWorker(t *testing.T) { next := h.message("next", "Unrelated work") h.read(testExecutionRequest) h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "unaffected"}) - waitTurn(t, h, next.TurnID, store.TurnCompleted) + waitTurn(t, h, next.TurnID, sessions.TurnCompleted) } diff --git a/services/core/internal/store/session_deletion_lifecycle_public_test.go b/services/core/internal/store/session_deletion_lifecycle_public_test.go index 6bcc32244..ccce7ddee 100644 --- a/services/core/internal/store/session_deletion_lifecycle_public_test.go +++ b/services/core/internal/store/session_deletion_lifecycle_public_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/google/uuid" @@ -63,14 +64,14 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { if err != nil { t.Fatal(err) } - from := store.TurnQueued + from := sessions.TurnQueued for _, status := range to { switch status { case "cancel": _, err = s.RequestCancel(ctx, tenant, session.ID, "cancel") case "function": err = s.RecordFunctionCall(ctx, tenant, session.ID, receipt.TurnID, store.FunctionCall{CallID: "pending", ExecutorCallID: "native-pending", Name: "lookup", Arguments: json.RawMessage(`{}`)}) - case store.TurnCompleted, store.TurnFailed: + case sessions.TurnCompleted, sessions.TurnFailed: _, err = s.CompleteExecution(ctx, tenant, session.ID, receipt.TurnID, status, nil, "", receipt.Sequence) default: _, err = s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: from, Status: status}) @@ -104,9 +105,9 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { // D3: every Session that is not durably idle or failed without required actions. busy := map[string]string{ "queued_turn": turn(), - "in_progress_turn": turn(store.TurnInProgress), - "cancelling_turn": turn(store.TurnInProgress, "cancel"), - "required_action": turn(store.TurnInProgress, "function"), + "in_progress_turn": turn(sessions.TurnInProgress), + "cancelling_turn": turn(sessions.TurnInProgress, "cancel"), + "required_action": turn(sessions.TurnInProgress, "function"), } awaiting := create(selfHosted, true) busy["self_hosted_awaiting_connection"] = awaiting.ID @@ -125,8 +126,8 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { // D4: settled Sessions, including idle hosted provisioning without input. settled := map[string]string{ "none_idle": create(none, false).ID, - "completed_turn": turn(store.TurnInProgress, store.TurnCompleted), - "failed_turn": turn(store.TurnInProgress, store.TurnFailed), + "completed_turn": turn(sessions.TurnInProgress, sessions.TurnCompleted), + "failed_turn": turn(sessions.TurnInProgress, sessions.TurnFailed), "cancelled_turn": turn("cancel"), "self_hosted_idle": create(selfHosted, false).ID, "hosted_provisioning_idle": create(hosted, false).ID, diff --git a/services/core/internal/store/session_deletion_test.go b/services/core/internal/store/session_deletion_test.go index 5f5bd5918..a18cc0b26 100644 --- a/services/core/internal/store/session_deletion_test.go +++ b/services/core/internal/store/session_deletion_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -49,7 +50,7 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { s, pool := testStore(t) ctx := t.Context() tenant := uuid.NewString() - for _, status := range []string{TurnQueued, TurnInProgress, TurnCompleted, TurnFailed} { + for _, status := range []string{sessions.TurnQueued, sessions.TurnInProgress, sessions.TurnCompleted, sessions.TurnFailed} { t.Run(status, func(t *testing.T) { input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: status} session, err := s.CreateSession(ctx, tenant, input) @@ -60,13 +61,13 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { if err != nil { t.Fatal(err) } - if status != TurnQueued { - _, err = s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + if status != sessions.TurnQueued { + _, err = s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } } - if status == TurnCompleted || status == TurnFailed { + if status == sessions.TurnCompleted || status == sessions.TurnFailed { if _, err = s.CompleteExecution(ctx, tenant, session.ID, receipt.TurnID, status, nil, "", receipt.Sequence); err != nil { t.Fatal(err) } @@ -74,7 +75,7 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { if err := s.DeleteSession(ctx, uuid.NewString(), session.ID); !errors.Is(err, ErrNotFound) { t.Fatal(err) } - if status == TurnQueued || status == TurnInProgress { + if status == sessions.TurnQueued || status == sessions.TurnInProgress { // Deletion leaves active work untouched: no cancellation, marker or event. cursor, err := s.SessionEventCursor(ctx, tenant, session.ID) if err != nil { @@ -98,11 +99,11 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { if _, err := s.RequestCancel(ctx, tenant, session.ID, "cancel"); err != nil { t.Fatal(err) } - if status == TurnInProgress { + if status == sessions.TurnInProgress { if err := s.DeleteSession(ctx, tenant, session.ID); !errors.Is(err, ErrSessionNotIdle) { t.Fatal("cancelling Session deleted", err) } - if _, err := s.CompleteExecution(ctx, tenant, session.ID, receipt.TurnID, TurnCancelled, nil, "", receipt.Sequence); err != nil { + if _, err := s.CompleteExecution(ctx, tenant, session.ID, receipt.TurnID, sessions.TurnCancelled, nil, "", receipt.Sequence); err != nil { t.Fatal(err) } } @@ -147,13 +148,13 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { t.Fatal(err) } want := status - if status == TurnQueued || status == TurnInProgress { - want = TurnCancelled + if status == sessions.TurnQueued || status == sessions.TurnInProgress { + want = sessions.TurnCancelled } if turn.Status != want { t.Fatal(turn) } - if _, err := fresh.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); !errors.Is(err, ErrTurnConflict) { + if _, err := fresh.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); !errors.Is(err, ErrTurnConflict) { t.Fatal(err) } inputs, err := fresh.ListTurnInputs(ctx, tenant, session.ID, receipt.TurnID, 0, 20) @@ -300,7 +301,7 @@ func TestSessionDeletionRacesAdmissionUnderSessionLock(t *testing.T) { if err != nil { t.Fatal(err) } - if kind.name == "turn" && (current.LastTurn == nil || current.LastTurn.Status != TurnQueued || !current.LastTurn.CancelRequestedAt.IsZero()) { + if kind.name == "turn" && (current.LastTurn == nil || current.LastTurn.Status != sessions.TurnQueued || !current.LastTurn.CancelRequestedAt.IsZero()) { t.Fatal("rejected deletion changed admitted work", current.LastTurn) } if kind.name == "environment_input" && !current.PendingInput { diff --git a/services/core/internal/store/session_diagnostics.go b/services/core/internal/store/session_diagnostics.go index c8f75b280..b9ab3d181 100644 --- a/services/core/internal/store/session_diagnostics.go +++ b/services/core/internal/store/session_diagnostics.go @@ -7,6 +7,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -21,7 +22,7 @@ type ItemDiagnosticTiming struct { type TurnDiagnosticsSnapshot struct { Session Session - Turn Turn + Turn sessions.Turn Items []ItemDiagnosticTiming ItemsTruncated bool } diff --git a/services/core/internal/store/session_diagnostics_test.go b/services/core/internal/store/session_diagnostics_test.go index 5ca9cfc5e..28d0b6f24 100644 --- a/services/core/internal/store/session_diagnostics_test.go +++ b/services/core/internal/store/session_diagnostics_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) @@ -23,7 +24,7 @@ func TestDiagnosticItemReceiptSettlementAndReplay(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) receipt := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) before := diagnosticToolEvent("cmd", "before", "in_progress") after := diagnosticToolEvent("cmd", "after", "failed") for i, event := range []ExecutionEvent{before, after} { @@ -54,7 +55,7 @@ func TestDiagnosticItemReceiptSettlementAndReplay(t *testing.T) { if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, []ExecutionEvent{after}); err != nil { t.Fatal(err) } - transition(t, s, tenant, session.ID, receipt.TurnID, TurnInProgress, TurnFailed) + transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnInProgress, sessions.TurnFailed) snap, err = s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) if err != nil || snap.Items[1].CompletedAt != nil { t.Fatal("historical settlement synthesized", snap, err) @@ -73,7 +74,7 @@ func TestDiagnosticForceSettlementIgnoresNativeClock(t *testing.T) { } source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() before := runtimeDatabaseTime(t, s) - completed, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)), "", 0) + completed, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, sessions.TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)), "", 0) after := runtimeDatabaseTime(t, s) if err != nil || completed.CompletedAt.UnixMilli() != source { t.Fatal("public native completion changed", completed, err) @@ -98,7 +99,7 @@ func TestDiagnosticSettlementWaitsForSessionLock(t *testing.T) { s, pool := testStore(t) tenant, session := newTurnSession(t, s) receipt := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) tx, err := pool.Begin(t.Context()) if err != nil { t.Fatal(err) @@ -109,7 +110,7 @@ func TestDiagnosticSettlementWaitsForSessionLock(t *testing.T) { } done := make(chan error, 1) go func() { - _, err := s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed}) + _, err := s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed}) done <- err }() // Wait for the actual competing transaction to block, not a scheduler delay. @@ -278,7 +279,7 @@ func TestDiagnosticFirstSettlementSurvivesStoredStatusRegression(t *testing.T) { s, pool := testStore(t) tenant, session := newTurnSession(t, s) receipt := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) events := []ExecutionEvent{diagnosticToolEvent("cmd", "before", "in_progress"), diagnosticToolEvent("cmd", "after", "completed")} if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 1, events); err != nil { t.Fatal(err) @@ -296,7 +297,7 @@ func TestDiagnosticFirstSettlementSurvivesStoredStatusRegression(t *testing.T) { for _, force := range []bool{false, true} { runtimeSuspensionSQL(t, pool, "UPDATE session_items SET payload=jsonb_set(payload,'{status}','\"in_progress\"') WHERE id=$1", item.ItemID) if force { - transition(t, s, tenant, session.ID, receipt.TurnID, TurnInProgress, TurnFailed) + transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnInProgress, sessions.TurnFailed) } else if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, events[1:]); err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/session_events.go b/services/core/internal/store/session_events.go index 14104cc75..f32b51430 100644 --- a/services/core/internal/store/session_events.go +++ b/services/core/internal/store/session_events.go @@ -5,93 +5,15 @@ import ( "context" "encoding/json" "errors" - "sort" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" - "github.com/google/uuid" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" ) var ErrStreamGap = errors.New("live event buffer exceeded; recover through Session and Items reads") -// SessionChange keeps transition snapshots separate from public response rendering. -type SessionChange struct { - Sequence int64 `json:"-"` - Event v1.SessionEvent `json:"event"` - Turn *Turn `json:"turn,omitempty"` - SessionUsage json.RawMessage `json:"session_usage,omitempty"` - RequiredActions []v1.FunctionCallAction `json:"required_actions,omitempty"` - EnvironmentInputActivity *EnvironmentInputActivity `json:"environment_input_activity,omitempty"` - // EnvironmentFailure is set on the agent.session.failed snapshot of a hosted - // provisioning failure, which also ends live event streams. - EnvironmentFailure *EnvironmentFailure `json:"environment_failure,omitempty"` - // Settled marks an idle or failed snapshot recorded when a Turn ends, or when - // the latest input reservation stops being pending (expired, cancelled or - // failed). A reservation made while the ending Turn captured Artifacts can - // still be pending and start a later Turn. It is internal, never a wire - // field; snapshots recorded without it read as unsettled. - Settled bool `json:"settled,omitempty"` -} - -func recordSessionChange(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, change SessionChange) error { - change.Event.EventID = uuid.NewString() - change.Event.SessionID = uuid.UUID(session.Bytes).String() - payload, err := json.Marshal(change) - if err != nil { - return err - } - return q.AppendSessionEvent(ctx, sqlc.AppendSessionEventParams{ID: session, Payload: payload}) -} - -func recordTurnChange(ctx context.Context, q *sqlc.Queries, row sqlc.Turn, created bool) error { - if row.Status == TurnWaiting { - return nil - } - if terminalStatus(row.Status) { - // Managed idle time starts when Core commits completion, not on a remote clock. - if err := q.RecordRuntimeTerminalActivity(ctx, row.SessionID); err != nil { - return err - } - if err := settleTurnArtifacts(ctx, q, row); err != nil { - return err - } - unfinished, err := q.FinishSessionItems(ctx, sqlc.FinishSessionItemsParams{SessionID: row.SessionID, TurnID: row.ID}) - if err != nil { - return err - } - sort.Slice(unfinished, func(i, j int) bool { return unfinished[i].Position < unfinished[j].Position }) - for _, item := range unfinished { - var value v1.Item - if err := json.Unmarshal(item.Payload, &value); err != nil { - return err - } - previous := value - previous.Status = "in_progress" - if err := recordItemChange(ctx, q, row.SessionID, item.OutputIndex, previous, value, nil); err != nil { - return err - } - } - } - turn := turnFromRow(row) - turn.Outcome = nil - kind := row.Status - if created { - kind = "created" - } - change := SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn." + kind, TurnID: turn.ID}, Turn: &turn} - if err := recordSessionChange(ctx, q, row.SessionID, change); err != nil { - return err - } - // The admitting input records a new Turn's Session activity after its Items. - if created || !terminalStatus(row.Status) { - return nil - } - return recordSessionActivity(ctx, q, row, nil) -} - func (s *Store) SessionEventCursor(ctx context.Context, tenantID, sessionID string) (int64, error) { tenant, err := parseID(tenantID) if err != nil { @@ -105,7 +27,7 @@ func (s *Store) SessionEventCursor(ctx context.Context, tenantID, sessionID stri return cursor, err } -func (s *Store) ListSessionEvents(ctx context.Context, tenantID, sessionID string, after int64) ([]SessionChange, error) { +func (s *Store) ListSessionEvents(ctx context.Context, tenantID, sessionID string, after int64) ([]sessions.SessionChange, error) { if after < 0 { return nil, ErrInvalidInput } @@ -125,7 +47,7 @@ func (s *Store) ListSessionEvents(ctx context.Context, tenantID, sessionID strin if err != nil { return nil, err } - changes := make([]SessionChange, 0, len(rows)) + changes := make([]sessions.SessionChange, 0, len(rows)) if len(rows) == 0 && latest > after { return nil, ErrStreamGap } @@ -133,7 +55,7 @@ func (s *Store) ListSessionEvents(ctx context.Context, tenantID, sessionID strin if row.Sequence != after+1 { return nil, ErrStreamGap } - var change SessionChange + var change sessions.SessionChange decoder := json.NewDecoder(bytes.NewReader(row.Payload)) decoder.UseNumber() if err := decoder.Decode(&change); err != nil { diff --git a/services/core/internal/store/session_events_test.go b/services/core/internal/store/session_events_test.go index 6170bb2be..fe40c827f 100644 --- a/services/core/internal/store/session_events_test.go +++ b/services/core/internal/store/session_events_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -70,7 +71,7 @@ func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { if before != after { t.Fatal("input retry published duplicate events") } - if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } batch := []store.ExecutionEvent{ @@ -95,10 +96,10 @@ func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { if before != after { t.Fatal("failed transaction published events") } - if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{"private":"must not escape"}`), "", input.Sequence); err != nil { + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{"private":"must not escape"}`), "", input.Sequence); err != nil { t.Fatal(err) } - var all []store.SessionChange + var all []sessions.SessionChange cursor := int64(0) for { page, err := store.New(pool).ListSessionEvents(ctx, tenant, session.ID, cursor) @@ -111,7 +112,7 @@ func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { all = append(all, page...) cursor = page[len(page)-1].Sequence } - if all[0].Event.Type != "agent.session.turn.created" || all[0].Turn.Status != store.TurnQueued || all[len(all)-1].Event.Type != "agent.session.idle" || all[len(all)-1].Turn.Status != store.TurnCancelled { + if all[0].Event.Type != "agent.session.turn.created" || all[0].Turn.Status != sessions.TurnQueued || all[len(all)-1].Event.Type != "agent.session.idle" || all[len(all)-1].Turn.Status != sessions.TurnCancelled { t.Fatalf("transition snapshots changed: %+v", all) } counts := map[string]int{} diff --git a/services/core/internal/store/session_initial_input.go b/services/core/internal/store/session_initial_input.go index 680db13ec..0d344acc8 100644 --- a/services/core/internal/store/session_initial_input.go +++ b/services/core/internal/store/session_initial_input.go @@ -13,6 +13,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" ) @@ -143,7 +144,7 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param } } } - if err := q.PruneSessionEvents(ctx, row.ID); err != nil { + if err := sessionpg.PruneChanges(ctx, q, row.ID); err != nil { return err } return audit() diff --git a/services/core/internal/store/session_initial_input_test.go b/services/core/internal/store/session_initial_input_test.go index b4b48935a..887c3deb5 100644 --- a/services/core/internal/store/session_initial_input_test.go +++ b/services/core/internal/store/session_initial_input_test.go @@ -8,6 +8,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -63,8 +64,8 @@ func TestInitialInputCreationRetriesAcrossConnectionsAndLaterTurns(t *testing.T) t.Fatal("changed initial input accepted", err) } } - transition(t, s, tenant, first.ID, first.LastTurn.ID, TurnQueued, TurnInProgress) - transition(t, s, tenant, first.ID, first.LastTurn.ID, TurnInProgress, TurnCompleted) + transition(t, s, tenant, first.ID, first.LastTurn.ID, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, s, tenant, first.ID, first.LastTurn.ID, sessions.TurnInProgress, sessions.TurnCompleted) // The same caller key at the events endpoint is an independent request. next, err := s.SubmitInputs(ctx, tenant, first.ID, input.IdempotencyKey, []Input{messageInput("later")}) if err != nil || len(next) != 1 || next[0].TurnID == first.LastTurn.ID { @@ -83,7 +84,7 @@ func TestInitialInputCreationRetriesAcrossConnectionsAndLaterTurns(t *testing.T) pool.Close() restarted, _ := testStore(t) retry, err := restarted.CreateSession(ctx, tenant, input) - if err != nil || retry.ID != first.ID || retry.LastTurn.ID != next[0].TurnID || retry.LastTurn.Status != TurnCancelled || retry.Metadata["updated"] != "yes" { + if err != nil || retry.ID != first.ID || retry.LastTurn.ID != next[0].TurnID || retry.LastTurn.Status != sessions.TurnCancelled || retry.Metadata["updated"] != "yes" { t.Fatal(retry, err) } after, err := restarted.ListSessionEvents(ctx, tenant, first.ID, 0) diff --git a/services/core/internal/store/session_metadata_test.go b/services/core/internal/store/session_metadata_test.go index 3cc1b51d9..9235ffb66 100644 --- a/services/core/internal/store/session_metadata_test.go +++ b/services/core/internal/store/session_metadata_test.go @@ -9,6 +9,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -112,15 +113,15 @@ func TestSessionMetadataPreservesTerminalActivity(t *testing.T) { if err != nil { t.Fatal(err) } - for _, status := range []string{TurnCompleted, TurnFailed, TurnCancelled} { + for _, status := range []string{sessions.TurnCompleted, sessions.TurnFailed, sessions.TurnCancelled} { receipt, err := s.SubmitMessage(ctx, tenant, session.ID, uuid.NewString(), []byte(`{"text":"metadata fixture"}`)) if err != nil { t.Fatal(err) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); err != nil { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: status}); err != nil { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: status}); err != nil { t.Fatal(err) } before, err := s.GetSession(ctx, tenant, session.ID) diff --git a/services/core/internal/store/session_transaction.go b/services/core/internal/store/session_transaction.go index 8e68fbd60..db8d1d53b 100644 --- a/services/core/internal/store/session_transaction.go +++ b/services/core/internal/store/session_transaction.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" ) // All Turn admission and lifecycle writes lock the tenant-scoped Session first. @@ -55,6 +56,6 @@ func (s *Store) withLockedSession(ctx context.Context, tenantID, sessionID strin if err := apply(ctx, q, session); err != nil { return err } - return q.PruneSessionEvents(ctx, id) + return sessionpg.PruneChanges(ctx, q, id) }) } diff --git a/services/core/internal/store/sessions.go b/services/core/internal/store/sessions.go index 0dfdfb9a5..af0f8a0f5 100644 --- a/services/core/internal/store/sessions.go +++ b/services/core/internal/store/sessions.go @@ -26,6 +26,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) var ( @@ -46,14 +47,14 @@ type Session struct { Metadata map[string]string CreatedAt time.Time Configuration json.RawMessage - LastTurn *Turn + LastTurn *sessions.Turn Usage json.RawMessage RequiredActions []v1.FunctionCallAction Environment *Environment - EnvironmentInputActivity *EnvironmentInputActivity + EnvironmentInputActivity *sessions.EnvironmentInputActivity // EnvironmentFailure is the recorded provisioning failure of a failed hosted // Environment. It makes the Session failed and is terminal. - EnvironmentFailure *EnvironmentFailure + EnvironmentFailure *sessions.EnvironmentFailure // PendingInput reports that the latest input reservation, read once no Turn // is active or newer, can still start a Turn. It only supports settlement // checks and is never rendered. diff --git a/services/core/internal/store/steering_receipts_test.go b/services/core/internal/store/steering_receipts_test.go index b31ccd39e..beff34513 100644 --- a/services/core/internal/store/steering_receipts_test.go +++ b/services/core/internal/store/steering_receipts_test.go @@ -8,7 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) func TestExecutionDurableInputReceiptLifetime(t *testing.T) { @@ -26,7 +26,7 @@ func TestExecutionDurableInputReceiptLifetime(t *testing.T) { t.Fatal("durable receipt was not requested", err) } h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Written: true}) - status := store.TurnFailed + status := sessions.TurnFailed switch mode { case "delayed-acceptance": select { @@ -36,7 +36,7 @@ func TestExecutionDurableInputReceiptLifetime(t *testing.T) { } h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Accepted: true}) h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "completed"}) - status = store.TurnCompleted + status = sessions.TurnCompleted case "missing-at-done": h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "unconfirmed input"}) case "retry-after-write": @@ -56,7 +56,7 @@ func TestExecutionDurableInputReceiptLifetime(t *testing.T) { case <-time.After(300 * time.Millisecond): } h.write(first.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Content: "partial"}}) - status = store.TurnCancelled + status = sessions.TurnCancelled } done := h.finished(result, status) var outcome execution.Result diff --git a/services/core/internal/store/structured_output_dispatch_test.go b/services/core/internal/store/structured_output_dispatch_test.go index 86a1ee10a..6a8362fde 100644 --- a/services/core/internal/store/structured_output_dispatch_test.go +++ b/services/core/internal/store/structured_output_dispatch_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine/enginetest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -54,7 +55,7 @@ func TestStructuredOutputDispatchRechecksOperationQualification(t *testing.T) { t.Fatal("unqualified structured output dispatched") } turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal("unqualified work was claimed", turn.Status, err) } } diff --git a/services/core/internal/store/subagent_coordination.go b/services/core/internal/store/subagent_coordination.go index 3db45b1d6..6edd9fd9e 100644 --- a/services/core/internal/store/subagent_coordination.go +++ b/services/core/internal/store/subagent_coordination.go @@ -9,6 +9,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -83,28 +85,18 @@ func projectRootCoordination(ctx context.Context, q *sqlc.Queries, session, turn if err != nil { return err } - id, _ := parseID(value.ID) - old, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return err - } - var previous v1.Item - if err == nil { - if err = json.Unmarshal(old.Payload, &previous); err != nil { - return err - } - } - merged, err := items.Merge(items.Update{Item: value}, previous) + update := items.Update{Item: value} + stored, err := sessionpg.LoadItem(ctx, q, session, turn, update) if err != nil { return err } - payload, err := merged.MarshalStored() - if err != nil { + change, ok, err := items.Observe(proto.TypeSubagentCoordination, update, stored) + if err != nil || !ok { return err } - row, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: true}) + index, err := sessionpg.PutItem(ctx, q, session, turn, created, change) if err != nil { return err } - return recordItemChange(ctx, q, session, row.OutputIndex, previous, merged, nil) + return sessionpg.AppendChanges(ctx, q, session, sessions.ItemChanges(change, index)...) } diff --git a/services/core/internal/store/subagent_dispatch_test.go b/services/core/internal/store/subagent_dispatch_test.go index a2cc7555d..87c8018b6 100644 --- a/services/core/internal/store/subagent_dispatch_test.go +++ b/services/core/internal/store/subagent_dispatch_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -36,22 +37,22 @@ func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { identity := proto.SubagentIdentityPayload{NativeID: "child", ParentNativeID: "root", NativeCreatedAt: 100, ParentTurnID: "native-turn", SourceItemID: "spawn-item"} h.write(input.TurnID, proto.TypeSubagentIdentity, identity) if !enabled { - h.finished(running, store.TurnFailed) + h.finished(running, sessions.TurnFailed) if _, err = h.s.GetSubagentIdentity(ctx, h.tenant, h.session.ID, "child"); !errors.Is(err, store.ErrNotFound) { t.Fatal("unsolicited identity committed", err) } return } h.write(input.TurnID, proto.TypeSubagentIdentity, identity) - childTurn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: store.TurnInProgress, CreatedAtMS: 100000} + childTurn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: sessions.TurnInProgress, CreatedAtMS: 100000} h.write(input.TurnID, proto.TypeSubagentTurn, childTurn) h.write(input.TurnID, proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "child-turn", ItemID: "answer", Position: 0, Kind: proto.TypeOutputMessage, Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"child answer"}`)}) completed := int64(101000) - childTurn.Status, childTurn.CompletedAtMS = store.TurnCompleted, &completed + childTurn.Status, childTurn.CompletedAtMS = sessions.TurnCompleted, &completed h.write(input.TurnID, proto.TypeSubagentTurn, childTurn) h.write(input.TurnID, proto.TypeSubagentLifecycle, proto.SubagentLifecyclePayload{NativeID: "child", EffectID: "native-close", Status: "closed", OccurredAtMS: 102000}) h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "root result", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "root"}}) - h.finished(running, store.TurnCompleted) + h.finished(running, sessions.TurnCompleted) saved, err := h.s.GetSubagentIdentity(ctx, h.tenant, h.session.ID, "child") if err != nil || saved.NativeID != "child" || saved.ParentNativeID != "root" || saved.FirstTurnID != input.TurnID { t.Fatal(saved, err) diff --git a/services/core/internal/store/subagent_identities_test.go b/services/core/internal/store/subagent_identities_test.go index 6eac9f0dc..a6d5ce057 100644 --- a/services/core/internal/store/subagent_identities_test.go +++ b/services/core/internal/store/subagent_identities_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -31,7 +32,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { t.Fatal(err) } input := submitMessage(t, s, tenant, session.ID, "first") - transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) a, b := subagentIdentityEvent("child-a", "root", 102), subagentIdentityEvent("child-b", "root", 101) batch := []ExecutionEvent{a, b, a} if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); !errors.Is(err, ErrExecutionAuthority) { @@ -96,7 +97,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { t.Fatal(err) } foreignInput := submitMessage(t, s, tenant, foreign.ID, "first") - transition(t, w, tenant, foreign.ID, foreignInput.TurnID, TurnQueued, TurnInProgress) + transition(t, w, tenant, foreign.ID, foreignInput.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if err = w.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []ExecutionEvent{a}); !errors.Is(err, ErrIdempotencyConflict) { t.Fatal("same device/native child reassigned to another Session", err) } @@ -106,7 +107,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { if err = w.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []ExecutionEvent{subagentIdentityEvent("other-child", "root", 101)}); !errors.Is(err, ErrIdempotencyConflict) { t.Fatal("known root binding ignored", err) } - if _, err = w.CompleteExecution(ctx, tenant, session.ID, input.TurnID, TurnCompleted, json.RawMessage(`{}`), "root", input.Sequence); err != nil { + if _, err = w.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{}`), "root", input.Sequence); err != nil { t.Fatal(err) } if err = w.lease.Close(ctx); err != nil { @@ -119,7 +120,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { t.Fatal("restart changed identity", again, err) } second := submitMessage(t, reopened, tenant, session.ID, "second") - transition(t, nextOwner, tenant, session.ID, second.TurnID, TurnQueued, TurnInProgress) + transition(t, nextOwner, tenant, session.ID, second.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if err = w.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, []ExecutionEvent{a}); err == nil { t.Fatal("closed owner wrote identity") } @@ -148,7 +149,7 @@ func TestSubagentIdentityRejectsLostLease(t *testing.T) { old := executionWriter(t, s) tenant, session := newSubagentSession(t, s) input := submitMessage(t, s, tenant, session.ID, "first") - transition(t, old, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, old, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) var killed bool if err := pool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1, 1000)", executionOwnerPID(t, pool)).Scan(&killed); err != nil || !killed { t.Fatal(killed, err) diff --git a/services/core/internal/store/subagent_item_projection.go b/services/core/internal/store/subagent_item_projection.go index f228b7202..643830866 100644 --- a/services/core/internal/store/subagent_item_projection.go +++ b/services/core/internal/store/subagent_item_projection.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -76,7 +77,7 @@ func putChildItem(ctx context.Context, q *sqlc.Queries, session, childID pgtype. return ErrIdempotencyConflict } } - if terminalStatus(turn.Status) { + if sessions.TerminalStatus(turn.Status) { return ErrTurnConflict } // Child Items publish no Session events: the Session stream carries root work, diff --git a/services/core/internal/store/subagent_lifecycle.go b/services/core/internal/store/subagent_lifecycle.go index 13273f068..87bd2fa72 100644 --- a/services/core/internal/store/subagent_lifecycle.go +++ b/services/core/internal/store/subagent_lifecycle.go @@ -9,6 +9,8 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -22,7 +24,7 @@ func recordSubagentChange(ctx context.Context, q *sqlc.Queries, session, id pgty if err != nil { return err } - return recordSessionChange(ctx, q, session, SessionChange{Event: v1.SessionEvent{Type: "agent.session.subagent." + kind, Subagent: &value}}) + return sessionpg.AppendChanges(ctx, q, session, sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.subagent." + kind, Subagent: &value}}) } func publishSubagent(ctx context.Context, q *sqlc.Queries, session, id pgtype.UUID, identity proto.SubagentIdentityPayload) error { previous, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: identity.NativeID}) diff --git a/services/core/internal/store/subagent_native_outputs_test.go b/services/core/internal/store/subagent_native_outputs_test.go index f2c0cb6c0..07f28b0fd 100644 --- a/services/core/internal/store/subagent_native_outputs_test.go +++ b/services/core/internal/store/subagent_native_outputs_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -23,13 +24,13 @@ func TestSubagentNativeFunctionResultDoesNotConsumeOutputIndex(t *testing.T) { t.Fatal(err) } input := submitMessage(t, s, tenant, session.ID, "start") - transition(t, owner, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, owner, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) call := json.RawMessage(`{"id":"native-file-change","stage":"after","observation":{"status":"completed","kind":"function","name":"apply_patch","arguments":{"count":9007199254740993,"scale":1e2},"content":[{"type":"input_text","text":"file written"}]}}`) text := "child answer" message, _ := json.Marshal(proto.OutputMessagePayload{ID: "answer", Status: "completed", Text: &text}) facts := []ExecutionEvent{ subagentIdentityEvent("child", "root", 100), - subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "turn", Status: TurnInProgress, CreatedAtMS: 100000}), + subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "turn", Status: sessions.TurnInProgress, CreatedAtMS: 100000}), subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "turn", ItemID: "native-file-change", Position: 0, Kind: proto.TypeToolCall, Payload: call}), subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "turn", ItemID: "answer", Position: 1, Kind: proto.TypeOutputMessage, Payload: message}), } @@ -37,7 +38,7 @@ func TestSubagentNativeFunctionResultDoesNotConsumeOutputIndex(t *testing.T) { t.Fatal(err) } finished := int64(101000) - terminal := subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "turn", Status: TurnCompleted, CreatedAtMS: 100000, CompletedAtMS: &finished}) + terminal := subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "turn", Status: sessions.TurnCompleted, CreatedAtMS: 100000, CompletedAtMS: &finished}) if err = owner.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 5, []ExecutionEvent{terminal, facts[2], facts[3]}); err != nil { t.Fatal("identical native tool history must survive replay after completion", err) } @@ -97,16 +98,16 @@ func TestSubagentCancelledPartialMessageSurvivesHistoryReplay(t *testing.T) { t.Fatal(err) } input := submitMessage(t, s, tenant, session.ID, "start") - transition(t, owner, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, owner, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) message := subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{ NativeID: "child", TurnID: "child-turn", ItemID: "partial", Kind: proto.TypeOutputMessage, Payload: json.RawMessage(`{"id":"partial","status":"incomplete","text":"Partial native answer"}`), }) finished := int64(101000) - terminal := subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: TurnCancelled, CreatedAtMS: 100000, CompletedAtMS: &finished}) + terminal := subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: sessions.TurnCancelled, CreatedAtMS: 100000, CompletedAtMS: &finished}) facts := []ExecutionEvent{ subagentIdentityEvent("child", "root", 100), - subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: TurnInProgress, CreatedAtMS: 100000}), + subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: sessions.TurnInProgress, CreatedAtMS: 100000}), message, terminal, // A cold history read must preserve the partial answer without re-execution. message, terminal, @@ -123,18 +124,18 @@ func TestSubagentCancelledPartialMessageSurvivesHistoryReplay(t *testing.T) { t.Fatal(items, err) } turns, err := s.ListSubagentTurns(t.Context(), tenant, session.ID, child.ID, "", 20, true) - if err != nil || len(turns.Data) != 1 || turns.Data[0].Status != TurnCancelled { + if err != nil || len(turns.Data) != 1 || turns.Data[0].Status != sessions.TurnCancelled { t.Fatal(turns, err) } } func TestSubagentRootCompletionRetainsNativeSourceTime(t *testing.T) { - for _, status := range []string{TurnCompleted, TurnCancelled} { + for _, status := range []string{sessions.TurnCompleted, sessions.TurnCancelled} { t.Run(status, func(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) input := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) current, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) if err != nil { t.Fatal(err) @@ -145,10 +146,10 @@ func TestSubagentRootCompletionRetainsNativeSourceTime(t *testing.T) { if err != nil { t.Fatal(err) } - if status == TurnCompleted && !completed.CompletedAt.Equal(time.UnixMilli(source)) { + if status == sessions.TurnCompleted && !completed.CompletedAt.Equal(time.UnixMilli(source)) { t.Fatal("child drain changed root source completion", completed.CompletedAt) } - if status == TurnCancelled && !completed.CompletedAt.After(time.UnixMilli(source)) { + if status == sessions.TurnCancelled && !completed.CompletedAt.After(time.UnixMilli(source)) { t.Fatal("cancellation reused native success timestamp", completed.CompletedAt) } }) diff --git a/services/core/internal/store/subagent_reads.go b/services/core/internal/store/subagent_reads.go index 4934b28a5..1e4af963c 100644 --- a/services/core/internal/store/subagent_reads.go +++ b/services/core/internal/store/subagent_reads.go @@ -8,6 +8,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -115,7 +116,7 @@ func publicChildTurn(row sqlc.SubagentTurn, agent string) v1.Turn { value.CompletedAt = &seconds } _ = json.Unmarshal(row.TokenUsage, &value.Usage) - if row.Status == TurnFailed { + if row.Status == sessions.TurnFailed { value.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} } return value diff --git a/services/core/internal/store/subagent_resources_test.go b/services/core/internal/store/subagent_resources_test.go index e1e5aaf62..b78b47ecf 100644 --- a/services/core/internal/store/subagent_resources_test.go +++ b/services/core/internal/store/subagent_resources_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -39,7 +40,7 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { t.Fatal(err) } root := submitMessage(t, s, tenant, session.ID, "first") - transition(t, owner, tenant, session.ID, root.TurnID, TurnQueued, TurnInProgress) + transition(t, owner, tenant, session.ID, root.TurnID, sessions.TurnQueued, sessions.TurnInProgress) ordinal := int32(1) appendFacts := func(facts ...ExecutionEvent) { t.Helper() @@ -64,13 +65,13 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { } opened := int64(101000) finished := int64(102000) - turn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn-1", Status: TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened} + turn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn-1", Status: sessions.TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened} appendFacts(subagentFact(proto.TypeSubagentTurn, turn)) text := "child-owned-result" message, _ := json.Marshal(proto.OutputMessagePayload{ID: "native-message", Status: "completed", Text: &text}) item := proto.SubagentItemPayload{NativeID: "child", TurnID: turn.TurnID, ItemID: "native-message", Position: 0, Kind: proto.TypeOutputMessage, Payload: message} appendFacts(subagentFact(proto.TypeSubagentItem, item)) - turn.Status = TurnCompleted + turn.Status = sessions.TurnCompleted turn.CompletedAtMS = &finished appendFacts(subagentFact(proto.TypeSubagentTurn, turn)) // Later history reads preserve the terminal Turn and all existing Items. @@ -99,7 +100,7 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { t.Fatal(allTurns, err) } // Nested children follow the same agent_id rule (unobserved officially). - nestedTurn := proto.SubagentTurnPayload{NativeID: "nested", TurnID: "native-nested-turn", Status: TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened} + nestedTurn := proto.SubagentTurnPayload{NativeID: "nested", TurnID: "native-nested-turn", Status: sessions.TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened} appendFacts(subagentFact(proto.TypeSubagentTurn, nestedTurn)) nestedTurns, err := s.ListSubagentTurns(ctx, tenant, session.ID, nested.ID, "", 20, true) if err != nil || len(nestedTurns.Data) != 1 || nestedTurns.Data[0].AgentID != "agent_root" || *nestedTurns.Data[0].SubagentID != nested.ID { @@ -184,7 +185,7 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { t.Fatal("non-atomic batch", err) } // Reads do not invoke native processes, including after the root finishes. - transition(t, owner, tenant, session.ID, root.TurnID, TurnInProgress, TurnCompleted) + transition(t, owner, tenant, session.ID, root.TurnID, sessions.TurnInProgress, sessions.TurnCompleted) if _, err = reopened.GetSubagentTurn(context.Background(), tenant, session.ID, child.ID, tid); err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/subagent_turn_projection.go b/services/core/internal/store/subagent_turn_projection.go index cedd2162c..19622119c 100644 --- a/services/core/internal/store/subagent_turn_projection.go +++ b/services/core/internal/store/subagent_turn_projection.go @@ -11,6 +11,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -27,10 +29,10 @@ func projectSubagentTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UU if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || p.CreatedAtMS <= 0 { return ErrInvalidInput } - if p.Status != TurnQueued && p.Status != TurnInProgress && p.Status != TurnWaiting && !terminalStatus(p.Status) { + if p.Status != sessions.TurnQueued && p.Status != sessions.TurnInProgress && p.Status != sessions.TurnWaiting && !sessions.TerminalStatus(p.Status) { return ErrInvalidInput } - if terminalStatus(p.Status) != (p.CompletedAtMS != nil) { + if sessions.TerminalStatus(p.Status) != (p.CompletedAtMS != nil) { return ErrInvalidInput } if (p.StartedAtMS != nil && *p.StartedAtMS < p.CreatedAtMS) || (p.CompletedAtMS != nil && (*p.CompletedAtMS < p.CreatedAtMS || (p.StartedAtMS != nil && *p.CompletedAtMS < *p.StartedAtMS))) { @@ -52,15 +54,15 @@ func projectSubagentTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UU usage := []byte(nil) if p.Usage != nil { value, _ := json.Marshal(p.Usage) - if measured := measuredUsage("usage", value); measured != nil { + if measured := sessions.MeasuredUsage("usage", value); measured != nil { usage, _ = json.Marshal(measured) } else { return ErrInvalidInput } } // Re-reading native history cannot reopen or mutate a completed child Turn. - if !fresh && terminalStatus(old.Status) { - if !terminalStatus(p.Status) { + if !fresh && sessions.TerminalStatus(old.Status) { + if !sessions.TerminalStatus(p.Status) { return nil } var previousUsage, nextUsage *v1.TokenUsage @@ -84,8 +86,8 @@ func projectSubagentTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UU // Terminal replays returned above; they must not restart the managed idle timer. // Child Turns publish no Session events: the Session stream carries root work, // and child state is read through the Subagent routes. - if terminalStatus(row.Status) { - return q.RecordRuntimeTerminalActivity(ctx, session) + if sessions.TerminalStatus(row.Status) { + return sessionpg.RecordTerminalActivity(ctx, q, session) } return nil } diff --git a/services/core/internal/store/subagent_visibility_public_test.go b/services/core/internal/store/subagent_visibility_public_test.go index d77da801f..a253c3210 100644 --- a/services/core/internal/store/subagent_visibility_public_test.go +++ b/services/core/internal/store/subagent_visibility_public_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -126,7 +127,7 @@ func TestSubagentVisibilityPublic(t *testing.T) { if err = writer.BindSessionDevice(ctx, tenant, session, host.ID); err != nil { t.Fatal(err) } - if _, err = writer.TransitionTurn(ctx, tenant, session, root, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = writer.TransitionTurn(ctx, tenant, session, root, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } identity := func(child, parent string, created int64) store.ExecutionEvent { @@ -141,16 +142,16 @@ func TestSubagentVisibilityPublic(t *testing.T) { facts := []store.ExecutionEvent{ identity("child", "root", 1700000001), identity("nested", "child", 1700000001), subagentFixture(proto.TypeSubagentCoordination, proto.SubagentCoordinationPayload{ID: "spawn", Kind: "create_subagent_call", Status: "completed"}), - subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: store.TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened}), + subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: sessions.TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened}), message("child", "child-turn", "first", 0), message("child", "child-turn", "second", 1), - subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: store.TurnCompleted, CreatedAtMS: opened, StartedAtMS: &opened, CompletedAtMS: &finished}), - subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "nested", TurnID: "nested-turn", Status: store.TurnCompleted, CreatedAtMS: opened, StartedAtMS: &opened, CompletedAtMS: &finished}), + subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: sessions.TurnCompleted, CreatedAtMS: opened, StartedAtMS: &opened, CompletedAtMS: &finished}), + subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "nested", TurnID: "nested-turn", Status: sessions.TurnCompleted, CreatedAtMS: opened, StartedAtMS: &opened, CompletedAtMS: &finished}), subagentFixture(proto.TypeSubagentCoordination, proto.SubagentCoordinationPayload{ID: "wait", Kind: "wait_for_subagents_call", Status: "completed", Recipients: []string{"child"}}), } if err = writer.AppendTurnEvents(ctx, tenant, session, root, 1, facts); err != nil { t.Fatal(err) } - if _, err = writer.TransitionTurn(ctx, tenant, session, root, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnCompleted}); err != nil { + if _, err = writer.TransitionTurn(ctx, tenant, session, root, store.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnCompleted}); err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/token_usage.go b/services/core/internal/store/token_usage.go index c0c5b9613..340bdd05a 100644 --- a/services/core/internal/store/token_usage.go +++ b/services/core/internal/store/token_usage.go @@ -4,12 +4,12 @@ import ( "context" "encoding/json" "fmt" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/jackc/pgx/v5/pgtype" - "math" - "strings" ) // MeasuredSessionUsage returns Core-internal measured usage for Runtime @@ -47,65 +47,10 @@ func projectSource(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UU case proto.TypeSubagentCoordination: return projectRootCoordination(ctx, q, session, turn, raw, created) } - if usage := measuredUsage(kind, raw); usage != nil { - payload, err := json.Marshal(usage) - if err != nil { - return err - } - if err = q.PutTurnUsage(ctx, sqlc.PutTurnUsageParams{SessionID: session, ID: turn, TokenUsage: payload}); err != nil { + if usage := sessions.MeasuredUsage(kind, raw); usage != nil { + if err := sessionpg.PutTurnUsage(ctx, q, session, turn, *usage); err != nil { return err } } return projectItemSource(ctx, q, session, turn, kind, sequence, raw, created) } - -func measuredUsage(kind string, raw json.RawMessage) *v1.TokenUsage { - var object map[string]json.RawMessage - if json.Unmarshal(raw, &object) != nil { - return nil - } - if kind == "cancel_receipt" { - var applied bool - if json.Unmarshal(object["applied"], &applied) != nil || !applied { - return nil - } - raw = object["outcome"] - object = nil - if json.Unmarshal(raw, &object) != nil { - return nil - } - kind = "done" - } - if strings.HasPrefix(kind, "execution_") { - raw = object["done"] - object = nil - if json.Unmarshal(raw, &object) != nil { - return nil - } - kind = "done" - } - if kind == "done" { - raw = object["usage"] - object = nil - if json.Unmarshal(raw, &object) != nil { - return nil - } - } else if kind != "usage" { - return nil - } - - var tokens map[string]*int64 - if json.Unmarshal(object["tokens"], &tokens) != nil { - return nil - } - for _, key := range []string{"input_tokens", "output_tokens", "cached_input_tokens", "reasoning_output_tokens", "total_tokens"} { - if tokens[key] == nil || *tokens[key] < 0 { - return nil - } - } - input, output, cached, reasoning, total := *tokens["input_tokens"], *tokens["output_tokens"], *tokens["cached_input_tokens"], *tokens["reasoning_output_tokens"], *tokens["total_tokens"] - if cached > input || reasoning > output || input > math.MaxInt64-output || total != input+output { - return nil - } - return &v1.TokenUsage{InputTokens: input, OutputTokens: output, TotalTokens: total, InputTokensDetails: v1.InputTokenDetails{CachedTokens: cached}, OutputTokensDetails: v1.OutputTokenDetails{ReasoningTokens: reasoning}} -} diff --git a/services/core/internal/store/token_usage_integration_test.go b/services/core/internal/store/token_usage_integration_test.go index 7b41e40a0..d2c941ec9 100644 --- a/services/core/internal/store/token_usage_integration_test.go +++ b/services/core/internal/store/token_usage_integration_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -30,12 +31,12 @@ func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { t.Fatalf("unexpected usage: %s", raw) } } - for n, status := range []string{store.TurnFailed, store.TurnCancelled} { + for n, status := range []string{sessions.TurnFailed, sessions.TurnCancelled} { admission, err := s.SubmitMessage(ctx, tenant, session.ID, fmt.Sprint(n), json.RawMessage(`{"text":"measure"}`)) if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -54,11 +55,11 @@ func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { t.Fatal(err) } check(measured.Usage, 20) - if _, err = s.CompleteExecution(ctx, tenant, session.ID, admission.TurnID, store.TurnCompleted, json.RawMessage(`{"done":{"usage":`+string(usage(99))+`}}`), "missing-binding", admission.Sequence); !errors.Is(err, store.ErrNotFound) { + if _, err = s.CompleteExecution(ctx, tenant, session.ID, admission.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{"usage":`+string(usage(99))+`}}`), "missing-binding", admission.Sequence); !errors.Is(err, store.ErrNotFound) { t.Fatal(err) } rolledBack, err := s.GetTurn(ctx, tenant, session.ID, admission.TurnID) - if err != nil || rolledBack.Status != store.TurnInProgress { + if err != nil || rolledBack.Status != sessions.TurnInProgress { t.Fatalf("rollback: %+v %v", rolledBack, err) } check(rolledBack.Usage, 20) @@ -114,7 +115,7 @@ func TestCancellationReceiptUsageSurvivesRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -123,7 +124,7 @@ func TestCancellationReceiptUsageSurvivesRecovery(t *testing.T) { t.Fatal(err) } // Startup recovery has no in-memory cancellation outcome. - recovered, err := s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_interrupted"}`)}) + recovered, err := s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_interrupted"}`)}) if err != nil { t.Fatal(err) } @@ -213,14 +214,14 @@ func TestSessionUsageRequiresEveryRootTurnEndedAndMeasured(t *testing.T) { measured(-1) first := submit("first") total(-1) - move(first.TurnID, store.TurnQueued, store.TurnInProgress) + move(first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if err = s.AppendTurnEvents(ctx, tenant, session.ID, first.TurnID, 1, usage(10)); err != nil { t.Fatal(err) } // An active Turn's recorded snapshot does not count yet. total(-1) measured(13) - finish(first, store.TurnCompleted) + finish(first, sessions.TurnCompleted) total(13) if idle := lastIdleUsage(); idle == nil { t.Fatal("settled Session snapshot lost the known total") @@ -228,33 +229,33 @@ func TestSessionUsageRequiresEveryRootTurnEndedAndMeasured(t *testing.T) { // A queued, active or waiting Turn hides the known terminal totals. second := submit("second") total(-1) - move(second.TurnID, store.TurnQueued, store.TurnInProgress) + move(second.TurnID, sessions.TurnQueued, sessions.TurnInProgress) total(-1) if err = s.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, usage(20)); err != nil { t.Fatal(err) } total(-1) - move(second.TurnID, store.TurnInProgress, store.TurnWaiting) + move(second.TurnID, sessions.TurnInProgress, sessions.TurnWaiting) total(-1) measured(36) - move(second.TurnID, store.TurnWaiting, store.TurnInProgress) - finish(second, store.TurnCancelled) + move(second.TurnID, sessions.TurnWaiting, sessions.TurnInProgress) + finish(second, sessions.TurnCancelled) total(36) // A Turn that ends without usage makes the total unknown for good. third := submit("third") - move(third.TurnID, store.TurnQueued, store.TurnInProgress) - finish(third, store.TurnCancelled) + move(third.TurnID, sessions.TurnQueued, sessions.TurnInProgress) + finish(third, sessions.TurnCancelled) total(-1) measured(36) if idle := lastIdleUsage(); idle != nil && string(idle) != "null" { t.Fatalf("settled Session snapshot usage: %s", idle) } fourth := submit("fourth") - move(fourth.TurnID, store.TurnQueued, store.TurnInProgress) + move(fourth.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if err = s.AppendTurnEvents(ctx, tenant, session.ID, fourth.TurnID, 1, usage(30)); err != nil { t.Fatal(err) } - finish(fourth, store.TurnCompleted) + finish(fourth, sessions.TurnCompleted) total(-1) measured(69) } diff --git a/services/core/internal/store/turn_completion.go b/services/core/internal/store/turn_completion.go index 3c188f0ce..7281d984e 100644 --- a/services/core/internal/store/turn_completion.go +++ b/services/core/internal/store/turn_completion.go @@ -9,6 +9,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -16,17 +18,17 @@ import ( var ErrUnappliedInputs = errors.New("turn has messages without an executor receipt") // CompleteExecution commits the outcome and native continuity under the admission lock. -func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turnID, status string, outcome json.RawMessage, nativeID string, appliedThrough int64) (Turn, error) { +func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turnID, status string, outcome json.RawMessage, nativeID string, appliedThrough int64) (sessions.Turn, error) { p, err := turnLookup(tenantID, sessionID, turnID) if err != nil { - return Turn{}, err + return sessions.Turn{}, err } - if !terminalStatus(status) || len(outcome) > 512*1024 || len(nativeID) > 512 || appliedThrough < 0 { - return Turn{}, ErrInvalidInput + if !sessions.TerminalStatus(status) || len(outcome) > 512*1024 || len(nativeID) > 512 || appliedThrough < 0 { + return sessions.Turn{}, ErrInvalidInput } outcome, err = jsonobject.Normalize(outcome) if err != nil { - return Turn{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) + return sessions.Turn{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } var row sqlc.Turn err = s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { @@ -36,10 +38,10 @@ func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turn } else if err != nil { return err } - if current.Status != TurnInProgress && (current.Status != TurnWaiting || status == TurnCompleted) { + if current.Status != sessions.TurnInProgress && (current.Status != sessions.TurnWaiting || status == sessions.TurnCompleted) { return ErrTurnConflict } - if status == TurnCompleted { + if status == sessions.TurnCompleted { pending, err := q.HasUnappliedMessages(ctx, sqlc.HasUnappliedMessagesParams{SessionID: session, TurnID: p.ID, Sequence: appliedThrough}) if err != nil { return err @@ -49,7 +51,7 @@ func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turn } } sourceCompleted := pgtype.Timestamptz{} - if status == TurnCompleted { + if status == sessions.TurnCompleted { var snapshot struct { Done *struct { SourceCompletedAtMS *int64 `json:"source_completed_at_ms"` @@ -91,10 +93,14 @@ func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turn if err != nil { return err } - return recordTurnChange(ctx, q, row, false) + ending, err := sessionpg.LoadEnding(ctx, q, session, row.ID) + if err != nil { + return err + } + return sessionpg.ApplyTurnEnd(ctx, q, session, row.ID, sessions.EndTurn(turnFromRow(row), ending)) }) if err != nil { - return Turn{}, err + return sessions.Turn{}, err } return turnFromRow(row), nil } diff --git a/services/core/internal/store/turn_events.go b/services/core/internal/store/turn_events.go index ec262729d..d3d355d37 100644 --- a/services/core/internal/store/turn_events.go +++ b/services/core/internal/store/turn_events.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -82,7 +83,7 @@ func (s *Store) AppendTurnEvents(ctx context.Context, tenantID, sessionID, turnI } return nil } - if (turn.Status != TurnInProgress && turn.Status != TurnWaiting) || first != turn.EventCount+1 { + if (turn.Status != sessions.TurnInProgress && turn.Status != sessions.TurnWaiting) || first != turn.EventCount+1 { return ErrTurnConflict } if turn.EventCount+int32(len(events)) > 65536 || turn.EventBytes+int64(payloadBytes) > 32*1024*1024 { diff --git a/services/core/internal/store/turn_events_test.go b/services/core/internal/store/turn_events_test.go index e30a576ff..505ed61f9 100644 --- a/services/core/internal/store/turn_events_test.go +++ b/services/core/internal/store/turn_events_test.go @@ -7,6 +7,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -23,7 +24,7 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -61,14 +62,14 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { t.Fatal(err) } // A failed native binding write must roll back both the terminal event and status. - if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCompleted, json.RawMessage(`{}`), "missing-binding", input.Sequence); !errors.Is(err, store.ErrNotFound) { + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{}`), "missing-binding", input.Sequence); !errors.Is(err, store.ErrNotFound) { t.Fatal(err) } events, _ := s.ListTurnEvents(ctx, tenant, session.ID, input.TurnID, 0, 100) if len(events) != 2 { t.Fatal("terminal event survived rollback") } - if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{"done":{"content":""}}`), "", input.Sequence); err != nil { + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{"done":{"content":""}}`), "", input.Sequence); err != nil { t.Fatal(err) } if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { @@ -93,7 +94,7 @@ func TestEventLimitStillAllowsTerminalFailure(t *testing.T) { h := newDispatchHarness(t) ctx := context.Background() input := h.message("start", "Test output budget") - _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err != nil { t.Fatal(err) } @@ -106,7 +107,7 @@ func TestEventLimitStillAllowsTerminalFailure(t *testing.T) { if err = h.s.AppendTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 1, events); !errors.Is(err, store.ErrEventLimit) { t.Fatal(err) } - if _, err = h.s.CompleteExecution(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnFailed, json.RawMessage(`{"error_code":"event_limit"}`), "", input.Sequence); err != nil { + if _, err = h.s.CompleteExecution(ctx, h.tenant, h.session.ID, input.TurnID, sessions.TurnFailed, json.RawMessage(`{"error_code":"event_limit"}`), "", input.Sequence); err != nil { t.Fatal(err) } got, err := h.s.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) diff --git a/services/core/internal/store/turn_inputs.go b/services/core/internal/store/turn_inputs.go index c9628dc0d..697c42cd1 100644 --- a/services/core/internal/store/turn_inputs.go +++ b/services/core/internal/store/turn_inputs.go @@ -16,6 +16,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) type InputReceipt struct { @@ -162,7 +164,7 @@ func admitInput(ctx context.Context, q *sqlc.Queries, tenantID string, session p turn, err = q.CreateTurn(ctx, sqlc.CreateTurnParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session}) if err == nil { created = true - err = recordTurnChange(ctx, q, turn, true) + err = sessionpg.AppendChanges(ctx, q, session, sessions.TurnChanges(turnFromRow(turn), true)...) } } else { err = nil // Retain even an idle cancellation's retry identity. @@ -188,7 +190,11 @@ func admitInput(ctx context.Context, q *sqlc.Queries, tenantID string, session p if created { // A new Turn publishes turn.created, then its user input Items, then the // Session activity, within this transaction. - if err := recordSessionActivity(ctx, q, turn, nil); err != nil { + usage, err := sessionpg.LoadUsage(ctx, q, session) + if err != nil { + return InputReceipt{}, err + } + if err := sessionpg.AppendChanges(ctx, q, session, sessions.ActivityChange(turnFromRow(turn), usage, nil)); err != nil { return InputReceipt{}, err } } diff --git a/services/core/internal/store/turn_inputs_test.go b/services/core/internal/store/turn_inputs_test.go index e7b918a51..4b69b8484 100644 --- a/services/core/internal/store/turn_inputs_test.go +++ b/services/core/internal/store/turn_inputs_test.go @@ -9,6 +9,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -35,7 +36,7 @@ func submitMessage(t *testing.T, s *Store, tenant, session, key string) InputRec return receipt } -func transition(t *testing.T, s *Store, tenant, session, turn, from, to string) Turn { +func transition(t *testing.T, s *Store, tenant, session, turn, from, to string) sessions.Turn { t.Helper() got, err := s.TransitionTurn(context.Background(), tenant, session, turn, TurnTransition{ExpectedStatus: from, Status: to}) if err != nil { @@ -109,7 +110,7 @@ func TestTurnInputRetriesAndRestart(t *testing.T) { tenant, session := newTurnSession(t, s) ctx := context.Background() first := submitMessage(t, s, tenant, session.ID, "first") - transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) steer := submitMessage(t, s, tenant, session.ID, "steer") if steer.TurnID != first.TurnID || steer.Sequence <= first.Sequence { t.Fatalf("active message did not steer: %+v", steer) @@ -125,7 +126,7 @@ func TestTurnInputRetriesAndRestart(t *testing.T) { if _, err := s.RequestCancel(ctx, tenant, session.ID, "first"); !errors.Is(err, ErrIdempotencyConflict) { t.Fatalf("changed input kind accepted: %v", err) } - completed := transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnCompleted) + completed := transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnInProgress, sessions.TurnCompleted) next := submitMessage(t, s, tenant, session.ID, "next") if next.TurnID == first.TurnID { t.Fatal("idle message did not start a new Turn") @@ -160,7 +161,7 @@ func TestTurnInputRetriesAndRestart(t *testing.T) { t.Fatalf("recovered inputs = %+v", all) } snapshot, err := recovered.GetSession(ctx, tenant, session.ID) - if err != nil || snapshot.LastTurn == nil || snapshot.LastTurn.ID != next.TurnID || snapshot.LastTurn.Status != TurnQueued { + if err != nil || snapshot.LastTurn == nil || snapshot.LastTurn.ID != next.TurnID || snapshot.LastTurn.Status != sessions.TurnQueued { t.Fatal("latest Session activity did not survive restart", err) } snapshot.LastTurn = nil @@ -188,7 +189,7 @@ func TestTurnOperationsAreTenantAndSessionScoped(t *testing.T) { return err }, "transition": func() error { - _, err := s.TransitionTurn(ctx, scope.tenant, scope.session, first.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnFailed}) + _, err := s.TransitionTurn(ctx, scope.tenant, scope.session, first.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnFailed}) return err }, } { @@ -205,7 +206,7 @@ func TestTurnOperationsAreTenantAndSessionScoped(t *testing.T) { if _, err := s.GetTurn(ctx, tenant, second.ID, first.TurnID); !errors.Is(err, ErrNotFound) { t.Fatalf("cross-session turn read: %v", err) } - if _, err := s.TransitionTurn(ctx, tenant, second.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnFailed}); !errors.Is(err, ErrNotFound) { + if _, err := s.TransitionTurn(ctx, tenant, second.ID, first.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnFailed}); !errors.Is(err, ErrNotFound) { t.Fatalf("cross-session turn write: %v", err) } otherInput := submitMessage(t, s, otherTenant, other.ID, "input") diff --git a/services/core/internal/store/turn_reads.go b/services/core/internal/store/turn_reads.go index ae3162767..3a4fd8fd7 100644 --- a/services/core/internal/store/turn_reads.go +++ b/services/core/internal/store/turn_reads.go @@ -6,12 +6,13 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type TurnPage struct { - Turns []Turn + Turns []sessions.Turn NextCursor string } @@ -40,7 +41,7 @@ func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor strin if err != nil { return TurnPage{}, fmt.Errorf("list turns: %w", err) } - page := TurnPage{Turns: make([]Turn, 0, min(limit, len(rows)))} + page := TurnPage{Turns: make([]sessions.Turn, 0, min(limit, len(rows)))} if len(rows) > limit { page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() rows = rows[:limit] diff --git a/services/core/internal/store/turn_reads_test.go b/services/core/internal/store/turn_reads_test.go index 10a13383b..e5877661b 100644 --- a/services/core/internal/store/turn_reads_test.go +++ b/services/core/internal/store/turn_reads_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -17,7 +18,7 @@ func TestTurnPaginationRetainsScopeAndOrder(t *testing.T) { ids := make([]string, 0, 4) for i := 0; i < 4; i++ { receipt := submitMessage(t, s, tenant, session.ID, uuid.NewString()) - transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnCancelled) + transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnCancelled) ids = append(ids, receipt.TurnID) } // Equal creation times exercise the ID tie-breaker across page boundaries. diff --git a/services/core/internal/store/turns.go b/services/core/internal/store/turns.go index 1196deb04..9389b3319 100644 --- a/services/core/internal/store/turns.go +++ b/services/core/internal/store/turns.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -14,36 +13,12 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) var ErrTurnConflict = errors.New("turn state changed or cancellation was requested") -const ( - TurnQueued = "queued" - TurnInProgress = "in_progress" - TurnWaiting = "waiting" - TurnCompleted = "completed" - TurnFailed = "failed" - TurnCancelled = "cancelled" -) - -// Turn is a root Turn from the Core work queue and uses its Session's immutable -// execution configuration; Subagent Turns have a native writer and their own -// table. Zero timestamps mean the corresponding event has not occurred. Outcome -// is adapter-owned data, not an upstream response; the API must project -// supported wire types explicitly. -type Turn struct { - ID, SessionID, Status string - CreatedAt time.Time - StartedAt time.Time - CompletedAt time.Time - CancelRequestedAt time.Time - Usage json.RawMessage - Outcome json.RawMessage - // ArtifactCaptureStarted is private Runtime coordination, never a wire field. - ArtifactCaptureStarted bool `json:"-"` -} - type TurnTransition struct { ExpectedStatus string Status string @@ -52,17 +27,17 @@ type TurnTransition struct { // GetTurn reads a root Turn. A Subagent Turn ID is not found here, exactly like // a missing one; GetSubagentTurn reads child Turns. -func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) (Turn, error) { +func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) (sessions.Turn, error) { params, err := publicTurnLookup(tenantID, sessionID, turnID) if err != nil { - return Turn{}, err + return sessions.Turn{}, err } row, err := s.queries.GetTurn(ctx, params) if errors.Is(err, pgx.ErrNoRows) { - return Turn{}, ErrNotFound + return sessions.Turn{}, ErrNotFound } if err != nil { - return Turn{}, fmt.Errorf("get turn: %w", err) + return sessions.Turn{}, fmt.Errorf("get turn: %w", err) } return turnFromRow(row), nil } @@ -70,20 +45,20 @@ func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) // TransitionTurn is a compare-and-set for execution callbacks. Once terminal, // a Turn cannot be reopened or have its outcome overwritten, including by retries. // A dispatcher must claim queued -> in_progress before sending work to a daemon. -func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID string, input TurnTransition) (Turn, error) { +func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID string, input TurnTransition) (sessions.Turn, error) { params, err := turnLookup(tenantID, sessionID, turnID) if err != nil { - return Turn{}, err + return sessions.Turn{}, err } if !validTransition(input.ExpectedStatus, input.Status) || len(input.Outcome) > 512*1024 { - return Turn{}, fmt.Errorf("%w: invalid turn transition or outcome size", ErrInvalidInput) + return sessions.Turn{}, fmt.Errorf("%w: invalid turn transition or outcome size", ErrInvalidInput) } outcome, err := jsonobject.Normalize(input.Outcome) if err != nil { - return Turn{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) + return sessions.Turn{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } - if !terminalStatus(input.Status) && string(outcome) != "{}" { - return Turn{}, fmt.Errorf("%w: outcome requires a terminal status", ErrInvalidInput) + if !sessions.TerminalStatus(input.Status) && string(outcome) != "{}" { + return sessions.Turn{}, fmt.Errorf("%w: outcome requires a terminal status", ErrInvalidInput) } input.Outcome = outcome var row sqlc.Turn @@ -93,7 +68,7 @@ func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID return err }) if err != nil { - return Turn{}, fmt.Errorf("transition turn: %w", err) + return sessions.Turn{}, fmt.Errorf("transition turn: %w", err) } return turnFromRow(row), nil } @@ -104,7 +79,7 @@ func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnPar } else if err != nil { return sqlc.Turn{}, err } - if input.ExpectedStatus == TurnQueued && input.Status == TurnInProgress { + if input.ExpectedStatus == sessions.TurnQueued && input.Status == sessions.TurnInProgress { if err := checkRuntimeComputeAdmission(ctx, q, params.SessionID); err != nil { return sqlc.Turn{}, err } @@ -116,16 +91,26 @@ func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnPar if errors.Is(err, pgx.ErrNoRows) { return sqlc.Turn{}, ErrTurnConflict } - if err == nil && terminalStatus(row.Status) { - if err = projectSource(ctx, q, row.SessionID, row.ID, "execution_"+row.Status, 0, row.Outcome, row.CompletedAt); err != nil { + if err != nil { + return sqlc.Turn{}, err + } + if !sessions.TerminalStatus(row.Status) { + if err := sessionpg.AppendChanges(ctx, q, row.SessionID, sessions.TurnChanges(turnFromRow(row), false)...); err != nil { return sqlc.Turn{}, err } - row, err = q.GetTurn(ctx, params) + return row, nil } + if err = projectSource(ctx, q, row.SessionID, row.ID, "execution_"+row.Status, 0, row.Outcome, row.CompletedAt); err != nil { + return sqlc.Turn{}, err + } + if row, err = q.GetTurn(ctx, params); err != nil { + return sqlc.Turn{}, err + } + ending, err := sessionpg.LoadEnding(ctx, q, row.SessionID, row.ID) if err != nil { return sqlc.Turn{}, err } - if err := recordTurnChange(ctx, q, row, false); err != nil { + if err := sessionpg.ApplyTurnEnd(ctx, q, row.SessionID, row.ID, sessions.EndTurn(turnFromRow(row), ending)); err != nil { return sqlc.Turn{}, err } return row, nil @@ -133,21 +118,17 @@ func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnPar func validTransition(from, to string) bool { switch from { - case TurnQueued: - return to == TurnInProgress || to == TurnFailed || to == TurnCancelled - case TurnInProgress: - return to == TurnWaiting || terminalStatus(to) - case TurnWaiting: - return to == TurnInProgress || terminalStatus(to) + case sessions.TurnQueued: + return to == sessions.TurnInProgress || to == sessions.TurnFailed || to == sessions.TurnCancelled + case sessions.TurnInProgress: + return to == sessions.TurnWaiting || sessions.TerminalStatus(to) + case sessions.TurnWaiting: + return to == sessions.TurnInProgress || sessions.TerminalStatus(to) default: return false } } -func terminalStatus(status string) bool { - return status == TurnCompleted || status == TurnFailed || status == TurnCancelled -} - func turnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, error) { var p sqlc.GetTurnParams var err error @@ -168,8 +149,8 @@ func publicTurnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, e return sqlc.GetTurnParams{TenantID: tenant, SessionID: pgunit.PathID(sessionID), ID: pgunit.PathID(turnID)}, err } -func turnFromRow(row sqlc.Turn) Turn { - return Turn{ +func turnFromRow(row sqlc.Turn) sessions.Turn { + return sessions.Turn{ ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), Status: row.Status, CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, CancelRequestedAt: row.CancelRequestedAt.Time, Outcome: json.RawMessage(row.Outcome), Usage: json.RawMessage(row.TokenUsage), diff --git a/services/core/internal/store/turns_test.go b/services/core/internal/store/turns_test.go index 4811d5a3e..4a6646db4 100644 --- a/services/core/internal/store/turns_test.go +++ b/services/core/internal/store/turns_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "reflect" "sync" "testing" @@ -18,7 +19,7 @@ func TestCancellationStaysBoundToItsOriginalTurn(t *testing.T) { t.Fatalf("idle cancellation: %+v, %v", idle, err) } first := submitMessage(t, s, tenant, session.ID, "first") - started := transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + started := transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if started.StartedAt.IsZero() || !started.CompletedAt.IsZero() { t.Fatalf("started timestamps: %+v", started) } @@ -27,10 +28,10 @@ func TestCancellationStaysBoundToItsOriginalTurn(t *testing.T) { t.Fatalf("cancellation target: %+v, %v", cancel, err) } pending, err := s.GetTurn(ctx, tenant, session.ID, first.TurnID) - if err != nil || pending.Status != TurnInProgress || pending.CancelRequestedAt.IsZero() || !pending.CompletedAt.IsZero() { + if err != nil || pending.Status != sessions.TurnInProgress || pending.CancelRequestedAt.IsZero() || !pending.CompletedAt.IsZero() { t.Fatalf("running cancellation falsely completed: %+v, %v", pending, err) } - transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnCancelled) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnInProgress, sessions.TurnCancelled) next := submitMessage(t, s, tenant, session.ID, "next") for key, original := range map[string]InputReceipt{"cancel": cancel, "idle-cancel": idle} { retry, err := s.RequestCancel(ctx, tenant, session.ID, key) @@ -39,17 +40,17 @@ func TestCancellationStaysBoundToItsOriginalTurn(t *testing.T) { } } queued, err := s.GetTurn(ctx, tenant, session.ID, next.TurnID) - if err != nil || queued.Status != TurnQueued || !queued.CancelRequestedAt.IsZero() { + if err != nil || queued.Status != sessions.TurnQueued || !queued.CancelRequestedAt.IsZero() { t.Fatalf("old cancellation affected later Turn: %+v, %v", queued, err) } if _, err := s.RequestCancel(ctx, tenant, session.ID, "cancel-queued"); err != nil { t.Fatal(err) } stopped, err := s.GetTurn(ctx, tenant, session.ID, next.TurnID) - if err != nil || stopped.Status != TurnCancelled || stopped.CompletedAt.IsZero() || !stopped.StartedAt.IsZero() { + if err != nil || stopped.Status != sessions.TurnCancelled || stopped.CompletedAt.IsZero() || !stopped.StartedAt.IsZero() { t.Fatalf("queued work did not stop: %+v, %v", stopped, err) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, next.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); !errors.Is(err, ErrTurnConflict) { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, next.TurnID, TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); !errors.Is(err, ErrTurnConflict) { t.Fatalf("cancelled queued work was started: %v", err) } } @@ -59,24 +60,24 @@ func TestWaitingTurnRetainsInputsAndStartTime(t *testing.T) { tenant, session := newTurnSession(t, s) ctx := context.Background() first := submitMessage(t, s, tenant, session.ID, "first") - started := transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) - transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnWaiting) + started := transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnInProgress, sessions.TurnWaiting) steer := submitMessage(t, s, tenant, session.ID, "steer") if steer.TurnID != first.TurnID { t.Fatal("waiting input started another Turn") } - resumed := transition(t, s, tenant, session.ID, first.TurnID, TurnWaiting, TurnInProgress) + resumed := transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnWaiting, sessions.TurnInProgress) if !started.StartedAt.Equal(resumed.StartedAt) { t.Fatal("resume reset the start time") } - transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnWaiting) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnInProgress, sessions.TurnWaiting) if _, err := s.RequestCancel(ctx, tenant, session.ID, "cancel"); err != nil { t.Fatal(err) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnWaiting, Status: TurnInProgress}); !errors.Is(err, ErrTurnConflict) { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: sessions.TurnWaiting, Status: sessions.TurnInProgress}); !errors.Is(err, ErrTurnConflict) { t.Fatalf("cancelling Turn resumed: %v", err) } - transition(t, s, tenant, session.ID, first.TurnID, TurnWaiting, TurnCancelled) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnWaiting, sessions.TurnCancelled) } func TestTerminalOutcomeIsImmutableDuringConcurrentCallbacks(t *testing.T) { @@ -85,19 +86,19 @@ func TestTerminalOutcomeIsImmutableDuringConcurrentCallbacks(t *testing.T) { tenant, session := newTurnSession(t, s) ctx := context.Background() first := submitMessage(t, s, tenant, session.ID, "first") - transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) if _, err := s.RequestCancel(ctx, tenant, session.ID, "cancel"); err != nil { t.Fatal(err) } var wg sync.WaitGroup - winners := make(chan Turn, 3) + winners := make(chan sessions.Turn, 3) errs := make(chan error, 3) - for _, status := range []string{TurnCompleted, TurnFailed, TurnCancelled} { + for _, status := range []string{sessions.TurnCompleted, sessions.TurnFailed, sessions.TurnCancelled} { wg.Add(1) go func() { defer wg.Done() outcome, _ := json.Marshal(map[string]string{"reported": status}) - turn, err := other.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: status, Outcome: outcome}) + turn, err := other.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: status, Outcome: outcome}) if err == nil { winners <- turn } else { @@ -120,7 +121,7 @@ func TestTerminalOutcomeIsImmutableDuringConcurrentCallbacks(t *testing.T) { if winner.CompletedAt.IsZero() || winner.CancelRequestedAt.IsZero() || winner.CompletedAt.Before(winner.StartedAt) { t.Fatalf("terminal timestamps: %+v", winner) } - if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed, Outcome: json.RawMessage(`{"late":true}`)}); !errors.Is(err, ErrTurnConflict) { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"late":true}`)}); !errors.Is(err, ErrTurnConflict) { t.Fatalf("late terminal callback accepted: %v", err) } pool.Close() @@ -150,17 +151,17 @@ func TestTurnInputValidationHasNoSideEffects(t *testing.T) { t.Fatal("failed submission persisted a receipt") } for _, input := range []TurnTransition{ - {ExpectedStatus: TurnQueued, Status: TurnCompleted}, - {ExpectedStatus: TurnInProgress, Status: TurnQueued}, - {ExpectedStatus: TurnCompleted, Status: TurnInProgress}, - {ExpectedStatus: TurnQueued, Status: TurnInProgress, Outcome: json.RawMessage(`{"premature":true}`)}, + {ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnCompleted}, + {ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnQueued}, + {ExpectedStatus: sessions.TurnCompleted, Status: sessions.TurnInProgress}, + {ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress, Outcome: json.RawMessage(`{"premature":true}`)}, } { if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, input); !errors.Is(err, ErrInvalidInput) { t.Fatalf("invalid transition accepted: %v", err) } } got, err := s.GetTurn(ctx, tenant, session.ID, first.TurnID) - if err != nil || got.Status != TurnQueued || !got.StartedAt.IsZero() { + if err != nil || got.Status != sessions.TurnQueued || !got.StartedAt.IsZero() { t.Fatalf("invalid transition changed Turn: %+v, %v", got, err) } } diff --git a/services/core/internal/store/worker_capacity_test.go b/services/core/internal/store/worker_capacity_test.go index fe02256e4..596019ee1 100644 --- a/services/core/internal/store/worker_capacity_test.go +++ b/services/core/internal/store/worker_capacity_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) // Consume the actual controls so capacity rejection is exercised before any @@ -35,11 +35,11 @@ func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { h := newDispatchHarness(t) enableWorkerEnvironment(t, h) h.d.MaxConcurrentExecutions = 5 - sessions := make(map[string]string) + turns := make(map[string]string) for i := range 5 { h.session = publicSession(t, h, fmt.Sprintf("capacity-%d", i)) receipt := h.message("work", "once") - sessions[h.session.ID] = receipt.TurnID + turns[h.session.ID] = receipt.TurnID } frames := capacityWorkerFrames(t, h) _, stop := startEnvironmentExpiryWorker(t, h.db, h.d) @@ -53,7 +53,7 @@ func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { switch frame.Type { case proto.TypeExecutionPrepare: var prepare proto.ExecutionPreparePayload - if frame.DecodePayload(&prepare) != nil || sessions[prepare.SessionID] == "" || len(prepare.Configuration.Input) != 0 || prepare.Configuration.RunID != "" { + if frame.DecodePayload(&prepare) != nil || turns[prepare.SessionID] == "" || len(prepare.Configuration.Input) != 0 || prepare.Configuration.RunID != "" { t.Fatal("invalid input-free preparation", prepare) } if blocked == "" && len(admissions) == 4 { @@ -69,7 +69,7 @@ func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { case proto.TypeExecutionStart: var start proto.ExecutionStartPayload session := admissions[frame.ID] - if frame.DecodePayload(&start) != nil || session == "" || start.RunID != sessions[session] || inputTextForTest(t, start.Input) != "once" { + if frame.DecodePayload(&start) != nil || session == "" || start.RunID != turns[session] || inputTextForTest(t, start.Input) != "once" { t.Fatal("Start changed the original input or Turn", start) } started[session]++ @@ -102,8 +102,8 @@ func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { } assertQueued := func() { t.Helper() - turn, err := h.s.GetTurn(t.Context(), h.tenant, blocked, sessions[blocked]) - if err != nil || turn.Status != store.TurnQueued { + turn, err := h.s.GetTurn(t.Context(), h.tenant, blocked, turns[blocked]) + if err != nil || turn.Status != sessions.TurnQueued { t.Fatal("capacity rejection failed queued work", turn, err) } } @@ -133,25 +133,25 @@ func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { observeCapacity() // After completion, Runtime can retire the idle Executor. Its cleanup // still owns a slot and reports the same capacity rejection. - h.write(sessions[first], proto.TypeDone, proto.DonePayload{Content: "done"}) + h.write(turns[first], proto.TypeDone, proto.DonePayload{Content: "done"}) awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "first completed Turn", func() bool { - turn, err := h.s.GetTurn(t.Context(), h.tenant, first, sessions[first]) - return err == nil && turn.Status == store.TurnCompleted + turn, err := h.s.GetTurn(t.Context(), h.tenant, first, turns[first]) + return err == nil && turn.Status == sessions.TurnCompleted }) observeCapacity() capacityReleased = true for started[blocked] == 0 { next() } - for session, turn := range sessions { + for session, turn := range turns { if session != first { h.write(turn, proto.TypeDone, proto.DonePayload{Content: "done"}) } } awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "all five Turns completed", func() bool { - for session, id := range sessions { + for session, id := range turns { turn, err := h.s.GetTurn(t.Context(), h.tenant, session, id) - if err != nil || turn.Status != store.TurnCompleted { + if err != nil || turn.Status != sessions.TurnCompleted { return false } } @@ -183,7 +183,7 @@ func TestWorkerDoesNotDeferOtherPreparationOrStartRejections(t *testing.T) { nextWorkerFrame(t, frames, proto.TypeExecutionStart) } h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: test.operation, ErrorCode: test.code}) - waitTurn(t, h, receipt.TurnID, store.TurnFailed) + waitTurn(t, h, receipt.TurnID, sessions.TurnFailed) }) } } diff --git a/services/core/internal/store/worker_input_race_test.go b/services/core/internal/store/worker_input_race_test.go index 7dba1f1ef..34d15890b 100644 --- a/services/core/internal/store/worker_input_race_test.go +++ b/services/core/internal/store/worker_input_race_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -80,7 +81,7 @@ func TestWorkerInputReadSkipsConcurrentlyCancelledCandidate(t *testing.T) { t.Fatal("input-read interleaving was not reached") } turn, err := h.s.GetTurn(ctx, h.tenant, candidateSession, candidate.TurnID) - if err != nil || turn.Status != store.TurnCancelled { + if err != nil || turn.Status != sessions.TurnCancelled { t.Fatal("candidate was not cancelled", err) } // A later Session must still execute through this same Worker. @@ -101,7 +102,7 @@ func TestWorkerInputReadSkipsConcurrentlyCancelledCandidate(t *testing.T) { t.Fatal("cancelled candidate reached the Runtime", frame.ID) } h.write(healthy.TurnID, proto.TypeDone, proto.DonePayload{Content: "continued"}) - waitTurn(t, h, healthy.TurnID, store.TurnCompleted) + waitTurn(t, h, healthy.TurnID, sessions.TurnCompleted) }) } } diff --git a/services/core/internal/store/worker_lease_loss_test.go b/services/core/internal/store/worker_lease_loss_test.go index ad420eea7..38c6ee3b0 100644 --- a/services/core/internal/store/worker_lease_loss_test.go +++ b/services/core/internal/store/worker_lease_loss_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -68,7 +69,7 @@ func TestWorkerLeaseLossLeavesUncertainWorkForSuccessor(t *testing.T) { t.Fatal("worker ignored lease loss") } active, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, request.ID) - if err != nil || active.Status != store.TurnInProgress { + if err != nil || active.Status != sessions.TurnInProgress { t.Fatal("lost owner persisted fallback completion", active, err) } successor := startWorker(t, t.Context(), h.db, h.d) @@ -78,11 +79,11 @@ func TestWorkerLeaseLossLeavesUncertainWorkForSuccessor(t *testing.T) { t.Fatal(err) } active, err = h.s.GetTurn(t.Context(), h.tenant, h.session.ID, request.ID) - if err != nil || active.Status != store.TurnFailed { + if err != nil || active.Status != sessions.TurnFailed { t.Fatal("successor did not reconcile", active, err) } next, err := h.s.GetTurn(t.Context(), h.tenant, queued.ID, pending[0].TurnID) - if err != nil || next.Status != store.TurnQueued { + if err != nil || next.Status != sessions.TurnQueued { t.Fatal("successor lost queued work", next, err) } } diff --git a/services/core/tests/fixtures/items.go b/services/core/tests/fixtures/items.go index 5a1629972..c37d3ca27 100644 --- a/services/core/tests/fixtures/items.go +++ b/services/core/tests/fixtures/items.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -15,7 +16,7 @@ func observeItems(ctx context.Context, s *store.Store, tenant, session, turn, st {Kind: "tool_call", Payload: json.RawMessage(`{"id":"patch","stage":"after","observation":{"status":"completed","kind":"function","name":"apply_patch","arguments":{"changes":[{"path":"/workspace/sample","diff":"+example"}]}}}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"search","stage":"after","observation":{"status":"completed","kind":"web_search","action":{"type":"search","query":"reference"}}}`)}, } - if status == store.TurnCompleted || status == store.TurnFailed { + if status == sessions.TurnCompleted || status == sessions.TurnFailed { events = append(events, store.ExecutionEvent{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"final answer","phase":"final_answer"}`)}) } return s.AppendTurnEvents(ctx, tenant, session, turn, 1, events) diff --git a/services/core/tests/fixtures/main.go b/services/core/tests/fixtures/main.go index 3b1d4c4de..7d6bc6074 100644 --- a/services/core/tests/fixtures/main.go +++ b/services/core/tests/fixtures/main.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -52,20 +53,20 @@ func seed() error { } defer pool.Close() s := store.New(pool) - for _, status := range []string{store.TurnCompleted, store.TurnFailed, store.TurnCancelled, store.TurnInProgress} { + for _, status := range []string{sessions.TurnCompleted, sessions.TurnFailed, sessions.TurnCancelled, sessions.TurnInProgress} { receipt, err := s.SubmitMessage(ctx, f.Tenant, f.Session, uuid.NewString(), json.RawMessage(`{"text":"recovery fixture"}`)) if err != nil { return err } - if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { return err } if err = observeItems(ctx, s, f.Tenant, f.Session, receipt.TurnID, status); err != nil { return err } - if status != store.TurnInProgress { + if status != sessions.TurnInProgress { outcome := json.RawMessage(`{"error":"SECRET engine log","done":{"metadata":{"agent_session_id":"PRIVATE"}}}`) - if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: status, Outcome: outcome}); err != nil { + if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: status, Outcome: outcome}); err != nil { return err } }