mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-29 22:21:20 +00:00
- 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)
217 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|