feat: align receive messages response contract

This commit is contained in:
zhaoyilun
2026-07-10 01:16:47 +08:00
parent 87a787ee1b
commit bbffa2189d
7 changed files with 258 additions and 23 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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
} }

View File

@@ -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 {
Messages []struct { OK bool `json:"ok"`
ID string `json:"id"` NextCursor *string `json:"next_cursor"`
Conversation *struct {
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"` DisplayName string `json:"display_name"`
ReceiverID string `json:"receiver_id"` } `json:"conversation"`
Text string `json:"text"` Messages []struct {
Timestamp string `json:"timestamp"` ID string `json:"id"`
Subject string `json:"subject"` MessageID string `json:"message_id"`
ReceiptID string `json:"receipt_id"` ConversationID string `json:"conversation_id"`
Read bool `json:"read"` ConversationType string `json:"conversation_type"`
SenderID string `json:"sender_id"`
SenderName *string `json:"sender_name"`
ReceiverID string `json:"receiver_id"`
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"`
CreatedAt *string `json:"created_at"`
ReceivedAt any `json:"received_at"`
Subject string `json:"subject"`
ReceiptID string `json:"receipt_id"`
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 {

View File

@@ -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")