Files
goclaw/internal/tools/filesystem_write_deliver_test.go
viettranx 3a85d625a6 feat(tools): add send_file for delivering existing workspace files
- new send_file(path, caption?) tool with DenyPaths guard and duplicate-delivery check
- patch message(MEDIA:) to Mark DeliveredMedia on send success (closes cross-tool dup gap)
- register in gateway wiring + builtin seed
- add to systemprompt coreToolSummaries; clarify write_file deliver=true description
- 16 tests green (PG + SQLite builds clean, invariants green)
2026-04-23 08:31:53 +07:00

217 lines
7.2 KiB
Go

package tools
// Characterization tests for write_file deliver=true/false behavior.
// These pin the delivery chain side-effects so phase 03 (send_file tool) cannot
// silently regress Result.Media population or DeliveredMedia.Mark() call.
//
// Gap noted: message.go (line 123) only READS IsDelivered — it does NOT call Mark.
// Mark is called exclusively by write_file (filesystem_write.go:236, 281).
// Phase 03 will add send_file which must also call Mark. Tests here lock that contract.
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
// TestWriteFileDeliverTrue_PopulatesResultMedia asserts that write_file with
// deliver=true sets Result.Media with the resolved path and filename.
func TestWriteFileDeliverTrue_PopulatesResultMedia(t *testing.T) {
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
tool := NewWriteFileTool(workspaceCanonical, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": "report.csv",
"content": "col1,col2\n1,2\n",
"deliver": true,
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 Media entry, got %d", len(result.Media))
}
gotPath := result.Media[0].Path
wantPath := filepath.Join(workspaceCanonical, "report.csv")
if gotPath != wantPath {
t.Errorf("Media[0].Path = %q, want %q", gotPath, wantPath)
}
if result.Media[0].Filename != "report.csv" {
t.Errorf("Media[0].Filename = %q, want %q", result.Media[0].Filename, "report.csv")
}
}
// TestWriteFileDeliverTrue_MarksDeliveredMedia asserts that write_file with
// deliver=true calls dm.Mark(resolved) so message tool's self-send guard can detect it.
func TestWriteFileDeliverTrue_MarksDeliveredMedia(t *testing.T) {
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
tool := NewWriteFileTool(workspaceCanonical, true)
dm := NewDeliveredMedia()
ctx := WithDeliveredMedia(context.Background(), dm)
result := tool.Execute(ctx, map[string]any{
"path": "output.pdf",
"content": "%PDF-1.4",
"deliver": true,
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
resolvedPath := filepath.Join(workspaceCanonical, "output.pdf")
if !dm.IsDelivered(resolvedPath) {
t.Errorf("expected dm.IsDelivered(%q) = true after write_file deliver=true, got false", resolvedPath)
}
}
// TestWriteFileDeliverFalse_NoMediaNoMark asserts that write_file with
// deliver=false leaves Result.Media empty and does NOT call dm.Mark.
func TestWriteFileDeliverFalse_NoMediaNoMark(t *testing.T) {
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
tool := NewWriteFileTool(workspaceCanonical, true)
dm := NewDeliveredMedia()
ctx := WithDeliveredMedia(context.Background(), dm)
result := tool.Execute(ctx, map[string]any{
"path": "temp.json",
"content": `{"key":"val"}`,
"deliver": false,
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(result.Media) != 0 {
t.Errorf("expected 0 Media entries for deliver=false, got %d", len(result.Media))
}
resolvedPath := filepath.Join(workspaceCanonical, "temp.json")
if dm.IsDelivered(resolvedPath) {
t.Errorf("expected dm.IsDelivered(%q) = false for deliver=false, got true", resolvedPath)
}
}
// TestWriteFileDeliverDefault_IsTrue asserts that omitting the deliver arg
// defaults to deliver=true (per filesystem_write.go:110).
func TestWriteFileDeliverDefault_IsTrue(t *testing.T) {
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
tool := NewWriteFileTool(workspaceCanonical, true)
dm := NewDeliveredMedia()
ctx := WithDeliveredMedia(context.Background(), dm)
// No "deliver" key in args — default should be true.
result := tool.Execute(ctx, map[string]any{
"path": "data.txt",
"content": "hello",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Errorf("expected 1 Media entry when deliver omitted (default true), got %d", len(result.Media))
}
resolvedPath := filepath.Join(workspaceCanonical, "data.txt")
if !dm.IsDelivered(resolvedPath) {
t.Errorf("expected dm.IsDelivered(%q) = true when deliver omitted, got false", resolvedPath)
}
}
// TestWriteFileThenMessageBlocked characterizes the full delivery chain:
// write_file(deliver=true) → message(MEDIA:same_path) → blocked.
// This is the key regression guard for phase 03 — if send_file breaks Mark(),
// the message block will stop working.
func TestWriteFileThenMessageBlocked(t *testing.T) {
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
writeTool := NewWriteFileTool(workspaceCanonical, true)
msgTool := NewMessageTool(workspaceCanonical, true)
msgTool.SetMessageBus(nil) // no bus — we only care about the block error
dm := NewDeliveredMedia()
ctx := context.Background()
ctx = WithDeliveredMedia(ctx, dm)
ctx = WithToolChannel(ctx, "telegram")
ctx = WithToolChatID(ctx, "chat-42")
// Step 1: write_file delivers the file and marks it.
writeResult := writeTool.Execute(ctx, map[string]any{
"path": "invoice.pdf",
"content": "%PDF-1.4 body",
"deliver": true,
})
if writeResult.IsError {
t.Fatalf("write_file failed: %s", writeResult.ForLLM)
}
// Step 2: message(MEDIA:path) for same file must be blocked.
resolvedPath := filepath.Join(workspaceCanonical, "invoice.pdf")
msgResult := msgTool.Execute(ctx, map[string]any{
"action": "send",
"channel": "telegram",
"target": "chat-42",
"message": "MEDIA:" + resolvedPath,
})
if !msgResult.IsError {
t.Fatal("expected message(MEDIA:path) to be blocked after write_file deliver=true, but it was allowed")
}
}
// TestMessageMediaMarksDelivered verifies that message(MEDIA:path) calls dm.Mark()
// on the file it sends (patched in phase 03). This closes the cross-tool duplicate
// gap: send_file after message(MEDIA:) now correctly detects the duplicate.
func TestMessageMediaMarksDelivered(t *testing.T) {
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
// Create a real file so MEDIA: resolution succeeds.
filePath := filepath.Join(workspaceCanonical, "attachment.csv")
if err := os.WriteFile(filePath, []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatal(err)
}
msgTool := NewMessageTool(workspaceCanonical, true)
// Need a message bus for sendMedia to proceed past the nil-bus guard.
msgTool.SetMessageBus(bus.New())
dm := NewDeliveredMedia()
ctx := context.Background()
ctx = WithDeliveredMedia(ctx, dm)
// Unbound session (no channel/chatID) — MEDIA send is allowed.
result := msgTool.Execute(ctx, map[string]any{
"action": "send",
"channel": "telegram",
"target": "chat-99",
"message": "MEDIA:" + filePath,
})
// message(MEDIA:) should succeed and mark the file as delivered.
if result.IsError {
t.Fatalf("expected message(MEDIA:path) to succeed, got error: %s", result.ForLLM)
}
if !dm.IsDelivered(filePath) {
t.Errorf("expected dm.IsDelivered(%q) = true after message(MEDIA:path), got false — "+
"check message.go sendMedia: dm.Mark call missing", filePath)
}
}