mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Pi reads its prompt from argv (positional, see buildPiArgs) and never expects interactive input, so the Pi backend previously left cmd.Stdin nil. Under systemd, the resulting /dev/null character device has been observed not to satisfy Pi's readable-side wait, leaving runs stuck in "working" forever (#2188). Attach an explicit StdinPipe and close it immediately after Start so the child sees an EOF on a FIFO, matching the pattern already used by the Claude, Codex, Hermes, Kiro, and Kimi backends. The fix is defensive on the daemon side because Pi is mid-refactor and is not accepting issues upstream; once Pi itself stops blocking on stdin, this close is still correct (a closed pipe is a no-op for a process that does not read it). Test asserts the structural invariant: a shell-stub `pi` inspects /proc/self/fd/0 and only emits a valid event stream when stdin is a FIFO. If a future change drops the StdinPipe and stdin reverts to /dev/null (char device), the stub exits non-zero and the test fails.
184 lines
6.0 KiB
Go
184 lines
6.0 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBuildPiArgsNoToolAllowlist(t *testing.T) {
|
|
// Extension tools registered via Pi's registerTool() must not be
|
|
// filtered out by a hardcoded --tools allowlist. Omitting --tools
|
|
// lets Pi use its full tool registry. See #2379.
|
|
args := buildPiArgs("test prompt", "/tmp/session.jsonl", ExecOptions{}, slog.Default())
|
|
for i, arg := range args {
|
|
if arg == "--tools" {
|
|
t.Errorf("buildPiArgs emits --tools %q; should not restrict tool registry (see #2379)", args[i+1])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildPiArgsBasicFlags(t *testing.T) {
|
|
args := buildPiArgs("hello world", "/tmp/s.jsonl", ExecOptions{
|
|
Model: "anthropic/claude-sonnet-4-20250514",
|
|
SystemPrompt: "be helpful",
|
|
}, slog.Default())
|
|
|
|
joined := strings.Join(args, " ")
|
|
for _, want := range []string{"-p", "--mode json", "--session /tmp/s.jsonl", "--provider anthropic", "--model claude-sonnet-4-20250514", "--append-system-prompt"} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("expected %q in args, got: %v", want, args)
|
|
}
|
|
}
|
|
|
|
// Prompt must be the last positional argument.
|
|
if args[len(args)-1] != "hello world" {
|
|
t.Errorf("prompt should be last arg, got %q", args[len(args)-1])
|
|
}
|
|
}
|
|
|
|
func TestBuildPiArgsCustomArgsAppended(t *testing.T) {
|
|
// Users can still restrict tools via custom_args if desired.
|
|
args := buildPiArgs("prompt", "/tmp/s.jsonl", ExecOptions{
|
|
CustomArgs: []string{"--tools", "read,bash"},
|
|
}, slog.Default())
|
|
|
|
found := false
|
|
for i, arg := range args {
|
|
if arg == "--tools" && i+1 < len(args) && args[i+1] == "read,bash" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("custom --tools should pass through via custom_args, got: %v", args)
|
|
}
|
|
}
|
|
|
|
// TestPiExecuteAttachesStdinPipe verifies that the Pi backend spawns the
|
|
// child with an explicit stdin pipe (FIFO) instead of leaving cmd.Stdin
|
|
// nil. Without an explicit pipe, Pi has been observed to block under
|
|
// systemd waiting for stdin events (#2188); attaching and immediately
|
|
// closing a pipe delivers a clean EOF on a FIFO and unblocks Pi.
|
|
//
|
|
// The probe is structural rather than behavioral: a shell script in
|
|
// place of `pi` inspects /proc/self/fd/0 and only emits a valid event
|
|
// stream if stdin is a FIFO. If the fix regresses (stdin nil → /dev/null
|
|
// char device), the fake exits non-zero and the test fails.
|
|
func TestPiExecuteAttachesStdinPipe(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS != "linux" {
|
|
// /proc/self/fd/0 is Linux-specific; skipping elsewhere keeps
|
|
// the assertion portable without losing CI coverage.
|
|
t.Skip("stdin fd inspection relies on /proc/self/fd/0")
|
|
}
|
|
|
|
fakePath := filepath.Join(t.TempDir(), "pi")
|
|
script := "#!/bin/sh\n" +
|
|
"kind=$(stat -c '%F' -L /proc/self/fd/0 2>/dev/null || echo unknown)\n" +
|
|
"case \"$kind\" in\n" +
|
|
" fifo|*pipe*)\n" +
|
|
" printf '%s\\n' '{\"type\":\"agent_start\"}'\n" +
|
|
" printf '%s\\n' '{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"model\":\"test\",\"usage\":{\"input\":1,\"output\":1,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":2}}}'\n" +
|
|
" exit 0\n" +
|
|
" ;;\n" +
|
|
"esac\n" +
|
|
"printf 'stdin was %s; expected fifo\\n' \"$kind\" >&2\n" +
|
|
"exit 1\n"
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
|
if err != nil {
|
|
t.Fatalf("new pi backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result, ok := <-session.Result:
|
|
if !ok {
|
|
t.Fatal("result channel closed without a value")
|
|
}
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed (stdin attached as fifo), got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
}
|
|
}
|
|
|
|
func TestStripPiToolCallMarkup(t *testing.T) {
|
|
tests := map[string]string{
|
|
`before call:bash{command:<|"|>cd repo/path && ls -F<|"|>}<tool_call|> after`: "before after",
|
|
`before call:read{path:<|"|>repo/path/roles/example/verify.yml<|"|>} after`: "before after",
|
|
`before response:bash{command:<|"|>multica issue comment list issue-id --all --output json<|"|>} after`: "before after",
|
|
`before call:bash{command:<|"|>printf '{"key":"value"}'<|"|>} after`: "before after",
|
|
`before <|turn>model after`: "before after",
|
|
}
|
|
for in, want := range tests {
|
|
got := stripPiToolCallMarkup(in)
|
|
if got != want {
|
|
t.Fatalf("unexpected stripped text: %q, want %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrainPiTextBufferSplitToolCall(t *testing.T) {
|
|
chunks := []string{
|
|
"before ca",
|
|
`ll:bash{command:<|"|>ls -R repo/path`,
|
|
`/roles/example<|"|>}`,
|
|
" after",
|
|
}
|
|
var buf strings.Builder
|
|
var got strings.Builder
|
|
for _, chunk := range chunks {
|
|
got.WriteString(drainPiTextBuffer(&buf, chunk))
|
|
}
|
|
got.WriteString(flushPiTextBuffer(&buf))
|
|
if got.String() != "before after" {
|
|
t.Fatalf("unexpected streamed text: %q", got.String())
|
|
}
|
|
}
|
|
|
|
func TestDrainPiTextBufferSplitControlToken(t *testing.T) {
|
|
chunks := []string{"before <|tu", "rn>model after"}
|
|
var buf strings.Builder
|
|
var got strings.Builder
|
|
for _, chunk := range chunks {
|
|
got.WriteString(drainPiTextBuffer(&buf, chunk))
|
|
}
|
|
got.WriteString(flushPiTextBuffer(&buf))
|
|
if got.String() != "before after" {
|
|
t.Fatalf("unexpected streamed text: %q", got.String())
|
|
}
|
|
}
|
|
|
|
func TestFlushPiTextBufferKeepsUnmatchedToolPrefixes(t *testing.T) {
|
|
tests := []string{
|
|
"plain response: see below",
|
|
"plain call: see below",
|
|
`plain call:bash{command:<|"|>unterminated`,
|
|
}
|
|
for _, want := range tests {
|
|
var buf strings.Builder
|
|
got := drainPiTextBuffer(&buf, want)
|
|
got += flushPiTextBuffer(&buf)
|
|
if got != want {
|
|
t.Fatalf("unexpected flushed text: %q, want %q", got, want)
|
|
}
|
|
}
|
|
}
|