mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
* test: remove implementation-only coverage Co-authored-by: multica-agent <[email protected]> * test: remove remaining implementation-only coverage Co-authored-by: multica-agent <[email protected]> * test: restore compatibility and fragment navigation coverage Co-authored-by: multica-agent <[email protected]> * test: preserve behavioral cleanup contracts Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
770 lines
28 KiB
Go
770 lines
28 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeDimACPScript impersonates `dim acp` for unit tests. Dim 0.3.10+
|
|
// releases its per-process session lock shortly after the owning process
|
|
// exits, so a follow-up run resumes via the standard ACP session/load. This
|
|
// fake answers session/load with a resumed session (retaining the requested
|
|
// id, matching the real server which does not echo sessionId on load). It
|
|
// records every request line to DIM_REQUESTS_FILE so tests can assert which
|
|
// RPCs were (and were not) sent.
|
|
func fakeDimACPScript() string {
|
|
return `#!/bin/sh
|
|
while IFS= read -r line; do
|
|
if [ -n "$DIM_REQUESTS_FILE" ]; then
|
|
printf '%s\n' "$line" >> "$DIM_REQUESTS_FILE"
|
|
fi
|
|
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
|
|
case "$line" in
|
|
*'"method":"initialize"'*)
|
|
if [ -n "$DIM_INIT_FAIL_HANG" ]; then
|
|
# Spawn a descendant BEFORE sending the error response, so the PID
|
|
# file is written before the backend's cleanup race begins. Then
|
|
# ignore TERM and hang so the process does not exit on stdin EOF —
|
|
# exercises the force-kill process-group cleanup path.
|
|
trap '' TERM
|
|
sleep 60 &
|
|
child_pid=$!
|
|
if [ -n "$DIM_CHILD_PID_FILE" ]; then
|
|
printf '%s' "$child_pid" > "$DIM_CHILD_PID_FILE"
|
|
fi
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"initialize failed"}}\n' "$id"
|
|
wait
|
|
else
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentInfo":{"name":"dimcode","version":"0.3.10"},"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":false}}}}\n' "$id"
|
|
fi
|
|
;;
|
|
*'"method":"session/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_dim_new","models":{"availableModels":[{"modelId":"dim/model-a","name":"Model A"}],"currentModelId":"dim/model-a"}}}\n' "$id"
|
|
;;
|
|
*'"method":"session/load"'*)
|
|
# The real dim ACP server resumes the session and returns configOptions
|
|
# (no sessionId field — the id is the one the client requested). When
|
|
# DIM_LOAD_NOT_FOUND is set, emulate a session that no longer exists so
|
|
# tests can exercise the ResumeRejected path. When DIM_LOAD_HELD_N is set,
|
|
# return "held by another process" for the first N calls then succeed,
|
|
# exercising the bounded retry loop.
|
|
if [ -n "$DIM_LOAD_NOT_FOUND" ]; then
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32002,"message":"ACP session not found","data":{"sessionId":"ses_prior"}}}\n' "$id"
|
|
elif [ -n "$DIM_LOAD_HELD_N" ]; then
|
|
held_file="${DIM_REQUESTS_FILE}_held_counter"
|
|
count=0
|
|
if [ -f "$held_file" ]; then count=$(cat "$held_file"); fi
|
|
if [ "$count" -lt "$DIM_LOAD_HELD_N" ]; then
|
|
count=$((count + 1))
|
|
echo "$count" > "$held_file"
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Session held by another process","data":{"details":"held"}}}\n' "$id"
|
|
else
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"configOptions":[{"id":"permission","currentValue":"full-access"},{"id":"mode","currentValue":"agent"}],"models":{"currentModelId":"dim/model-a"}}}\n' "$id"
|
|
fi
|
|
elif [ -n "$DIM_LOAD_HELD_ALWAYS" ]; then
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Session held by another process","data":{"details":"held"}}}\n' "$id"
|
|
else
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"configOptions":[{"id":"permission","currentValue":"full-access"},{"id":"mode","currentValue":"agent"}],"models":{"currentModelId":"dim/model-a"}}}\n' "$id"
|
|
fi
|
|
;;
|
|
*'"method":"session/set_config_option"'*)
|
|
if [ -n "$DIM_CONFIG_FAIL_ONCE" ] && [ ! -f "$DIM_CONFIG_FAIL_ONCE" ]; then
|
|
printf '%s' x > "$DIM_CONFIG_FAIL_ONCE"
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"config set failed"}}\n' "$id"
|
|
elif [ -n "$DIM_CONFIG_FAIL" ]; then
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"config set failed"}}\n' "$id"
|
|
else
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
|
|
fi
|
|
;;
|
|
*'"method":"session/set_model"'*)
|
|
if [ -n "$DIM_MODEL_FAIL" ]; then
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"set_model failed"}}\n' "$id"
|
|
else
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
|
|
fi
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
# When DIM_PROMPT_NO_STOP is set, omit stopReason so extractPromptResult
|
|
# never fires onPromptDone — exercises the bounded final-notification
|
|
# wait (the backend must still complete, not hang on a missing notify).
|
|
if [ -n "$DIM_PROMPT_NO_STOP" ]; then
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"usage":{"inputTokens":10,"outputTokens":20}}}\n' "$id"
|
|
elif [ -n "$DIM_LATE_NOTIFICATION" ]; then
|
|
# Return the prompt response, then emit a late agent_message_chunk
|
|
# notification after a short delay — exercises the notification
|
|
# quiescence drain (the late text must survive into Result.Output).
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":10,"outputTokens":20}}}\n' "$id"
|
|
sleep 0.1
|
|
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"%s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"late-answer"}}}}\n' "$DIM_SESSION_ID"
|
|
else
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":10,"outputTokens":20}}}\n' "$id"
|
|
fi
|
|
;;
|
|
*'"method":"session/close"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
|
|
;;
|
|
*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"method not found"}}\n' "$id"
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
func writeFakeDimScript(t *testing.T, requestsFile string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "dim")
|
|
if err := os.WriteFile(bin, []byte(fakeDimACPScript()), 0o755); err != nil {
|
|
t.Fatalf("write fake dim: %v", err)
|
|
}
|
|
return bin
|
|
}
|
|
|
|
func newDimTestBackend(t *testing.T, requestsFile string) Backend {
|
|
t.Helper()
|
|
return newDimTestBackendWithEnv(t, requestsFile, nil)
|
|
}
|
|
|
|
func newDimTestBackendWithEnv(t *testing.T, requestsFile string, extra map[string]string) Backend {
|
|
t.Helper()
|
|
bin := writeFakeDimScript(t, requestsFile)
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
env := map[string]string{"DIM_REQUESTS_FILE": requestsFile}
|
|
for k, v := range extra {
|
|
env[k] = v
|
|
}
|
|
b, err := New("dim", Config{
|
|
ExecutablePath: bin,
|
|
Logger: logger,
|
|
Env: env,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New(dim) error: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func readDimRequests(t *testing.T, path string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read dim requests: %v", err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// TestDimSessionNew verifies the happy path: initialize → session/new →
|
|
// set_config_option(permission/mode) → prompt, with a fresh session.
|
|
func TestDimSessionNew(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackend(t, requestsFile)
|
|
|
|
ctx := context.Background()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.SessionID != "ses_dim_new" {
|
|
t.Fatalf("expected session id ses_dim_new, got %q", result.SessionID)
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
if strings.Contains(reqs, "session/load") {
|
|
t.Fatal("backend must not send session/load on a fresh-session run")
|
|
}
|
|
// The ACP server hardcodes read-only permission; the backend must raise
|
|
// it to full-access and pin agent mode before the prompt.
|
|
if !strings.Contains(reqs, `"configId":"permission"`) || !strings.Contains(reqs, `"value":"full-access"`) {
|
|
t.Fatal("expected set_config_option permission=full-access")
|
|
}
|
|
if !strings.Contains(reqs, `"configId":"mode"`) || !strings.Contains(reqs, `"value":"agent"`) {
|
|
t.Fatal("expected set_config_option mode=agent")
|
|
}
|
|
if !strings.Contains(reqs, "session/prompt") {
|
|
t.Fatal("expected session/prompt")
|
|
}
|
|
// The backend must send session/close during teardown so dim releases the
|
|
// per-process session lock immediately (graceful exit), enabling the next
|
|
// run to resume without delay.
|
|
if !strings.Contains(reqs, "session/close") {
|
|
t.Fatal("expected session/close during teardown")
|
|
}
|
|
}
|
|
|
|
// TestDimResumeLoadsSession verifies that when the daemon asks to resume a
|
|
// prior session, the backend resumes it via the standard ACP session/load,
|
|
// reuses the resumed session id, and re-issues set_config_option (fail-closed
|
|
// against a partially configured session — review #4). ResumeRejected must
|
|
// stay false because the resume succeeded.
|
|
func TestDimResumeLoadsSession(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackend(t, requestsFile)
|
|
|
|
ctx := context.Background()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
ResumeSessionID: "ses_prior",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.SessionID != "ses_prior" {
|
|
t.Fatalf("expected the resumed session id ses_prior, got %q", result.SessionID)
|
|
}
|
|
if result.ResumeRejected {
|
|
t.Fatal("ResumeRejected must be false when session/load succeeded")
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
if !strings.Contains(reqs, "session/load") {
|
|
t.Fatal("expected a session/load when a resume was requested")
|
|
}
|
|
if strings.Contains(reqs, "session/new") {
|
|
t.Fatal("backend must not send session/new when the resume succeeded")
|
|
}
|
|
// A resumed session is re-issued set_config_option (permission/mode) to
|
|
// guard against a partially configured session being resumed — see
|
|
// review #4. So set_config_option MUST appear after a successful load.
|
|
if !strings.Contains(reqs, "set_config_option") {
|
|
t.Fatal("set_config_option must be re-applied on a resumed session (fail-closed)")
|
|
}
|
|
}
|
|
|
|
// TestDimResumeNotFound verifies that when session/load reports the prior
|
|
// session is gone (ACP -32002 session not found), the backend fails with
|
|
// ResumeRejected=true so the daemon retries on a fresh session instead of
|
|
// silently losing the conversation.
|
|
func TestDimResumeNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{"DIM_LOAD_NOT_FOUND": "1"})
|
|
|
|
ctx := context.Background()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
ResumeSessionID: "ses_prior",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed, got %q", result.Status)
|
|
}
|
|
if !result.ResumeRejected {
|
|
t.Fatal("expected ResumeRejected=true when session/load reports not found")
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
if !strings.Contains(reqs, "session/load") {
|
|
t.Fatal("expected a session/load attempt")
|
|
}
|
|
if strings.Contains(reqs, "session/new") {
|
|
t.Fatal("backend must not fall through to session/new itself; the daemon owns the fresh retry")
|
|
}
|
|
}
|
|
|
|
// TestDimCleanupKillsHangingChild verifies the deferred cleanup force-kills the
|
|
// entire process group — not just the direct child — when a child ignores stdin
|
|
// EOF and SIGTERM after an early RPC failure, so Result still closes AND no
|
|
// descendant is orphaned (review #2/#3). Without the group force-kill,
|
|
// cmd.Wait() would hang forever and the sleep descendant would survive as a
|
|
// PPID=1 orphan.
|
|
func TestDimCleanupKillsHangingChild(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
requestsFile := filepath.Join(dir, "requests.jsonl")
|
|
childPidFile := filepath.Join(dir, "child_pid")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{
|
|
"DIM_INIT_FAIL_HANG": "1",
|
|
"DIM_CHILD_PID_FILE": childPidFile,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 20 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
// Result must close within a bounded window — the group force-kill
|
|
// (bounded by dimProcessWaitTimeout) guarantees the hung child cannot
|
|
// block it.
|
|
select {
|
|
case result := <-session.Result:
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed from the initialize error, got %q", result.Status)
|
|
}
|
|
case <-time.After(dimProcessWaitTimeout + 15*time.Second):
|
|
t.Fatal("Result never closed: the hanging child was not force-killed within the cleanup timeout")
|
|
}
|
|
|
|
// The descendant (sleep 60) must be gone too — not orphaned to PPID=1.
|
|
// Give the PID file a moment to appear (the child is spawned after the
|
|
// error response is written).
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
var pidStr string
|
|
for time.Now().Before(deadline) {
|
|
data, err := os.ReadFile(childPidFile)
|
|
if err == nil && len(data) > 0 {
|
|
pidStr = string(data)
|
|
break
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
if pidStr == "" {
|
|
t.Fatal("child PID file was never written; the fake did not spawn a descendant")
|
|
}
|
|
childPid, err := strconv.Atoi(strings.TrimSpace(pidStr))
|
|
if err != nil {
|
|
t.Fatalf("invalid child PID %q: %v", pidStr, err)
|
|
}
|
|
// Verify the descendant is no longer alive. Poll briefly because the
|
|
// group SIGKILL and the kernel's process-table reaping are asynchronous
|
|
// with respect to Result delivery.
|
|
proc, err := os.FindProcess(childPid)
|
|
if err != nil {
|
|
return // already reaped
|
|
}
|
|
goneDeadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(goneDeadline) {
|
|
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
|
return // process is gone
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Fatalf("descendant PID %d is still alive after cleanup: process group was not reaped", childPid)
|
|
}
|
|
|
|
// TestDimPromptMissingNotificationStillCompletes verifies the success path
|
|
// does not hang when the runtime returns session/prompt without a stopReason
|
|
// (so onPromptDone never fires). The bounded final-notification wait must
|
|
// fall through and let Result close (review #3 success-path race).
|
|
func TestDimPromptMissingNotificationStillCompletes(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{"DIM_PROMPT_NO_STOP": "1"})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 20 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result := <-session.Result:
|
|
// The turn completed (session/prompt returned ok); the missing
|
|
// stopReason only means no usage/stopReason was captured. Status must
|
|
// still be completed, not hang or fail.
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
case <-time.After(dimNotificationQuietTime + 15*time.Second):
|
|
t.Fatal("Result never closed: the missing prompt notification was not bounded by the quiet-time wait")
|
|
}
|
|
}
|
|
|
|
// TestDimDrainsLateFinalNotificationAfterPromptResponse verifies that a
|
|
// notification arriving just after the session/prompt response is not lost —
|
|
// the activity-based quiescence drain gives the stdout reader time to consume
|
|
// it before stdin is closed (review #3, the Dim equivalent of
|
|
// TestHermesBackendDrainsLateFinalNotificationAfterPromptResponse).
|
|
func TestDimDrainsLateFinalNotificationAfterPromptResponse(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{
|
|
"DIM_LATE_NOTIFICATION": "1",
|
|
"DIM_SESSION_ID": "ses_dim_new",
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 15 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result := <-session.Result:
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Output, "late-answer") {
|
|
t.Fatalf("late notification was lost: expected output to contain %q, got %q", "late-answer", result.Output)
|
|
}
|
|
case <-time.After(20 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
}
|
|
}
|
|
|
|
// TestDimConfigFailClosesSession verifies that when set_config_option fails,
|
|
// the backend sends session/close before returning the error, so a partially
|
|
// configured session is not left for the next resume to inherit without
|
|
// full-access (review #4).
|
|
func TestDimConfigFailClosesSession(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{"DIM_CONFIG_FAIL": "1"})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 15 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
result := <-session.Result
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed, got %q", result.Status)
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
// set_config_option must have been attempted.
|
|
if !strings.Contains(reqs, "set_config_option") {
|
|
t.Fatal("expected a set_config_option attempt")
|
|
}
|
|
// session/close must have been sent to clean up the partially configured
|
|
// session so it is not resumed later without full-access.
|
|
if !strings.Contains(reqs, "session/close") {
|
|
t.Fatal("expected session/close after config failure to prevent a partially configured resume")
|
|
}
|
|
}
|
|
|
|
// TestDimConfigFailThenResumeReestablishes verifies the "first setup fails →
|
|
// next run resumes" path (review #4): when the first run's set_config_option
|
|
// fails, session/close is sent; a second run that resumes the same session
|
|
// re-applies set_config_option (fail-closed) and succeeds.
|
|
func TestDimConfigFailThenResumeReestablishes(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
requestsFile := filepath.Join(dir, "requests.jsonl")
|
|
failFlag := filepath.Join(dir, "config_failed")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{
|
|
"DIM_CONFIG_FAIL_ONCE": failFlag,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Run A: fresh session, set_config_option fails on the first call.
|
|
sessionA, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 20 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute A: %v", err)
|
|
}
|
|
go func() {
|
|
for range sessionA.Messages {
|
|
}
|
|
}()
|
|
resultA := <-sessionA.Result
|
|
if resultA.Status != "failed" {
|
|
t.Fatalf("run A: expected status=failed, got %q", resultA.Status)
|
|
}
|
|
if resultA.SessionID == "" {
|
|
t.Fatal("run A: expected a session id despite the config failure")
|
|
}
|
|
|
|
// Run B: resume the same session. set_config_option should succeed this
|
|
// time (DIM_CONFIG_FAIL_ONCE only fails once) and the run should complete.
|
|
sessionB, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 20 * time.Second,
|
|
ResumeSessionID: resultA.SessionID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute B: %v", err)
|
|
}
|
|
go func() {
|
|
for range sessionB.Messages {
|
|
}
|
|
}()
|
|
resultB := <-sessionB.Result
|
|
if resultB.Status != "completed" {
|
|
t.Fatalf("run B: expected status=completed, got %q (error=%q)", resultB.Status, resultB.Error)
|
|
}
|
|
if resultB.ResumeRejected {
|
|
t.Fatal("run B: ResumeRejected should be false — the session was loadable after session/close")
|
|
}
|
|
|
|
// Verify run B re-applied set_config_option (fail-closed on resume).
|
|
reqs := readDimRequests(t, requestsFile)
|
|
if !strings.Contains(reqs, "session/load") {
|
|
t.Fatal("run B: expected session/load")
|
|
}
|
|
// set_config_option should appear at least 3 times: 1 failed in run A,
|
|
// 2 successful in run B (permission + mode).
|
|
count := strings.Count(reqs, "set_config_option")
|
|
if count < 3 {
|
|
t.Fatalf("expected at least 3 set_config_option calls (1 fail + 2 re-apply on resume), got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestDimVersionSupported pins the fail-closed version gate (review #3): only
|
|
// a parseable semver >= 0.3.10 is accepted. Empty, non-parseable, and
|
|
// too-short versions are rejected so a runtime that cannot prove it meets the
|
|
// minimum is blocked from session resume.
|
|
func TestDimVersionSupported(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
version string
|
|
want bool
|
|
}{
|
|
{"0.3.10", true},
|
|
{"0.3.9", false},
|
|
{"0.4.0", true},
|
|
{"1.0.0", true},
|
|
{"", false},
|
|
{"abc", false},
|
|
{"0.3", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := dimVersionSupported(tc.version); got != tc.want {
|
|
t.Errorf("dimVersionSupported(%q) = %v, want %v", tc.version, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDimSetModelFailClosesSession verifies that when session/set_model fails,
|
|
// the backend sends session/close before returning the error, so a partially
|
|
// configured session (permission/mode set, model not) is not left for the next
|
|
// resume to inherit (review #5).
|
|
func TestDimSetModelFailClosesSession(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{"DIM_MODEL_FAIL": "1"})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Model: "some-model",
|
|
Timeout: 15 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
result := <-session.Result
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
// session/set_model must have been attempted with the requested model.
|
|
if !strings.Contains(reqs, "session/set_model") {
|
|
t.Fatal("expected a session/set_model attempt")
|
|
}
|
|
if !strings.Contains(reqs, `"modelId":"some-model"`) {
|
|
t.Fatal("expected session/set_model to carry the requested model id")
|
|
}
|
|
// session/close must have been sent to clean up the partially configured
|
|
// session (permission/mode set, model not) so it is not resumed later.
|
|
if !strings.Contains(reqs, "session/close") {
|
|
t.Fatal("expected session/close after set_model failure to prevent a partially configured resume")
|
|
}
|
|
// The prompt must NOT have been sent — the model switch failed, so the
|
|
// turn aborts before session/prompt.
|
|
if strings.Contains(reqs, "session/prompt") {
|
|
t.Fatal("backend must not send session/prompt after set_model failure")
|
|
}
|
|
}
|
|
|
|
// TestDimSessionLoadRetrySucceeds verifies that session/load retries on
|
|
// "held by another process" and eventually succeeds (review #4 round 4).
|
|
func TestDimSessionLoadRetrySucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{
|
|
"DIM_LOAD_HELD_N": "2", // held for first 2 calls, succeeds on 3rd
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 50 * time.Second,
|
|
ResumeSessionID: "ses_prior",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.ResumeRejected {
|
|
t.Fatal("ResumeRejected must be false — retry succeeded")
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
loadCount := strings.Count(reqs, "session/load")
|
|
if loadCount < 3 {
|
|
t.Fatalf("expected at least 3 session/load attempts (2 held + 1 success), got %d", loadCount)
|
|
}
|
|
if strings.Contains(reqs, "session/new") {
|
|
t.Fatal("backend must not fall through to session/new when retry succeeds")
|
|
}
|
|
}
|
|
|
|
// TestDimSessionLoadRetryExhausted verifies that after exhausting retries on
|
|
// "held by another process", the backend fails without falling through to
|
|
// session/new (review #4 round 4).
|
|
func TestDimSessionLoadRetryExhausted(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{
|
|
"DIM_LOAD_HELD_ALWAYS": "1",
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 50 * time.Second,
|
|
ResumeSessionID: "ses_prior",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
if result.Status != "failed" && result.Status != "timeout" {
|
|
t.Fatalf("expected status=failed or timeout, got %q", result.Status)
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
loadCount := strings.Count(reqs, "session/load")
|
|
// dimSessionLoadRetryAttempts=3, so 1 initial + 3 retries = 4 attempts
|
|
if loadCount != 4 {
|
|
t.Fatalf("expected exactly 4 session/load attempts (1 + 3 retries), got %d", loadCount)
|
|
}
|
|
if strings.Contains(reqs, "session/new") {
|
|
t.Fatal("backend must not fall through to session/new when retries are exhausted")
|
|
}
|
|
}
|
|
|
|
// TestDimSessionLoadRetryCancelled verifies that cancelling the context during
|
|
// the retry delay aborts the loop without falling through to session/new
|
|
// (review #4 round 4).
|
|
func TestDimSessionLoadRetryCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
requestsFile := filepath.Join(t.TempDir(), "requests.jsonl")
|
|
b := newDimTestBackendWithEnv(t, requestsFile, map[string]string{
|
|
"DIM_LOAD_HELD_ALWAYS": "1",
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
session, err := b.Execute(ctx, "test prompt", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
Timeout: 50 * time.Second,
|
|
ResumeSessionID: "ses_prior",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
// Wait for the first session/load request to appear, then cancel during
|
|
// the retry delay — this is deterministic rather than a fixed sleep.
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(requestsFile); err == nil {
|
|
data, _ := os.ReadFile(requestsFile)
|
|
if strings.Contains(string(data), "session/load") {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
cancel()
|
|
|
|
result := <-session.Result
|
|
if result.Status != "failed" && result.Status != "aborted" && result.Status != "timeout" {
|
|
t.Fatalf("expected status=failed/aborted/timeout, got %q", result.Status)
|
|
}
|
|
|
|
reqs := readDimRequests(t, requestsFile)
|
|
if strings.Contains(reqs, "session/new") {
|
|
t.Fatal("backend must not fall through to session/new when cancelled during retry")
|
|
}
|
|
}
|