From 3f270b454f6f61488a4f8beb7f8c44f1ad2440ac Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Fri, 10 Jul 2026 09:04:06 +0800 Subject: [PATCH] feat: select explicit msglib receive source --- docs/current-status-card.md | 7 +- docs/msglib-read-sidecar-contract.md | 7 ++ .../capability-source-matrix.md | 4 +- ...tage-c-log-backed-receive-messages-loop.md | 54 +++++++++-- internal/tools/isphere_read.go | 34 ++++++- internal/tools/isphere_read_test.go | 96 +++++++++++++++++++ 6 files changed, 187 insertions(+), 15 deletions(-) diff --git a/docs/current-status-card.md b/docs/current-status-card.md index 5ff5d6e..e545eff 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -29,7 +29,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor - `isphere_receive_messages` accepts the read-contract argument set for the current local-readonly path: `conversation_id`, `query`, `since`, `limit`, `include_attachment_metadata`, `source_preference`, `preview`, and empty `cursor`. - `isphere_search_contacts` and `isphere_search_groups` accept and validate the safe search-contract args for the current local-readonly path and can optionally enrich results from the env-configured C29 `MsgLib.db` display-entity reader; default behavior remains log/JID-backed when MsgLib env vars are absent. - `isphere_receive_files` list mode accepts and validates the safe file-list contract args for the current local-readonly path while keeping download blocked. -- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks, bounded contact/group display-entity reads, message-source metadata, and sidecar-level bounded message listing; `internal/msglib` wraps it from Go, C30 wires display metadata as an optional MCP contact/group enrichment source, and C39 adds a Go receive-source adapter around `ListMessages` without changing the MCP default source. +- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks, bounded contact/group display-entity reads, message-source metadata, and sidecar-level bounded message listing; `internal/msglib` wraps it from Go, C30 wires display metadata as an optional MCP contact/group enrichment source, C39 adds a Go receive-source adapter around `ListMessages`, and C40 adds tool-level explicit `msglib_readonly` source selection without changing the MCP default source. - N12-pre and N12R documents define safe offline evidence intake and internal-sandbox live UIA capture procedures. - N13/N14/N15 produced selector catalog/report validation infrastructure, but those are supporting validation assets only. @@ -56,7 +56,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `Stage C / Loop C40 - explicit MsgLib receive source selection design/test slice`. +Current loop: `Stage C / Loop C41 - MsgLib receive env wiring and optional smoke`. Plan file: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`. @@ -108,10 +108,11 @@ Current loop output so far: 37. C37: `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md` defines the bounded DB-backed receive-message design: sidecar op `list_messages`, hard limit, no cursor initially, explicit `include_body`, no raw rows, no file path values, PacketReader remains default until reconciliation. 38. C38: added sidecar/Go-wrapper `list_messages`, clamped to `1..50`, cursor rejected, optional attachment metadata without path/download refs, and copied-DB verification with `include_body=false`; printed evidence is only count/source-table/safety booleans. 39. C39: added `internal/isphere.MsgLibMessageSource`, an injected-provider adapter that maps `msglib.ListMessages` results into the existing receive-message domain model, preserving explicit source refs and attachment metadata while leaving MCP default PacketReader behavior unchanged. +40. C40: added tool-level explicit receive source selection: empty/`auto`/`local_readonly` still use the primary PacketReader/log-backed source, while `source_preference="msglib_readonly"` uses a configured MsgLib receive source and is rejected when not configured. ## Next business mainline -1. Run Stage C Loop C40: define and test explicit MsgLib receive source selection behavior, keeping `auto`/default on PacketReader and enabling DB rows only through an explicit operator-selected path. +1. Run Stage C Loop C41: wire the MsgLib receive adapter into server env configuration behind explicit `source_preference="msglib_readonly"` and add an optional sanitized smoke; keep standard `verify-go-mcp.ps1` deterministic. 2. Leave `isphere_send_message` blocked until bridge/API, UIA action-chain, or protocol connector evidence is validated. 3. Leave actual file download/cache mapping as a later connector node until a message-to-cache hash or client download source is validated. 4. Keep UIA selector/report work as fallback support, not as the primary roadmap. diff --git a/docs/msglib-read-sidecar-contract.md b/docs/msglib-read-sidecar-contract.md index c788df9..64e9e79 100644 --- a/docs/msglib-read-sidecar-contract.md +++ b/docs/msglib-read-sidecar-contract.md @@ -300,6 +300,13 @@ Committed C39 implementation: - It preserves explicit `source`/`raw_ref` values and bounded attachment metadata while rejecting provider results that report non-read-only mode, raw rows, or file path values. - It is not wired into MCP default receive behavior; `auto` remains PacketReader/log-backed until an explicit source-selection loop is completed. +Committed C40 implementation: + +- Tool-layer selector: `RegisterISphereReadToolsWithReceiveSources`. +- `source_preference=""`, `auto`, and `local_readonly` continue to use the primary PacketReader/log-backed source. +- `source_preference="msglib_readonly"` can select an explicitly configured MsgLib receive source and is rejected when that source is absent. +- Server env wiring is deferred to C41, so standard MCP startup and verification remain deterministic. + ## Verification Default verification builds and checks `self_check` only: diff --git a/docs/source-discovery/capability-source-matrix.md b/docs/source-discovery/capability-source-matrix.md index bf346df..64238c5 100644 --- a/docs/source-discovery/capability-source-matrix.md +++ b/docs/source-discovery/capability-source-matrix.md @@ -17,7 +17,7 @@ Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md` | MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice | | --- | --- | --- | --- | --- | --- | -| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | copied `MsgLib.db` message tables through x86 read-only sidecar `list_messages`; decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md`; `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md` | C20 supports and validates the current safe receive-message contract args; C32 wires optional MsgLib display metadata into receive-message `sender_name` and `conversation.display_name`; C33 proves this path through a real copied-DB MCP smoke; C35 maps MsgLib message tables; C36 adds metadata-only `message_sources`; C37 defines bounded `list_messages` design; C38 implements sidecar/Go-wrapper `list_messages` with sanitized copied-DB verification; C39 adds a Go adapter from `ListMessages` to the receive-message domain model, while PacketReader remains MCP default until explicit source selection is tested | C40 explicit MsgLib receive source selection design/test slice | +| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | copied `MsgLib.db` message tables through x86 read-only sidecar `list_messages`; decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md`; `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md` | C20 supports and validates the current safe receive-message contract args; C32 wires optional MsgLib display metadata into receive-message `sender_name` and `conversation.display_name`; C33 proves this path through a real copied-DB MCP smoke; C35 maps MsgLib message tables; C36 adds metadata-only `message_sources`; C37 defines bounded `list_messages` design; C38 implements sidecar/Go-wrapper `list_messages` with sanitized copied-DB verification; C39 adds a Go adapter from `ListMessages` to the receive-message domain model; C40 adds tool-level explicit `msglib_readonly` source selection while `auto` remains PacketReader/log-backed | C41 MsgLib receive env wiring and optional sanitized smoke | | `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md` | C34 documents how to enable optional MsgLib contact display enrichment and verify sanitized MCP output; search enrichment remains available | C37 bounded DB-backed receive-source design | | `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents how to enable optional MsgLib group display enrichment and verify sanitized MCP output; search enrichment remains available | C37 bounded DB-backed receive-source design | | `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\` cache entries remain unlinked | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck` | C22 verifies safe file-list contract args; no log-to-cache/download mapping yet | defer download mapping; precheck contact/group display-name evidence | @@ -39,4 +39,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac ## Deferred source work -`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, and C39 adds a Go adapter from MsgLib list results to the receive-message domain model. The next step is explicit source-selection behavior; do not silently merge MsgLib rows into the MCP default until PacketReader-vs-DB reconciliation and operator selection are explicit. +`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, and C40 adds explicit tool-level `msglib_readonly` source selection. The next step is env wiring and optional smoke; do not silently merge MsgLib rows into the MCP default. diff --git a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md index 3d18a0d..adf1ae4 100644 --- a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md +++ b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md @@ -2762,17 +2762,59 @@ DB-backed receive source adapter result: - Standard `verify-go-mcp.ps1` should remain deterministic and should not require copied DB env. - Optional copied-DB verification can be added only if its printed output stays sanitized. -- [ ] **Step 1: Write source-selection RED test** +- [x] **Step 1: Write source-selection RED test** -Create a fake primary PacketReader source and fake MsgLib receive source; assert default/auto uses primary and explicit MsgLib selection uses the MsgLib source. +Added `TestISphereReceiveMessagesToolSelectsExplicitMsgLibSource` with fake primary and fake MsgLib receive sources. RED failed because `RegisterISphereReadToolsWithReceiveSources` did not exist. -- [ ] **Step 2: Implement minimal selector** +- [x] **Step 2: Implement minimal selector** -Add the smallest selector layer needed by the test. Keep current rejection behavior when explicit MsgLib is requested but not configured. +Added `RegisterISphereReadToolsWithReceiveSources` and `receiveMessagesSourceForPreference`. Empty/`auto`/`local_readonly` use the primary source; `msglib_readonly` uses the configured MsgLib receive source; explicit MsgLib is rejected when absent. -- [ ] **Step 3: Verify and update operator docs** +- [x] **Step 3: Verify and update operator docs** -Run full verification and update docs to describe the explicit opt-in route and the unchanged default route. +Updated status, matrix, contract, and this plan. Operator runbook/env wiring is deferred to C41 because C40 only proves the tool-layer selector. + +--- + +## Loop C40 Result + +Explicit MsgLib receive source selection result: + +- Added tool-level explicit source selection for `isphere_receive_messages`. +- Default/`auto` behavior remains PacketReader/log-backed and does not call the MsgLib receive source. +- `source_preference="msglib_readonly"` selects a configured MsgLib receive source and preserves `msglib:` raw refs through the response mapping. +- Explicit MsgLib selection is rejected when no MsgLib receive source is configured. +- No server env wiring or copied-DB MCP smoke is added in C40. + +--- + +## Loop C41: MsgLib receive env wiring and optional smoke + +**Goal:** Wire the C39 adapter and C40 selector into server env configuration so operators can explicitly call `isphere_receive_messages` with `source_preference="msglib_readonly"`, while keeping default verification deterministic. + +**Planned files:** +- Modify: `internal/mcpserver/server.go` and tests. +- Modify or extend: `scripts/verify-msglib-mcp-enrichment.ps1` or add a focused optional smoke. +- Modify docs/status/matrix/runbook/plan. + +**Planned behavior:** +- If full MsgLib env is configured, build both display enrichment source and MsgLib receive source from the same sidecar client. +- Empty/`auto` remains PacketReader/log-backed. +- Explicit `source_preference="msglib_readonly"` uses `MsgLibMessageSource`. +- Standard `scripts/verify-go-mcp.ps1` remains deterministic and should still report `msglib_configured=false`. +- Optional copied-DB smoke must print only counts/source refs/booleans and must not print message body values, file paths, raw rows, entity values, sends, downloads, hooks, injection, or DB writes. + +- [ ] **Step 1: Write server wiring RED test** + +Use injected/fake sources or env-shaped construction to prove the server can register both primary and MsgLib receive sources while default calls stay on primary. + +- [ ] **Step 2: Implement env wiring** + +Reuse `msglib.ConfigFromEnv`/client construction and pass `isphere.MsgLibMessageSource` into the C40 selector. Keep partial-env failure behavior. + +- [ ] **Step 3: Add optional sanitized smoke and docs** + +Extend optional verification to call explicit `msglib_readonly` only when copied DB env is present, printing sanitized result only. --- ## Self-Review diff --git a/internal/tools/isphere_read.go b/internal/tools/isphere_read.go index 9c92067..73e7528 100644 --- a/internal/tools/isphere_read.go +++ b/internal/tools/isphere_read.go @@ -35,6 +35,10 @@ func RegisterISphereReadTools(server *mcp.Server, source ReceiveMessagesSource) } func RegisterISphereReadToolsWithDisplayEntities(server *mcp.Server, source ReceiveMessagesSource, displaySource DisplayEntitySource) { + RegisterISphereReadToolsWithReceiveSources(server, source, nil, displaySource) +} + +func RegisterISphereReadToolsWithReceiveSources(server *mcp.Server, source ReceiveMessagesSource, msglibSource ReceiveMessagesSource, displaySource DisplayEntitySource) { if source == nil { source = isphere.EncryptedPacketLogSource{} } @@ -43,11 +47,15 @@ func RegisterISphereReadToolsWithDisplayEntities(server *mcp.Server, source Rece Name: ToolNameReceiveMessages, Description: "Return iSphere messages from the configured read-only log-backed source.", }, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveMessagesArgs) (*mcp.CallToolResult, map[string]any, error) { - if err := validateReceiveMessagesArgs(input); err != nil { + if err := validateReceiveMessagesCursor(input); err != nil { + return nil, nil, err + } + selectedSource, err := receiveMessagesSourceForPreference(input.SourcePreference, source, msglibSource) + if err != nil { return nil, nil, err } started := time.Now().UTC() - result, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{ + result, err := selectedSource.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{ ConversationID: input.ConversationID, Query: input.Query, Since: input.Since, @@ -71,8 +79,26 @@ func RegisterISphereReadToolsWithDisplayEntities(server *mcp.Server, source Rece }) } -func validateReceiveMessagesArgs(input ReceiveMessagesArgs) error { - return validateLocalReadonlySourceAndCursor(ToolNameReceiveMessages, input.SourcePreference, input.Cursor) +func validateReceiveMessagesCursor(input ReceiveMessagesArgs) error { + if strings.TrimSpace(input.Cursor) != "" { + return fmt.Errorf("%s cursor pagination is not available yet", ToolNameReceiveMessages) + } + return nil +} + +func receiveMessagesSourceForPreference(sourcePreferenceValue string, primarySource ReceiveMessagesSource, msglibSource ReceiveMessagesSource) (ReceiveMessagesSource, error) { + sourcePreference := strings.ToLower(strings.TrimSpace(sourcePreferenceValue)) + switch sourcePreference { + case "", "auto", "local_readonly": + return primarySource, nil + case "msglib_readonly": + if msglibSource == nil { + return nil, fmt.Errorf("%s source_preference %q is not configured", ToolNameReceiveMessages, sourcePreferenceValue) + } + return msglibSource, nil + default: + return nil, fmt.Errorf("%s source_preference %q is not available; only auto, local_readonly, and msglib_readonly are supported", ToolNameReceiveMessages, sourcePreferenceValue) + } } func validateLocalReadonlySourceAndCursor(toolName string, sourcePreferenceValue string, cursorValue string) error { diff --git a/internal/tools/isphere_read_test.go b/internal/tools/isphere_read_test.go index 9c8c976..5e1bea9 100644 --- a/internal/tools/isphere_read_test.go +++ b/internal/tools/isphere_read_test.go @@ -213,6 +213,79 @@ func TestISphereReceiveMessagesToolUsesInjectedDisplayEntities(t *testing.T) { } } +func TestISphereReceiveMessagesToolSelectsExplicitMsgLibSource(t *testing.T) { + primary := &fakeReceiveMessagesSource{ + result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ + ID: "packet-msg-1", + ConversationID: "packet-conversation", + ConversationType: "chat", + SenderID: "packet-sender", + ReceiverID: "packet-receiver", + Text: "packet body", + Timestamp: "1783423807000", + }}}, + } + msglibSource := &fakeReceiveMessagesSource{ + result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ + ID: "db-msg-1", + ConversationID: "db-conversation", + ConversationType: "group", + SenderID: "db-sender", + ReceiverID: "", + Text: "db body", + Timestamp: "1783423808000", + Source: "local_readonly", + RawRef: "msglib:tblMsgGroupPersonMsg", + }}}, + } + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereReadToolsWithReceiveSources(server, primary, msglibSource, nil) + }) + defer cleanup() + + defaultResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameReceiveMessages, + Arguments: map[string]any{"limit": 5}, + }) + if err != nil { + t.Fatalf("default call %s: %v", ToolNameReceiveMessages, err) + } + defaultPayload := decodeReceiveMessagesForSelectionTest(t, defaultResult.StructuredContent) + if len(defaultPayload.Messages) != 1 || defaultPayload.Messages[0].ID != "packet-msg-1" { + t.Fatalf("default messages = %+v, want packet source", defaultPayload.Messages) + } + if len(primary.queries) != 1 || len(msglibSource.queries) != 0 { + t.Fatalf("default source calls primary=%d msglib=%d", len(primary.queries), len(msglibSource.queries)) + } + + dbResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameReceiveMessages, + Arguments: map[string]any{"source_preference": "msglib_readonly", "query": "db", "limit": 3}, + }) + if err != nil { + t.Fatalf("msglib call %s: %v", ToolNameReceiveMessages, err) + } + dbPayload := decodeReceiveMessagesForSelectionTest(t, dbResult.StructuredContent) + if len(dbPayload.Messages) != 1 || dbPayload.Messages[0].ID != "db-msg-1" || dbPayload.Messages[0].RawRef != "msglib:tblMsgGroupPersonMsg" { + t.Fatalf("db messages = %+v, want explicit msglib source", dbPayload.Messages) + } + if len(msglibSource.queries) != 1 || msglibSource.queries[0].Query != "db" || msglibSource.queries[0].Limit != 3 { + t.Fatalf("msglib source queries = %+v", msglibSource.queries) + } + + missingMsgLibSession, missingCleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereReadToolsWithReceiveSources(server, primary, nil, nil) + }) + defer missingCleanup() + missingResult, err := missingMsgLibSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameReceiveMessages, + Arguments: map[string]any{"source_preference": "msglib_readonly", "limit": 1}, + }) + if err == nil && !missingResult.IsError { + t.Fatalf("explicit msglib source was accepted without a configured MsgLib receive source") + } +} + func TestISphereReceiveMessagesToolValidatesRemainingContractArgs(t *testing.T) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ @@ -282,6 +355,29 @@ func TestISphereReceiveMessagesToolValidatesRemainingContractArgs(t *testing.T) } } +func decodeReceiveMessagesForSelectionTest(t *testing.T, structured any) struct { + Messages []struct { + ID string `json:"id"` + RawRef string `json:"raw_ref"` + } `json:"messages"` +} { + t.Helper() + var decoded struct { + Messages []struct { + ID string `json:"id"` + RawRef string `json:"raw_ref"` + } `json:"messages"` + } + payload, err := json.Marshal(structured) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode structured content %s: %v", payload, err) + } + return decoded +} + type fakeReceiveMessagesSource struct { queries []isphere.ReceiveMessagesQuery result isphere.ReceiveMessagesResult