diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index e8784cfff..0d15252d8 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -158,7 +158,6 @@ Each item is Core's deliberate or native behavior where the official service beh - There is no storage-key rotation or re-encryption. - OAuth refresh happens only at dispatch: there is no refresh on a provider 401, no mid-Turn replacement and no withdrawal of a token already sent to a Runtime. -- Input sent to a Session whose selected Credential was deleted is admitted and then fails at dispatch. - Credential creation takes no `Idempotency-Key`. **Sessions** diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 88ebb0f37..db1414183 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -61,7 +61,7 @@ On `openai_hosted` and `self_hosted` Sessions, messages sent while a Turn runs j ### Input errors -Checks run in this order: [request validation](./wire-semantics.md#configuration-validation), whose paths start with `events[i]`, Session lookup, retry lookup, the Environment file-write gate for batches with a message, the pending-input gate, then each event in batch order. A rejected batch writes nothing and leaves any pending action unchanged. Every 409 has type `conflict_error` ([error envelope](./wire-semantics.md)). +Checks run in this order: [request validation](./wire-semantics.md#configuration-validation), whose paths start with `events[i]`, Session lookup, the Harness input check, retry lookup, the Credential check for a message that starts a Turn, the Environment file-write gate for batches with a message, the pending-input gate, then each event in batch order. A rejected batch writes nothing and leaves any pending action unchanged. Every 409 has type `conflict_error` ([error envelope](./wire-semantics.md)). | Case | Status and code | Message | | --- | --- | --- | @@ -76,9 +76,10 @@ Checks run in this order: [request validation](./wire-semantics.md#configuration | A reserved batch cancelled because an administrator archived the Session or reset its deployment | 409 `environment_input_cancelled` | "The environment input was cancelled before admission." | | New input after an `openai_hosted` Environment failed to provision | 409 `conflict_error` | "the hosted environment failed to provision" | | New input after a `self_hosted` Environment failed, input already waiting when the Environment failed, or an expired Environment | 409 `environment_unavailable` | "The environment is no longer available for new input." | +| A message that would start a Turn on a Session whose bound MCP Credential, or its Vault, was deleted | 400 `invalid_request_error`, param null | "MCP credential_id {id} was not found in an attached vault" | | A message the Session's Harness cannot take, such as whitespace-only text on MiniMax Code | 400 `unsupported_or_invalid_configuration` | See [whitespace-only text](./message-content.md#whitespace-only-text) | -An empty `turn_id` or blank `call_id` is the generic 400 `invalid_request`. Apart from the unknown tool call message, which names its `call_id` as the official SDK expects, error messages never repeat caller input or internal identifiers. +An empty `turn_id` or blank `call_id` is the generic 400 `invalid_request`. Apart from the unknown tool call message, which names its `call_id` as the official SDK expects, and the Credential message, which names the Credential as Session creation does, error messages never repeat caller input or internal identifiers. ## Initial input at Session creation diff --git a/contracts/agents-api/vaults.md b/contracts/agents-api/vaults.md index bd43c2cfa..56c611f33 100644 --- a/contracts/agents-api/vaults.md +++ b/contracts/agents-api/vaults.md @@ -145,4 +145,4 @@ An unreadable or malformed key file stops Core at startup. Losing or replacing t Deleting a Credential, or its Vault, removes the credential rows. It does not erase secrets from PostgreSQL pages, write-ahead logs, backups or native history. Afterwards its reads, updates and repeated deletion return 404, lists omit it, new Sessions cannot select it and new Credentials cannot be created in a deleted Vault. -Existing Sessions keep their frozen attachments and selections, and their history stays readable. The next dispatch that needs a deleted Credential fails; Core neither selects another Credential nor connects anonymously. Deletion does not cancel running work, withdraw a token already sent to a Runtime or revoke the grant at its provider: cancel the Session and revoke the grant yourself when needed. Revoked or invalid OAuth grants likewise fail until replaced. +Existing Sessions keep their frozen attachments and selections, and their history stays readable. A message that would start a Turn is rejected with 400 `invalid_request_error`, as at creation, and creates no Turn; a request under an `Idempotency-Key` the Session already used replays or conflicts as before. A deletion between that check and dispatch, and any other input, still fails at the next dispatch that needs the deleted Credential. Core neither selects another Credential nor connects anonymously. Deletion does not cancel running work, withdraw a token already sent to a Runtime or revoke the grant at its provider: cancel the Session and revoke the grant yourself when needed. Revoked or invalid OAuth grants likewise fail until replaced. diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 42c5abbee..2b1349bb2 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: afb241cb659061b9f5c50c5db88c83080d02d07eab37ffb2f1c70d5e2386c96e +source_hash: 007c0a43dce8af3d21c5a4afb196ad2f62fad8b697ac3ba38eaab6ce955e0f88 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -161,7 +161,6 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 没有存储密钥轮换或重新加密。 - OAuth 刷新仅在派发时执行:提供商返回 401 时不会刷新,不会在 Turn 中途替换令牌,也不会撤回已发送给 Runtime 的令牌。 -- 发送到所选 Credential 已被删除的 Session 的输入会先通过准入,随后在派发时失败。 - 创建 Credential 时不接受 `Idempotency-Key`。 **Sessions** diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 618f0b60b..eca247040 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: 39fe5deab865e778bd8d3fa50a5d66707e10df4652b868e47f17126d962291eb +source_hash: ffddefb51fd2dcd04532d210f658caf96a6f825057c9025c3c18dbf8eb3ddc5a --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -63,7 +63,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 ### 输入错误 {#input-errors} -检查按以下顺序执行:[请求验证](wire-semantics.md#configuration-validation)(路径以 `events[i]` 开头)、Session 查找、重试查找、针对包含消息的批次的 Environment 文件写入门控、待处理输入门控,最后按批次顺序检查每个事件。被拒绝的批次不会写入任何内容,也不会改变任何待处理操作。所有 409 响应的 `type` 都是 `conflict_error`([错误封装](wire-semantics.md))。 +检查按以下顺序执行:[请求验证](wire-semantics.md#configuration-validation)(路径以 `events[i]` 开头)、Session 查找、Harness 输入检查、重试查找、针对会启动 Turn 的消息的 Credential 检查、针对包含消息的批次的 Environment 文件写入门控、待处理输入门控,最后按批次顺序检查每个事件。被拒绝的批次不会写入任何内容,也不会改变任何待处理操作。所有 409 响应的 `type` 都是 `conflict_error`([错误封装](wire-semantics.md))。 | 情况 | 状态和代码 | 消息 | | --- | --- | --- | @@ -78,9 +78,10 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 | 预留的批次因管理员归档 Session 或重置其部署而被取消 | 409 `environment_input_cancelled` | "The environment input was cancelled before admission." | | `openai_hosted` Environment 预配失败后提交新输入 | 409 `conflict_error` | "the hosted environment failed to provision" | | `self_hosted` Environment 失败后提交新输入、Environment 失败时输入已在等待,或 Environment 已过期 | 409 `environment_unavailable` | "The environment is no longer available for new input." | +| 会在其所绑定 MCP Credential 或其 Vault 已删除的 Session 上启动 Turn 的消息 | 400 `invalid_request_error`,param 为 null | "MCP credential_id {id} was not found in an attached vault" | | Session Harness 无法处理的消息,例如 MiniMax Code 上仅含空白的文本 | 400 `unsupported_or_invalid_configuration` | 见[仅含空白的文本](message-content.md#whitespace-only-text) | -`turn_id` 为空或 `call_id` 为空白时,会返回通用的 400 `invalid_request`。除未知工具调用的消息按官方 SDK 的预期给出其 `call_id` 外,错误消息绝不会重复调用方输入或内部标识符。 +`turn_id` 为空或 `call_id` 为空白时,会返回通用的 400 `invalid_request`。除未知工具调用的消息按官方 SDK 的预期给出其 `call_id`、Credential 消息像 Session 创建时一样给出该 Credential 外,错误消息绝不会重复调用方输入或内部标识符。 ## 创建 Session 时的初始输入 {#initial-input-at-session-creation} diff --git a/contracts/agents-api/zh/vaults.md b/contracts/agents-api/zh/vaults.md index 9aee59336..9ccb10475 100644 --- a/contracts/agents-api/zh/vaults.md +++ b/contracts/agents-api/zh/vaults.md @@ -1,7 +1,7 @@ --- title: "Vault 与 Credential" source: contracts/agents-api/vaults.md -source_hash: 1a57ba70173f8a000b6b0cb74eb7fa6a6c688b4f71d7e617b4fdfb60e6d6fef5 +source_hash: 3ef927a873326ca5a14720843269c8c05053331e616305fcccfdf0e4367f4be2 --- Vault 是项目所有的 Credential 容器。Credential 保存一个 HTTPS MCP server 的秘密:`static_bearer` token 或 `mcp_oauth` grant。Session 在 `vault_ids` 中关联 Vault;Core 在创建 Session 时为每个 HTTP MCP server 选择一个 Credential,只在分派工作时将解密 token 交给 Runtime。秘密只能写入:任何读取都不返回 token、refresh token、client secret 或密文。 @@ -147,4 +147,4 @@ key 文件不可读或格式错误会使 Core 启动失败。丢失或替换 key 删除 Credential 或其 Vault 会移除凭据行,但不会擦除 PostgreSQL 页面、WAL、备份或原生历史中的秘密。此后查询、更新和重复删除返回 404;列表不再包含它;新 Session 不能选择它;不能在已删除 Vault 中创建新 Credential。 -现有 Session 保留固定的关联和选择,历史仍可读。下一次需要已删除 Credential 的分派失败;Core 不选择其他 Credential,也不匿名连接。删除不取消运行中的工作、不收回已发送 Runtime 的 token,也不在服务方撤销 grant;需要时自行取消 Session 并撤销 grant。已撤销或无效 OAuth grant 同样失败,直到被替换。 +现有 Session 保留固定的关联和选择,历史仍可读。会启动 Turn 的消息与创建时一样返回 400 `invalid_request_error`,且不创建 Turn;使用 Session 已用过的 `Idempotency-Key` 的请求照常重放或冲突。该检查与分派之间发生的删除以及其他输入,仍在下一次需要已删除 Credential 的分派时失败。Core 不选择其他 Credential,也不匿名连接。删除不取消运行中的工作、不收回已发送 Runtime 的 token,也不在服务方撤销 grant;需要时自行取消 Session 并撤销 grant。已撤销或无效 OAuth grant 同样失败,直到被替换。 diff --git a/services/core/internal/api/errors_sessions.go b/services/core/internal/api/errors_sessions.go index e69ec141b..5f2ee9ec3 100644 --- a/services/core/internal/api/errors_sessions.go +++ b/services/core/internal/api/errors_sessions.go @@ -8,13 +8,17 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) // writeInputError reports Session input admission failures. Input that the // Session cannot accept in its current state is the official conflict_error; // Idempotency-Key reuse keeps Core's local idempotency_conflict code. func writeInputError(w http.ResponseWriter, r *http.Request, err error) { + var selection *vaults.MCPCredentialSelectionError switch { + case errors.As(err, &selection): + writeVaultsError(w, r, err) case errors.Is(err, sessions.ErrInputPending): writeError(w, http.StatusConflict, "conflict_error", "Earlier input to this Session is still pending.") case errors.Is(err, sessions.ErrTurnConflict): diff --git a/services/core/internal/execution/dispatcher.go b/services/core/internal/execution/dispatcher.go index 0b647f820..1f0c5879a 100644 --- a/services/core/internal/execution/dispatcher.go +++ b/services/core/internal/execution/dispatcher.go @@ -57,8 +57,9 @@ type Dispatcher struct { } // Credentials opens the bearer token of a Session's frozen MCP credential -// binding. +// binding and checks that the bound Credentials still exist. type Credentials interface { + CheckMCPCredentials(context.Context, vaults.CheckMCPCredentials) error MCPBearerToken(context.Context, vaults.MCPBearerToken) (string, error) } diff --git a/services/core/internal/execution/mcp_support_test.go b/services/core/internal/execution/mcp_support_test.go index 725ccee36..be2ced897 100644 --- a/services/core/internal/execution/mcp_support_test.go +++ b/services/core/internal/execution/mcp_support_test.go @@ -187,3 +187,7 @@ func TestMCPStdioAdmission(t *testing.T) { t.Fatal(err, servers.MCP) } } + +func (c *recordingCredentials) CheckMCPCredentials(context.Context, vaults.CheckMCPCredentials) error { + return nil +} diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index b0068f5ad..0d48162cc 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "errors" + "slices" "sync" "time" "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/vaults" ) const DefaultExecutionConcurrency = 4 @@ -118,6 +120,9 @@ func (w *Worker) SubmitInputs(ctx context.Context, tenant, session, key string, if err := w.dispatcher.validateEngineInputs(value.Engine, value.Configuration, inputs); err != nil { return nil, err } + if err := w.checkInputCredentials(ctx, value, key, inputs); err != nil { + return nil, err + } if preparedEnvironmentConfiguration(value.Configuration) { return w.submitEnvironmentInputs(ctx, value, key, inputs) } @@ -127,6 +132,31 @@ func (w *Worker) SubmitInputs(ctx context.Context, tenant, session, key string, return w.admitInputs(ctx, tenant, session, key, inputs) } +// checkInputCredentials rejects a message that would start a Turn whose +// Session's frozen MCP Credential was deleted, as creation rejects an absent +// one. It runs before and outside the admission transaction, and never rejects +// a key the Session already used: admission replays that batch or reports the +// key conflict. A deletion after the check still fails at dispatch. +func (w *Worker) checkInputCredentials(ctx context.Context, session sessions.Session, key string, inputs []sessions.Input) error { + if (session.LastTurn != nil && !sessions.TerminalStatus(session.LastTurn.Status)) || + !slices.ContainsFunc(inputs, func(input sessions.Input) bool { return input.Kind == "message" }) { + return nil + } + var snapshot Snapshot + if json.Unmarshal(session.Configuration, &snapshot) != nil || len(snapshot.MCPCredentials) == 0 { + return nil + } + err := w.dispatcher.Credentials.CheckMCPCredentials(ctx, vaults.CheckMCPCredentials{TenantID: session.TenantID, VaultIDs: snapshot.VaultIDs, Bindings: snapshot.MCPCredentials}) + var selection *vaults.MCPCredentialSelectionError + if !errors.As(err, &selection) { + return err + } + if used, probeErr := w.dispatcher.Sessions.InputKeyUsed(ctx, session.TenantID, session.ID, key, inputs); probeErr != nil || used { + return probeErr + } + return err +} + // CreateSession validates execution support before the pooled Session // creation reserves or admits initial work, and wakes the scheduler once that // creation commits. diff --git a/services/core/internal/sessions/inputs.go b/services/core/internal/sessions/inputs.go index c4bb79be9..ce474d558 100644 --- a/services/core/internal/sessions/inputs.go +++ b/services/core/internal/sessions/inputs.go @@ -372,6 +372,27 @@ func (s *Service) SubmitInputs(ctx context.Context, tenant, session, key string, return receipts, nil } +// InputKeyUsed reports whether the Session already used key, by direct +// admission or an Environment input reservation, so that admission decides +// between a replay and a key conflict. +func (s *Service) InputKeyUsed(ctx context.Context, tenant, session, key string, inputs []Input) (bool, error) { + _, encoded, err := ValidateInputs(inputs) + if err != nil { + return false, err + } + var used bool + err = s.storage.WithInputs(ctx, tenant, session, func(ctx context.Context, tx InputTx) error { + receipts, _, err := tx.LoadInputBatch(ctx, key, encoded) + if err != nil { + return err + } + _, reserved, err := tx.FindInputReservation(ctx, key, encoded) + used = len(receipts) > 0 || reserved + return err + }) + return used, err +} + // FunctionCall retains public identity and its opaque execution-adapter reference. type FunctionCall struct { CallID, ExecutorCallID, Name string diff --git a/services/core/internal/vaults/service.go b/services/core/internal/vaults/service.go index 6143432ea..5c189e72d 100644 --- a/services/core/internal/vaults/service.go +++ b/services/core/internal/vaults/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "time" "github.com/google/uuid" @@ -226,6 +227,41 @@ func (s *Service) ResolveMCPCredentials(ctx context.Context, command ResolveMCPC return bindings, nil } +// CheckMCPCredentials asks whether the Credentials a Session's frozen bindings +// name still exist in its attached Vaults. +type CheckMCPCredentials struct { + TenantID string + VaultIDs []string + Bindings []MCPCredentialBinding +} + +// CheckMCPCredentials reads metadata only. A binding whose Credential, or whose +// Vault, was deleted reports the creation-time selection error for a +// Credential absent from the attached Vaults. +func (s *Service) CheckMCPCredentials(ctx context.Context, command CheckMCPCredentials) error { + tenant, ok := canonicalID(command.TenantID) + if !ok { + return ErrInvalidInput + } + attached, err := attachedVaultIDs(command.VaultIDs) + if err != nil { + return err + } + for _, binding := range command.Bindings { + if binding.CredentialID == "" { + continue + } + matches, err := s.storage.FindMCPCredentials(ctx, MCPCredentialQuery{TenantID: tenant, VaultIDs: attached, ServerURL: binding.ServerURL, CredentialID: binding.CredentialID}) + if err != nil { + return err + } + if !slices.ContainsFunc(matches, func(match MCPCredentialMatch) bool { return match.VaultID == binding.VaultID }) { + return mcpCredentialNotAttached(binding.CredentialID) + } + } + return nil +} + // MCPBearerToken asks for the bearer token of a Session's frozen binding. type MCPBearerToken struct { TenantID string diff --git a/services/core/tests/integration/mcp_credential_input_test.go b/services/core/tests/integration/mcp_credential_input_test.go new file mode 100644 index 000000000..a212b81e8 --- /dev/null +++ b/services/core/tests/integration/mcp_credential_input_test.go @@ -0,0 +1,81 @@ +package integration + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "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/vaults" +) + +// A message that would start a Turn is rejected once the Credential its +// Session bound is deleted, whether the tool named it or creation selected +// it, but a key the Session already used still replays or conflicts. +func TestWorkerRejectsInputWhoseCredentialWasDeleted(t *testing.T) { + for _, mode := range []string{"auto-selected", "explicit"} { + t.Run(mode, func(t *testing.T) { + h := newFunctionHarnessWith(t, functionConfiguration) + configuration, _ := mcpBearerWorkerConfiguration(t, h) + var snapshot execution.Snapshot + if err := json.Unmarshal([]byte(configuration), &snapshot); err != nil { + t.Fatal(err) + } + binding := snapshot.MCPCredentials[0] + if mode == "explicit" { + configuration = strings.Replace(configuration, `"credential_id":null`, `"credential_id":"`+binding.CredentialID+`"`, 1) + } + var err error + h.session, err = h.s.CreateSession(t.Context(), h.tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "bound", Configuration: json.RawMessage(configuration)}) + if err != nil { + t.Fatal(err) + } + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ + Streaming: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, + ExecutionControls: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, Preparation: proto.CapabilitySupported, + MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported})}}}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker := startWorker(t, ctx, h.s, h.d) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + <-done + }() + first, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "first", []sessions.Input{messageInput("one")}) + if err != nil { + t.Fatal(err) + } + transition(t, h.s, h.tenant, h.session.ID, first[0].TurnID, sessions.TurnQueued, sessions.TurnCancelled) + _, vaultService, err := fixtureVaults(h.s) + if err != nil { + t.Fatal(err) + } + if _, err := vaultService.DeleteCredential(ctx, vaults.DeleteCredential{TenantID: h.tenant, VaultID: binding.VaultID, CredentialID: binding.CredentialID}); err != nil { + t.Fatal(err) + } + var turns int + _, err = worker.SubmitInputs(ctx, h.tenant, h.session.ID, "second", []sessions.Input{messageInput("two")}) + var selection *vaults.MCPCredentialSelectionError + if !errors.As(err, &selection) || selection.Conflict { + t.Fatal("input after deletion was admitted", err) + } + if err := h.s.pool.QueryRow(ctx, "SELECT count(*) FROM turns WHERE session_id=$1", h.session.ID).Scan(&turns); err != nil || turns != 1 { + t.Fatal("rejected input created a Turn", turns, err) + } + replay, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "first", []sessions.Input{messageInput("one")}) + if err != nil || len(replay) != 1 || replay[0].TurnID != first[0].TurnID { + t.Fatal("retry of an admitted batch changed", replay, err) + } + if _, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "first", []sessions.Input{messageInput("other")}); !errors.Is(err, sessions.ErrIdempotencyConflict) { + t.Fatal("reused key with another batch", err) + } + }) + } +}