feat: add fake send connector contract
This commit is contained in:
@@ -58,7 +58,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
|
|||||||
|
|
||||||
## Current loop
|
## Current loop
|
||||||
|
|
||||||
Current loop: `Planning gate complete for continuous execution; awaiting business review before starting R6f`.
|
Current loop: `R6f fake/sandbox send connector contract complete; next R6g send audit and idempotency replay diagnostics`.
|
||||||
|
|
||||||
Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
||||||
|
|
||||||
@@ -224,10 +224,20 @@ R6e sandbox-only send connector shell and idempotency audit hardening is complet
|
|||||||
- Duplicate same-key/same-target/same-content preview calls return structured metadata with `duplicate_detected=true` and no side effects.
|
- Duplicate same-key/same-target/same-content preview calls return structured metadata with `duplicate_detected=true` and no side effects.
|
||||||
- Same idempotency key reused for different target/content returns `send_status="blocked"` with `conflict_detected=true` and no side effects.
|
- Same idempotency key reused for different target/content returns `send_status="blocked"` with `conflict_detected=true` and no side effects.
|
||||||
- `scripts\verify-go-mcp.ps1` and `cmd\isphere-capability-smoke` use fresh per-run idempotency state to keep repeatable smoke deterministic.
|
- `scripts\verify-go-mcp.ps1` and `cmd\isphere-capability-smoke` use fresh per-run idempotency state to keep repeatable smoke deterministic.
|
||||||
- Next node after user approval: R6f fake/sandbox connector contract.
|
- R6f started after user approval of the continuous execution plan.
|
||||||
|
|
||||||
Continuous execution plan R6f-R14 is drafted and awaiting user review:
|
R6f fake/sandbox send connector contract is complete:
|
||||||
|
|
||||||
|
- Added `internal/tools/send_message_connector.go` with `SendMessageConnector`, `SendMessageConnectorRequest`, and `SendMessageConnectorResult`.
|
||||||
|
- Added injected test-only `RegisterISphereSendMessageToolWithStateAndConnector`.
|
||||||
|
- Added fake connector accepted/failed tests proving connector outcome mapping, ack/error fields, redacted audit, and no real side effects.
|
||||||
|
- Default MCP server still registers no real connector; `execution_mode="production"` remains blocked and `production_send_enabled=false`.
|
||||||
|
- Next node: R6g send audit and idempotency replay diagnostics.
|
||||||
|
|
||||||
|
Continuous execution plan R6f-R14 is approved and execution has started:
|
||||||
|
|
||||||
- Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
- Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
||||||
- Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report.
|
- Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report.
|
||||||
- Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action.
|
- Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action.
|
||||||
|
- Last completed round: R6f fake/sandbox send connector contract.
|
||||||
|
- Next active round: R6g send audit and idempotency replay diagnostics.
|
||||||
|
|||||||
@@ -99,8 +99,9 @@ The verification confirms:
|
|||||||
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture; exact ID/display/account matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
|
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture; exact ID/display/account matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
|
||||||
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
|
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
|
||||||
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture.
|
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture.
|
||||||
- `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; `execution_mode="production"` returns `send_status="blocked"` and `production_send_enabled=false`.
|
- `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; the default runtime still returns `execution_mode="production"` as `send_status="blocked"` and `production_send_enabled=false`.
|
||||||
- `isphere_send_message` also exposes sandbox-only connector metadata and idempotency state: duplicate reuse of the same key for the same target/content is detectable, while conflicting reuse of the same key for different content/target is blocked.
|
- `isphere_send_message` also exposes sandbox-only connector metadata and idempotency state: duplicate reuse of the same key for the same target/content is detectable, while conflicting reuse of the same key for different content/target is blocked.
|
||||||
|
- R6f adds a test-only fake/sandbox connector contract for accepted/failed connector outcomes. This is an injected unit-test boundary only; the production MCP server still registers no real connector and performs no real send.
|
||||||
- Verification uses synthetic/local helper checks and does not load raw N12-pre evidence.
|
- Verification uses synthetic/local helper checks and does not load raw N12-pre evidence.
|
||||||
- The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable.
|
- The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable.
|
||||||
- File download/cache mapping, send-file, and production send remain later-stage work.
|
- File download/cache mapping, send-file, and production send remain later-stage work.
|
||||||
@@ -263,7 +264,7 @@ Allowed parameters:
|
|||||||
- `isphere_search_contacts`: `query`, `cursor`, `source_preference`, `include_inactive`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
|
- `isphere_search_contacts`: `query`, `cursor`, `source_preference`, `include_inactive`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
|
||||||
- `isphere_search_groups`: `query`, `cursor`, `source_preference`, `include_archived`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
|
- `isphere_search_groups`: `query`, `cursor`, `source_preference`, `include_archived`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
|
||||||
- `isphere_receive_files`: `conversation_id`, `message_id`, `file_id`, `name_contains`, `mode`, `output_dir`, `cursor`, `source_preference`, `preview`, `limit` only. C22 supports `mode` empty or `list`; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` and `output_dir` must be empty in current list-only mode; download is deferred.
|
- `isphere_receive_files`: `conversation_id`, `message_id`, `file_id`, `name_contains`, `mode`, `output_dir`, `cursor`, `source_preference`, `preview`, `limit` only. C22 supports `mode` empty or `list`; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` and `output_dir` must be empty in current list-only mode; download is deferred.
|
||||||
- `isphere_send_message`: `target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode` only. R6b supports `execution_mode` empty/`preview`/`dry_run` as planned preview. `execution_mode="production"` returns blocked status until sandbox-send evidence passes. The response and audit store `content_sha256` and `idempotency_key_sha256`, not raw message body or raw idempotency key.
|
- `isphere_send_message`: `target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode` only. R6b supports `execution_mode` empty/`preview`/`dry_run` as planned preview. The default runtime returns `execution_mode="production"` as blocked until sandbox-send evidence passes. R6f adds a Go `SendMessageConnector` interface for fake/sandbox tests so accepted, failed, duplicate, and conflict paths can be validated without login. The response and audit store `content_sha256` and `idempotency_key_sha256`, not raw message body or raw idempotency key.
|
||||||
|
|
||||||
Optional send audit/idempotency paths:
|
Optional send audit/idempotency paths:
|
||||||
|
|
||||||
@@ -288,7 +289,7 @@ The current runbook verifies nine tools:
|
|||||||
- `isphere_receive_files`
|
- `isphere_receive_files`
|
||||||
- `isphere_send_message`
|
- `isphere_send_message`
|
||||||
|
|
||||||
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, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata, sandbox-only connector-shell metadata, duplicate/conflict idempotency detection, and redacted audit only; production send remains blocked until dynamic observation, a full send evidence gate, and a controlled sandbox connector pass prove success/ack behavior.
|
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, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata, sandbox-only connector-shell metadata, duplicate/conflict idempotency detection, redacted audit, and a test-only `SendMessageConnector` contract for fake accepted/failed outcomes; the default MCP server still has no real connector and production send remains blocked until dynamic observation, a full send evidence gate, and a controlled sandbox connector pass prove success/ack behavior.
|
||||||
|
|
||||||
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send-message preview metadata and audit records are now in place; production send still waits for the later online sandbox-send gate.
|
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send-message preview metadata and audit records are now in place; production send still waits for the later online sandbox-send gate.
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ git push gitea main
|
|||||||
- Produces: `type SendMessageConnectorResult struct { Accepted bool; Status string; AckRef string; ErrorCode string; ErrorMessage string; ConnectorMode string }`
|
- Produces: `type SendMessageConnectorResult struct { Accepted bool; Status string; AckRef string; ErrorCode string; ErrorMessage string; ConnectorMode string }`
|
||||||
- Produces: `RegisterISphereSendMessageToolWithStateAndConnector(server, auditStore, idempotencyStore, connector)` for tests only.
|
- Produces: `RegisterISphereSendMessageToolWithStateAndConnector(server, auditStore, idempotencyStore, connector)` for tests only.
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing fake-accepted test**
|
- [x] **Step 1: Write failing fake-accepted test**
|
||||||
|
|
||||||
Add `TestISphereSendMessageFakeConnectorAcceptedAudit` to `internal/tools/isphere_send_message_test.go`:
|
Add `TestISphereSendMessageFakeConnectorAcceptedAudit` to `internal/tools/isphere_send_message_test.go`:
|
||||||
|
|
||||||
@@ -118,19 +118,19 @@ go test ./internal/tools -run TestISphereSendMessageFakeConnectorAcceptedAudit -
|
|||||||
|
|
||||||
Expected before implementation: FAIL because `RegisterISphereSendMessageToolWithStateAndConnector` and connector types do not exist.
|
Expected before implementation: FAIL because `RegisterISphereSendMessageToolWithStateAndConnector` and connector types do not exist.
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing fake-failed test**
|
- [x] **Step 2: Write failing fake-failed test**
|
||||||
|
|
||||||
Add `TestISphereSendMessageFakeConnectorFailureAudit` asserting `ok=false`, `send_status="failed"`, `error_code="fake_rejected"`, and `side_effect_flags.real_send_attempted=false`.
|
Add `TestISphereSendMessageFakeConnectorFailureAudit` asserting `ok=false`, `send_status="failed"`, `error_code="fake_rejected"`, and no real side-effect flags such as `sent_message` or `typed_text`.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement connector contract**
|
- [x] **Step 3: Implement connector contract**
|
||||||
|
|
||||||
Create `internal/tools/send_message_connector.go` with the interface, request/result structs, and result normalization. Keep the fake connector in the test file.
|
Create `internal/tools/send_message_connector.go` with the interface, request/result structs, and result normalization. Keep the fake connector in the test file.
|
||||||
|
|
||||||
- [ ] **Step 4: Wire injected connector only for tests**
|
- [x] **Step 4: Wire injected connector only for tests**
|
||||||
|
|
||||||
Modify `internal/tools/isphere_send_message.go` so default `RegisterISphereSendMessageTool` still blocks production, while `RegisterISphereSendMessageToolWithStateAndConnector` can execute a fake connector in tests.
|
Modify `internal/tools/isphere_send_message.go` so default `RegisterISphereSendMessageTool` still blocks production, while `RegisterISphereSendMessageToolWithStateAndConnector` can execute a fake connector in tests.
|
||||||
|
|
||||||
- [ ] **Step 5: Verify and commit**
|
- [x] **Step 5: Verify and commit**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git diff --check
|
git diff --check
|
||||||
@@ -145,6 +145,14 @@ git push gitea main
|
|||||||
|
|
||||||
**Acceptance gate:** Default MCP still reports `production_send_enabled=false`; fake connector is reachable only through test injection.
|
**Acceptance gate:** Default MCP still reports `production_send_enabled=false`; fake connector is reachable only through test injection.
|
||||||
|
|
||||||
|
**R6f Result:**
|
||||||
|
|
||||||
|
- Added `internal/tools/send_message_connector.go` with `SendMessageConnector`, `SendMessageConnectorRequest`, `SendMessageConnectorResult`, and result normalization.
|
||||||
|
- Added `RegisterISphereSendMessageToolWithStateAndConnector` for injected test-only connector execution.
|
||||||
|
- Added fake connector accepted/failed tests proving accepted/failed connector outcomes map into structured response and redacted audit metadata.
|
||||||
|
- Default server registration still uses no connector; `execution_mode="production"` remains blocked with `production_send_enabled=false`.
|
||||||
|
- No helper send operation, UI click/type, network replay, hook, upload, or real production send was introduced.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### R6g: Send audit and idempotency replay diagnostics
|
### R6g: Send audit and idempotency replay diagnostics
|
||||||
|
|||||||
@@ -51,12 +51,16 @@ type SendMessageAuditEvent struct {
|
|||||||
TargetRef string `json:"target_ref"`
|
TargetRef string `json:"target_ref"`
|
||||||
ExecutionMode string `json:"execution_mode"`
|
ExecutionMode string `json:"execution_mode"`
|
||||||
Connector string `json:"connector"`
|
Connector string `json:"connector"`
|
||||||
|
ConnectorMode string `json:"connector_mode"`
|
||||||
ConnectorStage string `json:"connector_stage"`
|
ConnectorStage string `json:"connector_stage"`
|
||||||
ContentSHA256 string `json:"content_sha256"`
|
ContentSHA256 string `json:"content_sha256"`
|
||||||
ContentLength int `json:"content_length"`
|
ContentLength int `json:"content_length"`
|
||||||
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
|
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
|
||||||
SendStatus string `json:"send_status"`
|
SendStatus string `json:"send_status"`
|
||||||
Result string `json:"result"`
|
Result string `json:"result"`
|
||||||
|
AckRef string `json:"ack_ref,omitempty"`
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
AuditRef string `json:"audit_ref"`
|
AuditRef string `json:"audit_ref"`
|
||||||
StartedAt string `json:"started_at"`
|
StartedAt string `json:"started_at"`
|
||||||
FinishedAt string `json:"finished_at"`
|
FinishedAt string `json:"finished_at"`
|
||||||
@@ -105,6 +109,16 @@ func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendM
|
|||||||
if idempotencyStore == nil {
|
if idempotencyStore == nil {
|
||||||
idempotencyStore = defaultSendMessageIdempotencyStore()
|
idempotencyStore = defaultSendMessageIdempotencyStore()
|
||||||
}
|
}
|
||||||
|
RegisterISphereSendMessageToolWithStateAndConnector(server, auditSink, idempotencyStore, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterISphereSendMessageToolWithStateAndConnector(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore, connector SendMessageConnector) {
|
||||||
|
if auditSink == nil {
|
||||||
|
auditSink = defaultSendMessageAuditSink()
|
||||||
|
}
|
||||||
|
if idempotencyStore == nil {
|
||||||
|
idempotencyStore = defaultSendMessageIdempotencyStore()
|
||||||
|
}
|
||||||
|
|
||||||
mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{
|
mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{
|
||||||
Name: ToolNameSendMessage,
|
Name: ToolNameSendMessage,
|
||||||
@@ -116,12 +130,16 @@ func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendM
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
finished := time.Now().UTC()
|
finished := time.Now().UTC()
|
||||||
response, event := sendMessagePreviewResponse(normalized, started, finished)
|
response, event := sendMessagePreviewResponse(normalized, started, finished, connector != nil)
|
||||||
decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event))
|
decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err)
|
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err)
|
||||||
}
|
}
|
||||||
applySendMessageIdempotencyDecision(response, &event, decision)
|
applySendMessageIdempotencyDecision(response, &event, decision)
|
||||||
|
if normalized.ExecutionMode == sendMessageProductionMode && connector != nil && !decision.Duplicate && !decision.Conflict {
|
||||||
|
result, connectorErr := connector.ExecuteSendMessage(ctx, sendMessageConnectorRequestFromNormalized(normalized))
|
||||||
|
applySendMessageConnectorResult(response, &event, result, connectorErr)
|
||||||
|
}
|
||||||
if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil {
|
if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil {
|
||||||
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err)
|
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err)
|
||||||
}
|
}
|
||||||
@@ -325,18 +343,22 @@ func normalizeSendMessageExecutionMode(value string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time) (map[string]any, SendMessageAuditEvent) {
|
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time, connectorAvailable bool) (map[string]any, SendMessageAuditEvent) {
|
||||||
sideEffects := sendMessageNoSideEffects()
|
sideEffects := sendMessageNoSideEffects()
|
||||||
status := "planned"
|
status := "planned"
|
||||||
stage := sendMessagePreviewStage
|
stage := sendMessagePreviewStage
|
||||||
ok := true
|
ok := true
|
||||||
blockedReason := any(nil)
|
blockedReason := any(nil)
|
||||||
if input.ExecutionMode == sendMessageProductionMode {
|
if input.ExecutionMode == sendMessageProductionMode {
|
||||||
|
if connectorAvailable {
|
||||||
|
stage = sendMessagePreviewStage
|
||||||
|
} else {
|
||||||
ok = false
|
ok = false
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
stage = sendMessageBlockedStage
|
stage = sendMessageBlockedStage
|
||||||
blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass"
|
blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256)
|
auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256)
|
||||||
event := SendMessageAuditEvent{
|
event := SendMessageAuditEvent{
|
||||||
Tool: ToolNameSendMessage,
|
Tool: ToolNameSendMessage,
|
||||||
@@ -345,6 +367,7 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
|
|||||||
TargetRef: input.TargetRef,
|
TargetRef: input.TargetRef,
|
||||||
ExecutionMode: input.ExecutionMode,
|
ExecutionMode: input.ExecutionMode,
|
||||||
Connector: sendMessageConnector,
|
Connector: sendMessageConnector,
|
||||||
|
ConnectorMode: sendMessageConnectorMode,
|
||||||
ConnectorStage: stage,
|
ConnectorStage: stage,
|
||||||
ContentSHA256: input.ContentSHA256,
|
ContentSHA256: input.ContentSHA256,
|
||||||
ContentLength: input.ContentLength,
|
ContentLength: input.ContentLength,
|
||||||
@@ -400,6 +423,54 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
|
|||||||
return response, event
|
return response, event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applySendMessageConnectorResult(response map[string]any, event *SendMessageAuditEvent, result SendMessageConnectorResult, err error) {
|
||||||
|
normalized := normalizeSendMessageConnectorResult(result, err)
|
||||||
|
response["ok"] = normalized.Accepted
|
||||||
|
response["send_status"] = normalized.Status
|
||||||
|
response["connector_mode"] = normalized.ConnectorMode
|
||||||
|
response["connector_stage"] = normalized.Status
|
||||||
|
if normalized.Accepted {
|
||||||
|
response["blocked_reason"] = nil
|
||||||
|
} else if normalized.ErrorMessage != "" {
|
||||||
|
response["blocked_reason"] = normalized.ErrorMessage
|
||||||
|
}
|
||||||
|
if normalized.AckRef != "" {
|
||||||
|
response["ack_ref"] = normalized.AckRef
|
||||||
|
}
|
||||||
|
if normalized.ErrorCode != "" {
|
||||||
|
response["error_code"] = normalized.ErrorCode
|
||||||
|
}
|
||||||
|
if normalized.ErrorMessage != "" {
|
||||||
|
response["error_message"] = normalized.ErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
audit, _ := response["audit"].(map[string]any)
|
||||||
|
if audit != nil {
|
||||||
|
audit["result"] = normalized.Status
|
||||||
|
audit["connector_mode"] = normalized.ConnectorMode
|
||||||
|
audit["connector_stage"] = normalized.Status
|
||||||
|
if normalized.AckRef != "" {
|
||||||
|
audit["ack_ref"] = normalized.AckRef
|
||||||
|
}
|
||||||
|
if normalized.ErrorCode != "" {
|
||||||
|
audit["error_code"] = normalized.ErrorCode
|
||||||
|
}
|
||||||
|
if normalized.ErrorMessage != "" {
|
||||||
|
audit["error_message"] = normalized.ErrorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if event != nil {
|
||||||
|
event.ConnectorMode = normalized.ConnectorMode
|
||||||
|
event.ConnectorStage = normalized.Status
|
||||||
|
event.SendStatus = normalized.Status
|
||||||
|
event.Result = normalized.Status
|
||||||
|
event.AckRef = normalized.AckRef
|
||||||
|
event.ErrorCode = normalized.ErrorCode
|
||||||
|
event.ErrorMessage = normalized.ErrorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord {
|
func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord {
|
||||||
return SendMessageIdempotencyRecord{
|
return SendMessageIdempotencyRecord{
|
||||||
Tool: event.Tool,
|
Tool: event.Tool,
|
||||||
|
|||||||
@@ -146,6 +146,153 @@ func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestISphereSendMessageFakeConnectorAcceptedAudit(t *testing.T) {
|
||||||
|
audit := &fakeSendMessageAuditSink{}
|
||||||
|
idempotency := newFakeSendMessageIdempotencyStore()
|
||||||
|
connector := &fakeSendMessageConnector{
|
||||||
|
result: SendMessageConnectorResult{
|
||||||
|
Accepted: true,
|
||||||
|
Status: "accepted",
|
||||||
|
AckRef: "fake-ack-1",
|
||||||
|
ConnectorMode: "fake",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||||
|
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
content := "fake connector accepted"
|
||||||
|
contentHash := sha256HexForSendMessageTest(content)
|
||||||
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: ToolNameSendMessage,
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"target_type": "direct",
|
||||||
|
"target_id": "alice@imopenfire1-lanzhou",
|
||||||
|
"content_text": content,
|
||||||
|
"content_sha256": contentHash,
|
||||||
|
"idempotency_key": "idem-fake-accepted-1",
|
||||||
|
"execution_mode": "production",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("call %s fake connector accepted: %v", ToolNameSendMessage, err)
|
||||||
|
}
|
||||||
|
if callResult.IsError {
|
||||||
|
t.Fatalf("fake connector accepted result should be structured, got error: %+v", callResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
SendStatus string `json:"send_status"`
|
||||||
|
ConnectorMode string `json:"connector_mode"`
|
||||||
|
ProductionSendEnabled bool `json:"production_send_enabled"`
|
||||||
|
SideEffects map[string]any `json:"side_effects"`
|
||||||
|
Audit map[string]any `json:"audit"`
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||||
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode fake accepted payload %s: %v", payload, err)
|
||||||
|
}
|
||||||
|
if !decoded.OK || decoded.SendStatus != "accepted" || decoded.ConnectorMode != "fake" {
|
||||||
|
t.Fatalf("unexpected fake accepted response: %s", payload)
|
||||||
|
}
|
||||||
|
if decoded.ProductionSendEnabled {
|
||||||
|
t.Fatalf("fake connector must not flip default production_send_enabled: %s", payload)
|
||||||
|
}
|
||||||
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
|
||||||
|
t.Fatalf("fake connector should not report real side effects: %#v", decoded.SideEffects)
|
||||||
|
}
|
||||||
|
if decoded.Audit["ack_ref"] != "fake-ack-1" || decoded.Audit["result"] != "accepted" {
|
||||||
|
t.Fatalf("unexpected fake accepted audit: %#v", decoded.Audit)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-accepted-1") {
|
||||||
|
t.Fatalf("fake accepted payload leaked raw content or idempotency key: %s", payload)
|
||||||
|
}
|
||||||
|
if connector.calls != 1 {
|
||||||
|
t.Fatalf("fake connector calls = %d, want 1", connector.calls)
|
||||||
|
}
|
||||||
|
if len(audit.events) != 1 {
|
||||||
|
t.Fatalf("audit events = %+v, want one", audit.events)
|
||||||
|
}
|
||||||
|
event := audit.events[0]
|
||||||
|
if event.Result != "accepted" || event.AckRef != "fake-ack-1" || event.ConnectorMode != "fake" {
|
||||||
|
t.Fatalf("unexpected audit event: %+v", event)
|
||||||
|
}
|
||||||
|
if event.ContentText != "" || event.IdempotencyKey != "" {
|
||||||
|
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestISphereSendMessageFakeConnectorFailureAudit(t *testing.T) {
|
||||||
|
audit := &fakeSendMessageAuditSink{}
|
||||||
|
idempotency := newFakeSendMessageIdempotencyStore()
|
||||||
|
connector := &fakeSendMessageConnector{
|
||||||
|
result: SendMessageConnectorResult{
|
||||||
|
Accepted: false,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorCode: "fake_rejected",
|
||||||
|
ErrorMessage: "fake connector rejected the request",
|
||||||
|
ConnectorMode: "fake",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||||
|
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
content := "fake connector failed"
|
||||||
|
contentHash := sha256HexForSendMessageTest(content)
|
||||||
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: ToolNameSendMessage,
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"target_type": "group",
|
||||||
|
"target_id": "project-room@conference.imopenfire1-lanzhou",
|
||||||
|
"content_text": content,
|
||||||
|
"content_sha256": contentHash,
|
||||||
|
"idempotency_key": "idem-fake-failed-1",
|
||||||
|
"execution_mode": "production",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("call %s fake connector failure: %v", ToolNameSendMessage, err)
|
||||||
|
}
|
||||||
|
if callResult.IsError {
|
||||||
|
t.Fatalf("fake connector failure should be structured, got error: %+v", callResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
SendStatus string `json:"send_status"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
SideEffects map[string]any `json:"side_effects"`
|
||||||
|
Audit map[string]any `json:"audit"`
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||||
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode fake failure payload %s: %v", payload, err)
|
||||||
|
}
|
||||||
|
if decoded.OK || decoded.SendStatus != "failed" || decoded.ErrorCode != "fake_rejected" {
|
||||||
|
t.Fatalf("unexpected fake failure response: %s", payload)
|
||||||
|
}
|
||||||
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false {
|
||||||
|
t.Fatalf("fake failure should not report real side effects: %#v", decoded.SideEffects)
|
||||||
|
}
|
||||||
|
if decoded.Audit["error_code"] != "fake_rejected" || decoded.Audit["result"] != "failed" {
|
||||||
|
t.Fatalf("unexpected fake failure audit: %#v", decoded.Audit)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-failed-1") {
|
||||||
|
t.Fatalf("fake failure payload leaked raw content or idempotency key: %s", payload)
|
||||||
|
}
|
||||||
|
if len(audit.events) != 1 {
|
||||||
|
t.Fatalf("audit events = %+v, want one", audit.events)
|
||||||
|
}
|
||||||
|
event := audit.events[0]
|
||||||
|
if event.Result != "failed" || event.ErrorCode != "fake_rejected" || event.ConnectorMode != "fake" {
|
||||||
|
t.Fatalf("unexpected audit event: %+v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
||||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||||
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
|
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
|
||||||
@@ -370,6 +517,17 @@ func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ contex
|
|||||||
return SendMessageIdempotencyDecision{}, nil
|
return SendMessageIdempotencyDecision{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeSendMessageConnector struct {
|
||||||
|
result SendMessageConnectorResult
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, _ SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
||||||
|
f.calls++
|
||||||
|
return f.result, f.err
|
||||||
|
}
|
||||||
|
|
||||||
func sha256HexForSendMessageTest(value string) string {
|
func sha256HexForSendMessageTest(value string) string {
|
||||||
sum := sha256.Sum256([]byte(value))
|
sum := sha256.Sum256([]byte(value))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|||||||
77
internal/tools/send_message_connector.go
Normal file
77
internal/tools/send_message_connector.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendMessageConnector interface {
|
||||||
|
ExecuteSendMessage(context.Context, SendMessageConnectorRequest) (SendMessageConnectorResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendMessageConnectorRequest struct {
|
||||||
|
TargetType string
|
||||||
|
TargetID string
|
||||||
|
TargetRef string
|
||||||
|
ContentSHA256 string
|
||||||
|
ContentLength int
|
||||||
|
IdempotencyKeySHA256 string
|
||||||
|
ExecutionMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendMessageConnectorResult struct {
|
||||||
|
Accepted bool
|
||||||
|
Status string
|
||||||
|
AckRef string
|
||||||
|
ErrorCode string
|
||||||
|
ErrorMessage string
|
||||||
|
ConnectorMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) SendMessageConnectorRequest {
|
||||||
|
return SendMessageConnectorRequest{
|
||||||
|
TargetType: input.TargetType,
|
||||||
|
TargetID: input.TargetID,
|
||||||
|
TargetRef: input.TargetRef,
|
||||||
|
ContentSHA256: input.ContentSHA256,
|
||||||
|
ContentLength: input.ContentLength,
|
||||||
|
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
|
||||||
|
ExecutionMode: input.ExecutionMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSendMessageConnectorResult(result SendMessageConnectorResult, err error) SendMessageConnectorResult {
|
||||||
|
normalized := result
|
||||||
|
normalized.Status = strings.TrimSpace(normalized.Status)
|
||||||
|
normalized.AckRef = strings.TrimSpace(normalized.AckRef)
|
||||||
|
normalized.ErrorCode = strings.TrimSpace(normalized.ErrorCode)
|
||||||
|
normalized.ErrorMessage = strings.TrimSpace(normalized.ErrorMessage)
|
||||||
|
normalized.ConnectorMode = strings.TrimSpace(normalized.ConnectorMode)
|
||||||
|
if normalized.ConnectorMode == "" {
|
||||||
|
normalized.ConnectorMode = sendMessageConnectorMode
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
normalized.Accepted = false
|
||||||
|
if normalized.Status == "" {
|
||||||
|
normalized.Status = "failed"
|
||||||
|
}
|
||||||
|
if normalized.ErrorCode == "" {
|
||||||
|
normalized.ErrorCode = "connector_error"
|
||||||
|
}
|
||||||
|
if normalized.ErrorMessage == "" {
|
||||||
|
normalized.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
if normalized.Status == "" {
|
||||||
|
if normalized.Accepted {
|
||||||
|
normalized.Status = "accepted"
|
||||||
|
} else {
|
||||||
|
normalized.Status = "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !normalized.Accepted && normalized.ErrorCode == "" {
|
||||||
|
normalized.ErrorCode = "connector_rejected"
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user