mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
* feat(collaboration): isolate delegated artifacts and child runs Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution. * fix(collaboration): harden delegated task isolation Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces. * fix(collaboration): recover interrupted async tasks * fix(collaboration): normalize persisted child-run status --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn>
341 lines
11 KiB
Go
341 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
type testMediaPathLoader struct {
|
|
path string
|
|
root string
|
|
}
|
|
|
|
func (l testMediaPathLoader) LoadPath(string) (string, error) {
|
|
return l.path, nil
|
|
}
|
|
|
|
func (l testMediaPathLoader) MediaRootPath() string {
|
|
return l.root
|
|
}
|
|
|
|
type readAudioUnsupportedProvider struct {
|
|
name string
|
|
chatCalls int
|
|
images int
|
|
}
|
|
|
|
func (p *readAudioUnsupportedProvider) Name() string { return p.name }
|
|
func (p *readAudioUnsupportedProvider) DefaultModel() string { return "claude-3-5-sonnet-latest" }
|
|
|
|
func (p *readAudioUnsupportedProvider) Chat(_ context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
|
|
p.chatCalls++
|
|
for _, msg := range req.Messages {
|
|
p.images += len(msg.Images)
|
|
}
|
|
return &providers.ChatResponse{Content: "unexpected"}, nil
|
|
}
|
|
|
|
func (p *readAudioUnsupportedProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// TestReadAudioCallProvider_TranscriptionModelWithoutCreds_FailsFast asserts
|
|
// that when no API credentials are present, a transcription-named model
|
|
// returns a clear error rather than silently falling back to chat/completions
|
|
// (which would then explode in a confusing way for transcription-only setups).
|
|
func TestReadAudioCallProvider_TranscriptionModelWithoutCreds_FailsFast(t *testing.T) {
|
|
tool := &ReadAudioTool{}
|
|
|
|
params := map[string]any{
|
|
"_provider_type": "openai",
|
|
"data": []byte{0x00, 0x01},
|
|
"mime": "audio/mpeg",
|
|
}
|
|
|
|
_, _, err := tool.callProvider(context.Background(), nil, "openai", "gpt-4o-mini-transcribe", params)
|
|
if err == nil {
|
|
t.Fatalf("expected fail-fast error for transcription model with nil credentials, got nil")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "credential") {
|
|
t.Errorf("expected error to mention credentials, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReadAudioCallProvider_TranscriptionModelWithoutCreds_OpenAICompat_FailsFast
|
|
// covers the openai_compat ptype variant — the bug the original PR found:
|
|
// previously a transcription model under openai_compat fell through to the
|
|
// generic chat-API fallback because only ptype=="openai" entered the
|
|
// transcription branch.
|
|
func TestReadAudioCallProvider_TranscriptionModelWithoutCreds_OpenAICompat_FailsFast(t *testing.T) {
|
|
tool := &ReadAudioTool{}
|
|
|
|
params := map[string]any{
|
|
"_provider_type": "openai_compat",
|
|
"data": []byte{0x00, 0x01},
|
|
"mime": "audio/mpeg",
|
|
}
|
|
|
|
_, _, err := tool.callProvider(context.Background(), nil, "dashscope", "whisper-1", params)
|
|
if err == nil {
|
|
t.Fatalf("expected fail-fast error for transcription model with nil credentials (openai_compat), got nil")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "credential") {
|
|
t.Errorf("expected error to mention credentials, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReadAudioCallProvider_GeminiWithoutCreds_FailsFast preserves the existing
|
|
// gemini fail-fast behavior (was previously a soft log + fallback that would
|
|
// then NPE on the registry path in tests; the broader guard makes it explicit).
|
|
func TestReadAudioCallProvider_GeminiWithoutCreds_FailsFast(t *testing.T) {
|
|
tool := &ReadAudioTool{}
|
|
|
|
params := map[string]any{
|
|
"_provider_type": "gemini",
|
|
"data": []byte{0x00, 0x01},
|
|
"mime": "audio/mpeg",
|
|
}
|
|
|
|
_, _, err := tool.callProvider(context.Background(), nil, "gemini", "gemini-2.5-flash", params)
|
|
if err == nil {
|
|
t.Fatalf("expected fail-fast error for gemini with nil credentials, got nil")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "credential") {
|
|
t.Errorf("expected error to mention credentials, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadAudioCallProvider_UnsupportedProviderDoesNotSendAudioAsImage(t *testing.T) {
|
|
reg := providers.NewRegistry(nil)
|
|
fake := &readAudioUnsupportedProvider{name: "anthropic"}
|
|
reg.Register(fake)
|
|
tool := &ReadAudioTool{registry: reg}
|
|
|
|
params := map[string]any{
|
|
"_provider_type": "anthropic",
|
|
"data": []byte{0x4f, 0x67, 0x67, 0x53},
|
|
"mime": "audio/ogg; codecs=opus",
|
|
}
|
|
|
|
_, _, err := tool.callProvider(context.Background(), nil, "anthropic", "claude-3-5-sonnet-latest", params)
|
|
if err == nil {
|
|
t.Fatalf("expected unsupported audio route error, got nil")
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
if !strings.Contains(msg, "unsupported audio route") {
|
|
t.Fatalf("expected unsupported audio route error, got: %v", err)
|
|
}
|
|
if !strings.Contains(msg, "anthropic") || !strings.Contains(msg, "claude-3-5-sonnet-latest") {
|
|
t.Fatalf("expected provider and model in error, got: %v", err)
|
|
}
|
|
if fake.chatCalls != 0 || fake.images != 0 {
|
|
t.Fatalf("unsupported audio route called chat fallback: calls=%d images=%d", fake.chatCalls, fake.images)
|
|
}
|
|
}
|
|
|
|
func TestResolveAudioFileRequiresExactMediaID(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
audioPath := filepath.Join(workspace, ".uploads", "recording.mp3")
|
|
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := providers.MediaRef{
|
|
ID: uuid.NewString(),
|
|
Kind: "audio",
|
|
Path: audioPath,
|
|
MimeType: "audio/mpeg",
|
|
}
|
|
tool := NewReadAudioTool(nil, nil)
|
|
ctx := WithToolWorkspace(context.Background(), workspace)
|
|
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
|
|
|
|
if gotPath, _, err := tool.resolveAudioFile(ctx, "recording.mp3"); err == nil {
|
|
t.Fatalf("non-ID value resolved to %q, want exact media_id error", gotPath)
|
|
}
|
|
gotPath, gotMime, err := tool.resolveAudioFile(ctx, ref.ID)
|
|
if err != nil {
|
|
t.Fatalf("exact media_id returned error: %v", err)
|
|
}
|
|
wantPath, err := filepath.EvalSymlinks(audioPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPath != wantPath || gotMime != "audio/mpeg" {
|
|
t.Fatalf("resolved (%q, %q), want (%q, audio/mpeg)", gotPath, gotMime, wantPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveAudioFileOmittedMediaIDUsesNewestRef(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
oldPath := filepath.Join(workspace, ".uploads", "old.mp3")
|
|
latestPath := filepath.Join(workspace, ".uploads", "latest.mp3")
|
|
if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range []string{oldPath, latestPath} {
|
|
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
refs := []providers.MediaRef{
|
|
{ID: uuid.NewString(), Kind: "audio", Path: oldPath, MimeType: "audio/mpeg"},
|
|
{ID: uuid.NewString(), Kind: "audio", Path: latestPath, MimeType: "audio/mpeg"},
|
|
}
|
|
ctx := WithToolWorkspace(context.Background(), workspace)
|
|
ctx = WithMediaAudioRefs(ctx, refs)
|
|
|
|
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, "")
|
|
if err != nil {
|
|
t.Fatalf("omitted media_id returned error: %v", err)
|
|
}
|
|
wantPath, err := filepath.EvalSymlinks(latestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPath != wantPath {
|
|
t.Fatalf("path = %q, want newest %q", gotPath, wantPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveAudioFileRejectsRefPathOutsideWorkspace(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
outsidePath := filepath.Join(t.TempDir(), "secret.mp3")
|
|
if err := os.WriteFile(outsidePath, []byte("secret"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := providers.MediaRef{
|
|
ID: uuid.NewString(),
|
|
Kind: "audio",
|
|
Path: outsidePath,
|
|
MimeType: "audio/mpeg",
|
|
}
|
|
ctx := WithToolWorkspace(context.Background(), workspace)
|
|
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
|
|
|
|
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, ref.ID)
|
|
if err == nil {
|
|
t.Fatalf("outside ref path resolved to %q, want containment error", gotPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveAudioFileRejectsLegacyLoaderPathOutsideWorkspace(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
outsidePath := filepath.Join(t.TempDir(), "legacy.mp3")
|
|
if err := os.WriteFile(outsidePath, []byte("audio"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := providers.MediaRef{
|
|
ID: uuid.NewString(),
|
|
Kind: "audio",
|
|
MimeType: "audio/mpeg",
|
|
}
|
|
ctx := WithToolWorkspace(context.Background(), workspace)
|
|
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
|
|
|
|
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{path: outsidePath}).resolveAudioFile(ctx, ref.ID)
|
|
if err == nil {
|
|
t.Fatalf("outside legacy loader path resolved to %q, want containment error", gotPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveAudioFileAcceptsConfiguredLegacyMediaRoot(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
mediaRoot := filepath.Join(t.TempDir(), ".media")
|
|
audioPath := filepath.Join(mediaRoot, "session-hash", "legacy.mp3")
|
|
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := providers.MediaRef{
|
|
ID: uuid.NewString(),
|
|
Kind: "audio",
|
|
MimeType: "audio/mpeg",
|
|
}
|
|
ctx := WithToolWorkspace(context.Background(), workspace)
|
|
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
|
|
|
|
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{
|
|
path: audioPath,
|
|
root: mediaRoot,
|
|
}).resolveAudioFile(ctx, ref.ID)
|
|
if err != nil {
|
|
t.Fatalf("configured legacy media path returned error: %v", err)
|
|
}
|
|
wantPath, err := filepath.EvalSymlinks(audioPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPath != wantPath {
|
|
t.Fatalf("path = %q, want %q", gotPath, wantPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveAudioFileAcceptsLegacyLoaderPathInsideWorkspace(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
audioPath := filepath.Join(workspace, ".uploads", "legacy.mp3")
|
|
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := providers.MediaRef{
|
|
ID: uuid.NewString(),
|
|
Kind: "audio",
|
|
MimeType: "audio/mpeg",
|
|
}
|
|
ctx := WithToolWorkspace(context.Background(), workspace)
|
|
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
|
|
|
|
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{path: audioPath}).resolveAudioFile(ctx, ref.ID)
|
|
if err != nil {
|
|
t.Fatalf("inside legacy loader path returned error: %v", err)
|
|
}
|
|
wantPath, err := filepath.EvalSymlinks(audioPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPath != wantPath {
|
|
t.Fatalf("path = %q, want %q", gotPath, wantPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveAudioFileAcceptsDelegationInputRef(t *testing.T) {
|
|
ctx, inputs, _ := delegationArtifactToolContext(t)
|
|
audioPath := filepath.Join(inputs, "recording.mp3")
|
|
if err := os.WriteFile(audioPath, []byte("audio"), 0o440); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := providers.MediaRef{
|
|
ID: uuid.NewString(),
|
|
Kind: "audio",
|
|
Path: audioPath,
|
|
MimeType: "audio/mpeg",
|
|
}
|
|
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
|
|
|
|
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, ref.ID)
|
|
if err != nil {
|
|
t.Fatalf("delegation input ref returned error: %v", err)
|
|
}
|
|
wantPath, err := filepath.EvalSymlinks(audioPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPath != wantPath {
|
|
t.Fatalf("path = %q, want staged input %q", gotPath, wantPath)
|
|
}
|
|
}
|