feat: align receive messages response contract
This commit is contained in:
@@ -25,6 +25,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
|
|||||||
- `isphere_receive_files`
|
- `isphere_receive_files`
|
||||||
- C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract.
|
- C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract.
|
||||||
- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, eight-tool MCP surface, default empty-source receive/contact/group/file calls, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke.
|
- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, eight-tool MCP surface, default empty-source receive/contact/group/file calls, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke.
|
||||||
|
- `isphere_receive_messages` now returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope while retaining legacy parser-native message fields for compatibility.
|
||||||
- N12-pre and N12R documents define safe offline evidence intake and internal-sandbox live UIA capture procedures.
|
- 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.
|
- N13/N14/N15 produced selector catalog/report validation infrastructure, but those are supporting validation assets only.
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
|
|||||||
|
|
||||||
## Current loop
|
## Current loop
|
||||||
|
|
||||||
Current loop: `Stage C / Loop C17 - read-tool contract alignment precheck`.
|
Current loop: `Stage C / Loop C18 - receive-message query/filter contract slice`.
|
||||||
|
|
||||||
Plan file: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`.
|
Plan file: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`.
|
||||||
|
|
||||||
@@ -80,10 +81,11 @@ Current loop output so far:
|
|||||||
14. C14: `isphere_receive_files` MCP list mode registered and verify-go-mcp now covers eight tools plus default/configured file-list smoke.
|
14. C14: `isphere_receive_files` MCP list mode registered and verify-go-mcp now covers eight tools plus default/configured file-list smoke.
|
||||||
15. C15: `isphere_send_message` source precheck found no validated write connector; send implementation is blocked pending bridge/API, UIA action, or protocol evidence.
|
15. C15: `isphere_send_message` source precheck found no validated write connector; send implementation is blocked pending bridge/API, UIA action, or protocol evidence.
|
||||||
16. C16: configured PacketReader source hardening added `ISPHERE_PACKET_LOG_DIR`, deterministic recursive `.log`/`.txt` directory loading, ambiguous file/dir config failure, and configured-directory receive/contact/group/file smoke verification.
|
16. C16: configured PacketReader source hardening added `ISPHERE_PACKET_LOG_DIR`, deterministic recursive `.log`/`.txt` directory loading, ambiguous file/dir config failure, and configured-directory receive/contact/group/file smoke verification.
|
||||||
|
17. C17: `isphere_receive_messages` output was aligned to the core read contract envelope and message fields while retaining legacy aliases; verify-go-mcp now asserts contract fields for configured file and directory source smokes.
|
||||||
|
|
||||||
## Next business mainline
|
## Next business mainline
|
||||||
|
|
||||||
1. Run Stage C Loop C17: audit and align the registered read-tool outputs against `docs/mcp-core-tools-contract.md`, starting with `isphere_receive_messages` because it is currently the least contract-shaped output.
|
1. Run Stage C Loop C18: add the first safe `isphere_receive_messages` query/filter slice (`conversation_id`, `query`) so digital employees can target configured local-readonly evidence more precisely.
|
||||||
2. Leave `isphere_send_message` blocked until bridge/API, UIA action-chain, or protocol connector evidence is validated.
|
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.
|
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.
|
4. Keep UIA selector/report work as fallback support, not as the primary roadmap.
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ The verification confirms:
|
|||||||
- Exactly eight tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files`.
|
- Exactly eight tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files`.
|
||||||
- `win_helper_version` returns `ISphereWinHelper`.
|
- `win_helper_version` returns `ISphereWinHelper`.
|
||||||
- `isphere_receive_messages` is callable and returns an empty `messages` array from the default empty source.
|
- `isphere_receive_messages` is callable and returns an empty `messages` array from the default empty source.
|
||||||
|
- `isphere_receive_messages` returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope, while preserving legacy message aliases such as `id`, `text`, and `timestamp`.
|
||||||
- A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message.
|
- A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message.
|
||||||
- A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture.
|
- A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture.
|
||||||
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture.
|
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture.
|
||||||
@@ -172,7 +173,7 @@ These eight tools are currently allowed:
|
|||||||
| `win_helper_self_check` | helper `self_check` | Read desktop/UIA availability status. |
|
| `win_helper_self_check` | helper `self_check` | Read desktop/UIA availability status. |
|
||||||
| `win_helper_scan_windows` | helper `scan_windows` | Read visible window metadata or likely iSphere candidates. |
|
| `win_helper_scan_windows` | helper `scan_windows` | Read visible window metadata or likely iSphere candidates. |
|
||||||
| `win_helper_dump_uia` | helper `dump_uia` | Read a UI Automation tree for a specified window handle. |
|
| `win_helper_dump_uia` | helper `dump_uia` | Read a UI Automation tree for a specified window handle. |
|
||||||
| `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. |
|
| `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source with a contract-shaped response envelope. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. |
|
||||||
| `isphere_search_contacts` | log-backed message JIDs | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source. |
|
| `isphere_search_contacts` | log-backed message JIDs | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source. |
|
||||||
| `isphere_search_groups` | log-backed groupchat JIDs | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source. |
|
| `isphere_search_groups` | log-backed groupchat JIDs | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source. |
|
||||||
| `isphere_receive_files` | log-backed file-transfer messages | List file metadata candidates extracted from file-transfer message bodies. Download/cache mapping is not implemented yet. |
|
| `isphere_receive_files` | log-backed file-transfer messages | List file metadata candidates extracted from file-transfer message bodies. Download/cache mapping is not implemented yet. |
|
||||||
@@ -201,7 +202,7 @@ The current runbook verifies eight tools:
|
|||||||
- `isphere_search_groups`
|
- `isphere_search_groups`
|
||||||
- `isphere_receive_files`
|
- `isphere_receive_files`
|
||||||
|
|
||||||
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_files` currently supports list mode only; download/cache mapping is deferred.
|
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility. `isphere_receive_files` currently supports list mode only; download/cache mapping is deferred.
|
||||||
|
|
||||||
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send capabilities enter the roadmap through that contract after source discovery, connector selection, preview metadata, execution metadata, and audit records are in place.
|
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send capabilities enter the roadmap through that contract after source discovery, connector selection, preview metadata, execution metadata, and audit records are in place.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | C16 env loader supports one encrypted file or a deterministic `.log`/`.txt` directory source; default source remains empty when unset | read-tool output contract alignment |
|
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | C17 response envelope and message fields align to the read contract while keeping legacy aliases; default source remains empty when unset | add `conversation_id` and `query` filters |
|
||||||
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | decrypted roster/contact stanzas from `Smark.SendReceive`; `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | C16 verification covers default, configured-file, and configured-directory contact smoke; JID-only display/account fallback | enrich display names from roster/db/uia evidence |
|
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | decrypted roster/contact stanzas from `Smark.SendReceive`; `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | C16 verification covers default, configured-file, and configured-directory contact smoke; JID-only display/account fallback | enrich display names from roster/db/uia evidence |
|
||||||
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C16 verification covers default, configured-file, and configured-directory group smoke; JID-only display fallback | enrich group display/member data from roster/db/uia evidence |
|
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C16 verification covers default, configured-file, and configured-directory group smoke; JID-only display fallback | enrich group display/member data from roster/db/uia evidence |
|
||||||
| `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\<hash>` 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` | C16 verification covers default, configured-file, and configured-directory file-list smoke; no log-to-cache/download mapping yet | defer download mapping; align read-tool contract output |
|
| `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\<hash>` 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` | C16 verification covers default, configured-file, and configured-directory file-list smoke; no log-to-cache/download mapping yet | defer download mapping; align read-tool contract output |
|
||||||
|
|||||||
@@ -1517,15 +1517,15 @@ Implemented and verified in this loop:
|
|||||||
- Do not change send-message or send-file behavior in this loop.
|
- Do not change send-message or send-file behavior in this loop.
|
||||||
- If output-shape changes are backward-compatible and small, implement them with tests; if they are breaking or broad, document the compatibility decision and write the next implementation loop.
|
- If output-shape changes are backward-compatible and small, implement them with tests; if they are breaking or broad, document the compatibility decision and write the next implementation loop.
|
||||||
|
|
||||||
- [ ] **Step 1: Run contract/output precheck**
|
- [x] **Step 1: Run contract/output precheck**
|
||||||
|
|
||||||
Inspect current tool registration code and tests, then record exact gaps against `docs/mcp-core-tools-contract.md`.
|
Inspect current tool registration code and tests, then record exact gaps against `docs/mcp-core-tools-contract.md`.
|
||||||
|
|
||||||
- [ ] **Step 2: Choose one small fix or defer**
|
- [x] **Step 2: Choose one small fix or defer**
|
||||||
|
|
||||||
If a small non-breaking fix is available, write the failing test first. Otherwise update docs with the contract gap and rewrite C18 as the implementation node.
|
If a small non-breaking fix is available, write the failing test first. Otherwise update docs with the contract gap and rewrite C18 as the implementation node.
|
||||||
|
|
||||||
- [ ] **Step 3: Verify and commit**
|
- [x] **Step 3: Verify and commit**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -1540,6 +1540,66 @@ Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Loop C17 Result
|
||||||
|
|
||||||
|
Implemented and verified in this loop:
|
||||||
|
|
||||||
|
- `isphere_receive_messages` now returns a contract-shaped top-level object with `ok`, `conversation`, `messages`, `next_cursor`, and `audit`.
|
||||||
|
- Each returned message now includes contract-facing fields: `message_id`, `sender_name`, `content_type`, `content_text`, `attachments`, `created_at`, `received_at`, `source`, and `raw_ref`.
|
||||||
|
- The implementation keeps the legacy parser-native fields (`id`, `conversation_id`, `conversation_type`, `receiver_id`, `text`, `timestamp`, `subject`, `receipt_id`, `read`) to avoid breaking current tests/scripts and operator consumers.
|
||||||
|
- File-transfer messages now expose attachment metadata derived from the same log-backed file-candidate extraction used by `isphere_receive_files`; download refs remain null until cache/download mapping is validated.
|
||||||
|
- `scripts/verify-go-mcp.ps1` now asserts the receive-message contract fields for configured file and configured directory source smoke tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loop C18: Receive-message query/filter contract slice
|
||||||
|
|
||||||
|
**Goal:** Add the first safe query/filter slice for `isphere_receive_messages` so a digital employee can read a specific conversation or keyword match from the configured local readonly source instead of only using `limit`.
|
||||||
|
|
||||||
|
**Planned files:**
|
||||||
|
- Modify: `internal/isphere/source.go`
|
||||||
|
- Modify: `internal/isphere/source_test.go`
|
||||||
|
- Modify: `internal/tools/isphere_read.go`
|
||||||
|
- Modify: `internal/tools/isphere_read_test.go`
|
||||||
|
- Modify: `scripts/verify-go-mcp.ps1`
|
||||||
|
- Modify: `docs/go-mcp-runbook.md`
|
||||||
|
- Modify: `docs/current-status-card.md`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`
|
||||||
|
|
||||||
|
**Planned behavior:**
|
||||||
|
- Extend `ReceiveMessagesQuery` and `ReceiveMessagesArgs` with `conversation_id` and `query` first; keep `since`, `cursor`, and `source_preference` deferred unless a small test proves they are safe.
|
||||||
|
- Filter normalized messages by exact `conversation_id` when supplied.
|
||||||
|
- Filter by case-insensitive keyword match over message text, subject, sender id, receiver id, and conversation id when `query` is supplied.
|
||||||
|
- Preserve current empty-source behavior and current `limit` behavior.
|
||||||
|
- Do not add write/send/file-download behavior.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing source/query test**
|
||||||
|
|
||||||
|
Extend `internal/isphere/source_test.go` with redacted fixtures proving `conversation_id` and keyword filtering.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement source query filtering**
|
||||||
|
|
||||||
|
Apply filters after decrypt/parse/normalize and before limit truncation, keeping deterministic source order.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire MCP args and verification**
|
||||||
|
|
||||||
|
Expose `conversation_id` and `query` on `isphere_receive_messages`, update tool tests and `verify-go-mcp.ps1` synthetic smoke.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run full verification and commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git diff --check
|
||||||
|
go test ./...
|
||||||
|
go build ./cmd/isphere-mcp
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. Commit exact C18 paths only and rewrite the next loop from actual results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Self-Review
|
## Self-Review
|
||||||
|
|
||||||
- Spec coverage: the plan follows the user's cyclic requirement: plan first, implement one loop at a time, update the next loop after each result, and ask only on blockers.
|
- Spec coverage: the plan follows the user's cyclic requirement: plan first, implement one loop at a time, update the next loop after each result, and ask only on blockers.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package tools
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
@@ -27,29 +30,124 @@ func RegisterISphereReadTools(server *mcp.Server, source ReceiveMessagesSource)
|
|||||||
Name: ToolNameReceiveMessages,
|
Name: ToolNameReceiveMessages,
|
||||||
Description: "Return iSphere messages from the configured read-only log-backed source.",
|
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) {
|
}, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveMessagesArgs) (*mcp.CallToolResult, map[string]any, error) {
|
||||||
|
started := time.Now().UTC()
|
||||||
result, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: input.Limit})
|
result, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: input.Limit})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return nil, receiveMessagesResultToMap(result), nil
|
return nil, receiveMessagesResultToMap(result, started, time.Now().UTC()), nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func receiveMessagesResultToMap(result isphere.ReceiveMessagesResult) map[string]any {
|
func receiveMessagesResultToMap(result isphere.ReceiveMessagesResult, started time.Time, finished time.Time) map[string]any {
|
||||||
messages := make([]map[string]any, 0, len(result.Messages))
|
messages := make([]map[string]any, 0, len(result.Messages))
|
||||||
for _, message := range result.Messages {
|
for _, message := range result.Messages {
|
||||||
|
attachments := receiveMessageAttachments(message)
|
||||||
messages = append(messages, map[string]any{
|
messages = append(messages, map[string]any{
|
||||||
"id": message.ID,
|
"id": message.ID,
|
||||||
|
"message_id": message.ID,
|
||||||
"conversation_id": message.ConversationID,
|
"conversation_id": message.ConversationID,
|
||||||
"conversation_type": message.ConversationType,
|
"conversation_type": message.ConversationType,
|
||||||
"sender_id": message.SenderID,
|
"sender_id": message.SenderID,
|
||||||
|
"sender_name": nil,
|
||||||
"receiver_id": message.ReceiverID,
|
"receiver_id": message.ReceiverID,
|
||||||
"text": message.Text,
|
"text": message.Text,
|
||||||
|
"content_type": receiveMessageContentType(message, attachments),
|
||||||
|
"content_text": message.Text,
|
||||||
|
"attachments": attachments,
|
||||||
"timestamp": message.Timestamp,
|
"timestamp": message.Timestamp,
|
||||||
|
"created_at": packetTimestampToRFC3339(message.Timestamp),
|
||||||
|
"received_at": nil,
|
||||||
"subject": message.Subject,
|
"subject": message.Subject,
|
||||||
"receipt_id": message.ReceiptID,
|
"receipt_id": message.ReceiptID,
|
||||||
"read": message.Read,
|
"read": message.Read,
|
||||||
|
"source": "local_readonly",
|
||||||
|
"raw_ref": "packetlog_message",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return map[string]any{"messages": messages}
|
return map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"conversation": receiveMessagesConversation(result.Messages),
|
||||||
|
"messages": messages,
|
||||||
|
"next_cursor": nil,
|
||||||
|
"audit": map[string]any{
|
||||||
|
"tool": ToolNameReceiveMessages,
|
||||||
|
"source": "local_readonly",
|
||||||
|
"execution_mode": "read",
|
||||||
|
"started_at": started.Format(time.RFC3339Nano),
|
||||||
|
"finished_at": finished.Format(time.RFC3339Nano),
|
||||||
|
"result": "ok",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveMessagesConversation(messages []isphere.Message) any {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
first := messages[0]
|
||||||
|
return map[string]any{
|
||||||
|
"conversation_id": first.ConversationID,
|
||||||
|
"conversation_type": contractConversationType(first.ConversationType),
|
||||||
|
"display_name": first.ConversationID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contractConversationType(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "groupchat", "group":
|
||||||
|
return "group"
|
||||||
|
case "chat", "direct":
|
||||||
|
return "direct"
|
||||||
|
case "system":
|
||||||
|
return "system"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveMessageAttachments(message isphere.Message) []map[string]any {
|
||||||
|
files := isphere.ListFilesFromMessages([]isphere.Message{message}, isphere.ListFilesQuery{})
|
||||||
|
attachments := make([]map[string]any, 0, len(files.Files))
|
||||||
|
for _, file := range files.Files {
|
||||||
|
attachments = append(attachments, map[string]any{
|
||||||
|
"file_id": file.FileID,
|
||||||
|
"file_name": file.FileName,
|
||||||
|
"size_bytes": file.SizeBytes,
|
||||||
|
"download_ref": nullableString(file.DownloadRef),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveMessageContentType(message isphere.Message, attachments []map[string]any) string {
|
||||||
|
if len(attachments) > 0 {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message.Text) != "" {
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message.Subject) != "" {
|
||||||
|
return "notify"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func packetTimestampToRFC3339(value string) any {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
epoch, err := strconv.ParseInt(trimmed, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ts time.Time
|
||||||
|
if epoch > 1_000_000_000_000 {
|
||||||
|
ts = time.UnixMilli(epoch)
|
||||||
|
} else {
|
||||||
|
ts = time.Unix(epoch, 0)
|
||||||
|
}
|
||||||
|
formatted := ts.UTC().Format(time.RFC3339)
|
||||||
|
return formatted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestISphereReceiveMessagesToolReturnsSourceMessages(t *testing.T) {
|
|||||||
ConversationType: "chat",
|
ConversationType: "chat",
|
||||||
SenderID: "sender@imopenfire1-lanzhou",
|
SenderID: "sender@imopenfire1-lanzhou",
|
||||||
ReceiverID: "receiver@imopenfire1-lanzhou",
|
ReceiverID: "receiver@imopenfire1-lanzhou",
|
||||||
Text: "redacted",
|
Text: "redacted-report.docx",
|
||||||
Timestamp: "1783423807000",
|
Timestamp: "1783423807000",
|
||||||
Subject: "FILE_TRANSFER_CANCEL",
|
Subject: "FILE_TRANSFER_CANCEL",
|
||||||
ReceiptID: "receipt-1",
|
ReceiptID: "receipt-1",
|
||||||
@@ -55,18 +55,46 @@ func TestISphereReceiveMessagesToolReturnsSourceMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var decoded struct {
|
var decoded struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
NextCursor *string `json:"next_cursor"`
|
||||||
|
Conversation *struct {
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
ConversationType string `json:"conversation_type"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"conversation"`
|
||||||
Messages []struct {
|
Messages []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
ConversationID string `json:"conversation_id"`
|
ConversationID string `json:"conversation_id"`
|
||||||
ConversationType string `json:"conversation_type"`
|
ConversationType string `json:"conversation_type"`
|
||||||
SenderID string `json:"sender_id"`
|
SenderID string `json:"sender_id"`
|
||||||
|
SenderName *string `json:"sender_name"`
|
||||||
ReceiverID string `json:"receiver_id"`
|
ReceiverID string `json:"receiver_id"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
ContentText string `json:"content_text"`
|
||||||
|
Attachments []struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
DownloadRef any `json:"download_ref"`
|
||||||
|
} `json:"attachments"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
|
CreatedAt *string `json:"created_at"`
|
||||||
|
ReceivedAt any `json:"received_at"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
ReceiptID string `json:"receipt_id"`
|
ReceiptID string `json:"receipt_id"`
|
||||||
Read bool `json:"read"`
|
Read bool `json:"read"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
RawRef string `json:"raw_ref"`
|
||||||
} `json:"messages"`
|
} `json:"messages"`
|
||||||
|
Audit struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
ExecutionMode string `json:"execution_mode"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
FinishedAt string `json:"finished_at"`
|
||||||
|
} `json:"audit"`
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(callResult.StructuredContent)
|
payload, err := json.Marshal(callResult.StructuredContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,19 +106,40 @@ func TestISphereReceiveMessagesToolReturnsSourceMessages(t *testing.T) {
|
|||||||
if len(decoded.Messages) != 1 {
|
if len(decoded.Messages) != 1 {
|
||||||
t.Fatalf("messages = %+v, want one", decoded.Messages)
|
t.Fatalf("messages = %+v, want one", decoded.Messages)
|
||||||
}
|
}
|
||||||
|
if !decoded.OK || decoded.NextCursor != nil {
|
||||||
|
t.Fatalf("ok/next_cursor = %v/%v", decoded.OK, decoded.NextCursor)
|
||||||
|
}
|
||||||
|
if decoded.Conversation == nil {
|
||||||
|
t.Fatalf("conversation missing")
|
||||||
|
}
|
||||||
|
if decoded.Conversation.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" || decoded.Conversation.DisplayName == "" {
|
||||||
|
t.Fatalf("unexpected conversation: %+v", decoded.Conversation)
|
||||||
|
}
|
||||||
msg := decoded.Messages[0]
|
msg := decoded.Messages[0]
|
||||||
if msg.ID != "msg-1" || msg.Text != "redacted" || msg.Subject != "FILE_TRANSFER_CANCEL" {
|
if msg.ID != "msg-1" || msg.MessageID != "msg-1" || msg.Text != "redacted-report.docx" || msg.ContentText != "redacted-report.docx" || msg.Subject != "FILE_TRANSFER_CANCEL" {
|
||||||
t.Fatalf("unexpected message content: %+v", msg)
|
t.Fatalf("unexpected message content: %+v", msg)
|
||||||
}
|
}
|
||||||
|
if msg.ContentType != "file" {
|
||||||
|
t.Fatalf("content_type = %q, want file", msg.ContentType)
|
||||||
|
}
|
||||||
|
if len(msg.Attachments) != 1 || msg.Attachments[0].FileID != "msg-1:redacted-report.docx" || msg.Attachments[0].FileName != "redacted-report.docx" || msg.Attachments[0].DownloadRef != nil {
|
||||||
|
t.Fatalf("attachments = %+v", msg.Attachments)
|
||||||
|
}
|
||||||
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
|
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
|
||||||
t.Fatalf("conversation_id = %q", msg.ConversationID)
|
t.Fatalf("conversation_id = %q", msg.ConversationID)
|
||||||
}
|
}
|
||||||
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" {
|
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" {
|
||||||
t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID)
|
t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID)
|
||||||
}
|
}
|
||||||
if msg.Timestamp != "1783423807000" || msg.ReceiptID != "receipt-1" || !msg.Read {
|
if msg.Timestamp != "1783423807000" || msg.CreatedAt == nil || *msg.CreatedAt != "2026-07-07T11:30:07Z" || msg.ReceiptID != "receipt-1" || !msg.Read {
|
||||||
t.Fatalf("metadata = %+v", msg)
|
t.Fatalf("metadata = %+v", msg)
|
||||||
}
|
}
|
||||||
|
if msg.SenderName != nil || msg.ReceivedAt != nil || msg.Source != "local_readonly" || msg.RawRef != "packetlog_message" {
|
||||||
|
t.Fatalf("contract metadata = %+v", msg)
|
||||||
|
}
|
||||||
|
if decoded.Audit.Tool != ToolNameReceiveMessages || decoded.Audit.Source != "local_readonly" || decoded.Audit.ExecutionMode != "read" || decoded.Audit.Result != "ok" || decoded.Audit.StartedAt == "" || decoded.Audit.FinishedAt == "" {
|
||||||
|
t.Fatalf("unexpected audit: %+v", decoded.Audit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeReceiveMessagesSource struct {
|
type fakeReceiveMessagesSource struct {
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ func main() {
|
|||||||
if err := json.Unmarshal(receiveEncoded, &receivePayload); err != nil {
|
if err := json.Unmarshal(receiveEncoded, &receivePayload); err != nil {
|
||||||
fail("tools/call isphere_receive_messages", fmt.Errorf("decode structuredContent %s: %w", receiveEncoded, err))
|
fail("tools/call isphere_receive_messages", fmt.Errorf("decode structuredContent %s: %w", receiveEncoded, err))
|
||||||
}
|
}
|
||||||
|
if receivePayload["ok"] != true {
|
||||||
|
fail("tools/call isphere_receive_messages", fmt.Errorf("ok = %#v, want true", receivePayload["ok"]))
|
||||||
|
}
|
||||||
messagesValue, ok := receivePayload["messages"].([]any)
|
messagesValue, ok := receivePayload["messages"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
fail("tools/call isphere_receive_messages", fmt.Errorf("messages missing or not array: %s", receiveEncoded))
|
fail("tools/call isphere_receive_messages", fmt.Errorf("messages missing or not array: %s", receiveEncoded))
|
||||||
@@ -321,6 +324,17 @@ func verifyConfiguredReceiveSource(ctx context.Context) (int, int, int, int) {
|
|||||||
if message["id"] != "msg-fixture-1" || message["text"] != "redacted-report.docx" || message["subject"] != "FILE_TRANSFER_CANCEL" {
|
if message["id"] != "msg-fixture-1" || message["text"] != "redacted-report.docx" || message["subject"] != "FILE_TRANSFER_CANCEL" {
|
||||||
fail("fixture/assert", fmt.Errorf("unexpected message: %#v", message))
|
fail("fixture/assert", fmt.Errorf("unexpected message: %#v", message))
|
||||||
}
|
}
|
||||||
|
if message["message_id"] != "msg-fixture-1" || message["content_text"] != "redacted-report.docx" || message["content_type"] != "file" || message["source"] != "local_readonly" || message["raw_ref"] != "packetlog_message" {
|
||||||
|
fail("fixture/assert_contract", fmt.Errorf("unexpected contract message fields: %#v", message))
|
||||||
|
}
|
||||||
|
attachments, ok := message["attachments"].([]any)
|
||||||
|
if !ok || len(attachments) != 1 {
|
||||||
|
fail("fixture/assert_contract", fmt.Errorf("attachments = %#v, want one", message["attachments"]))
|
||||||
|
}
|
||||||
|
attachment, ok := attachments[0].(map[string]any)
|
||||||
|
if !ok || attachment["file_id"] != "msg-fixture-1:redacted-report.docx" || attachment["file_name"] != "redacted-report.docx" || attachment["download_ref"] != nil {
|
||||||
|
fail("fixture/assert_contract", fmt.Errorf("unexpected attachment: %#v", attachments[0]))
|
||||||
|
}
|
||||||
if message["conversation_id"] != "project-room@conference.imopenfire1-lanzhou|sender@imopenfire1-lanzhou" {
|
if message["conversation_id"] != "project-room@conference.imopenfire1-lanzhou|sender@imopenfire1-lanzhou" {
|
||||||
fail("fixture/assert", fmt.Errorf("conversation_id = %#v", message["conversation_id"]))
|
fail("fixture/assert", fmt.Errorf("conversation_id = %#v", message["conversation_id"]))
|
||||||
}
|
}
|
||||||
@@ -427,6 +441,17 @@ func verifyConfiguredDirectorySource(ctx context.Context) (int, int, int, int) {
|
|||||||
if message["id"] != "msg-dir-fixture-1" || message["text"] != "redacted-dir-report.docx" || message["subject"] != "FILE_TRANSFER_CANCEL" {
|
if message["id"] != "msg-dir-fixture-1" || message["text"] != "redacted-dir-report.docx" || message["subject"] != "FILE_TRANSFER_CANCEL" {
|
||||||
fail("dir_fixture/assert", fmt.Errorf("unexpected message: %#v", message))
|
fail("dir_fixture/assert", fmt.Errorf("unexpected message: %#v", message))
|
||||||
}
|
}
|
||||||
|
if message["message_id"] != "msg-dir-fixture-1" || message["content_text"] != "redacted-dir-report.docx" || message["content_type"] != "file" || message["source"] != "local_readonly" || message["raw_ref"] != "packetlog_message" {
|
||||||
|
fail("dir_fixture/assert_contract", fmt.Errorf("unexpected contract message fields: %#v", message))
|
||||||
|
}
|
||||||
|
attachments, ok := message["attachments"].([]any)
|
||||||
|
if !ok || len(attachments) != 1 {
|
||||||
|
fail("dir_fixture/assert_contract", fmt.Errorf("attachments = %#v, want one", message["attachments"]))
|
||||||
|
}
|
||||||
|
attachment, ok := attachments[0].(map[string]any)
|
||||||
|
if !ok || attachment["file_id"] != "msg-dir-fixture-1:redacted-dir-report.docx" || attachment["file_name"] != "redacted-dir-report.docx" || attachment["download_ref"] != nil {
|
||||||
|
fail("dir_fixture/assert_contract", fmt.Errorf("unexpected attachment: %#v", attachments[0]))
|
||||||
|
}
|
||||||
contactCount := verifySearchContacts(ctx, session, "sender", "dir_fixture/call isphere_search_contacts", 1)
|
contactCount := verifySearchContacts(ctx, session, "sender", "dir_fixture/call isphere_search_contacts", 1)
|
||||||
groupCount := verifySearchGroups(ctx, session, "project", "dir_fixture/call isphere_search_groups", 1)
|
groupCount := verifySearchGroups(ctx, session, "project", "dir_fixture/call isphere_search_groups", 1)
|
||||||
fileCount := verifyReceiveFiles(ctx, session, "dir-report", "dir_fixture/call isphere_receive_files", 1, "msg-dir-fixture-1:redacted-dir-report.docx", "redacted-dir-report.docx")
|
fileCount := verifyReceiveFiles(ctx, session, "dir-report", "dir_fixture/call isphere_receive_files", 1, "msg-dir-fixture-1:redacted-dir-report.docx", "redacted-dir-report.docx")
|
||||||
|
|||||||
Reference in New Issue
Block a user