package server import ( "bytes" "context" "encoding/json" "errors" "iter" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/norma/llm" "github.com/Autumn-27/norma/transcript" ) type workerMessageTestProvider struct{} func (workerMessageTestProvider) Stream(_ context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { return func(yield func(llm.StreamEvent, error) bool) { yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "已按新的对话意图继续执行"}, nil) yield(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: "end_turn"}, nil) yield(llm.StreamEvent{Type: llm.SEMessageStop}, nil) } } func (p workerMessageTestProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { acc := llm.NewAccumulator() for event, err := range p.Stream(ctx, req) { if err != nil { return llm.Message{}, "", llm.Usage{}, err } acc.Add(event) } return acc.Message(), acc.StopReason, acc.Usage, nil } type interventionProtocolFixture struct { m *Manager task *Task engine *Engine worker *agent.Worker tx *transcript.Store intentID int64 } func newInterventionProtocolFixture(t *testing.T, targetPriority int) *interventionProtocolFixture { t.Helper() m, err := NewManager(t.TempDir(), "") if err != nil { t.Skipf("postgres unavailable (%v) - skipping", err) } task, err := m.CreateTask("worker message protocol", "verify immediate conversation handoff", nil, 0, 0) if err != nil { m.Close() t.Fatal(err) } intentID, err := task.Store.AddIntent(map[string]any{"summary": "target worker intent"}, targetPriority, nil, "planner") if err != nil { m.Close() t.Fatal(err) } if claimed, err := task.Store.ClaimIntent(intentID, "work#1"); err != nil || !claimed { m.Close() t.Fatalf("claim target intent: claimed=%v err=%v", claimed, err) } tx := transcript.NewStore(m.dir + "/transcripts") worker := agent.NewWorker(workerMessageTestProvider{}, "test-model", m.dir, tx, 0, 1) engine := NewEngine(m) engine.UseLLM(nil, worker) fixture := &interventionProtocolFixture{m: m, task: task, engine: engine, worker: worker, tx: tx, intentID: intentID} t.Cleanup(func() { taskID, _ := strconv.ParseInt(task.ID, 10, 64) _ = m.pg.DeleteTask(taskID) _ = m.Close() }) return fixture } // settleRegisteredIntervention emulates the ordering inside runWorkerStep after // a cancellation-aware provider returns: detach the live work handle, commit the // running->paused final state, install the directed hand-off, then release the // controller waiting on workExecution.done. func settleRegisteredIntervention(t *testing.T, f *interventionProtocolFixture) string { t.Helper() action, complete := f.engine.detachWork(f.intentID) if action != "intervene" { t.Fatalf("settled action=%q, want intervene", action) } if changed, err := f.task.Store.CompareAndSetIntentState(f.intentID, "running", "paused"); err != nil || !changed { t.Fatalf("settle running->paused: changed=%v err=%v", changed, err) } f.engine.queueDirectedClaim(f.task.ID, f.intentID) complete(nil) return action } func countTranscriptWorkerMessages(t *testing.T, tx *transcript.Store, sessionID, requestID string) (int, []transcript.Record) { t.Helper() records, err := tx.Load(tx.MainPath(sessionID)) if err != nil { t.Fatal(err) } marker := "ARTEX_WORKER_CHAT:" + requestID count := 0 for _, record := range records { if record.Message != nil && strings.Contains(record.Message.Text(), marker) { count++ } } return count, records } func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing.T) { f := newInterventionProtocolFixture(t, 1) competitorID, err := f.task.Store.AddIntent(map[string]any{"summary": "higher priority competitor"}, 100, nil, "planner") if err != nil { t.Fatal(err) } // Pre-seed the same stable session so the message must continue its UUID // chain rather than creating a worker-slot-specific conversation. sessionID := agent.WorkerSessionID(f.task.ExpID, f.intentID) seed := f.tx.NewWriter(sessionID, "") seed.RecordMessage(llm.UserText("original intent turn"), llm.Usage{}) if err := seed.Err(); err != nil { t.Fatal(err) } before, err := f.tx.Load(f.tx.MainPath(sessionID)) if err != nil || len(before) != 1 { t.Fatalf("seed transcript records=%d err=%v", len(before), err) } workCtx, cancelWork := context.WithCancelCause(context.Background()) f.engine.registerWork(f.intentID, cancelWork) live, unsubscribe := f.engine.Broadcaster().Subscribe(f.task.ID) defer unsubscribe() const requestID = "worker-message-engine-1" const message = "停止主动枚举,立即验证登录越权" type outcome struct { result WorkInterventionResult err error } done := make(chan outcome, 1) go func() { result, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, message) done <- outcome{result: result, err: err} }() select { case <-workCtx.Done(): if code, _, _, _ := agent.AbortReason(workCtx); code != "work_intervened_by_user" { t.Fatalf("old execution cancel code=%q, want work_intervened_by_user", code) } case <-time.After(time.Second): t.Fatal("Worker message did not immediately cancel the old execution") } select { case early := <-done: t.Fatalf("Worker message accepted before old worker settled: %+v", early) case <-time.After(25 * time.Millisecond): } pending, err := f.task.Store.IntentInterventionByRequest(f.intentID, requestID) if err != nil || pending == nil || pending.Status != db.IntentInterventionPending { t.Fatalf("durable pending reservation=%+v err=%v", pending, err) } settleRegisteredIntervention(t, f) var accepted outcome select { case accepted = <-done: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for accepted Worker message") } if accepted.err != nil || accepted.result.ActivityID <= pending.ActivityID || accepted.result.Duplicate || accepted.result.State != "open" { t.Fatalf("accepted Worker message=%+v control=%+v err=%v", accepted.result, pending, accepted.err) } node, err := f.task.Store.GetNode(f.intentID) if err != nil || node == nil || node.State != "open" { t.Fatalf("reopened target=%+v err=%v", node, err) } // The API publishes the visible user activity but never edits transcript files. // agentcore appends the standard user turn only after the same Worker is claimed. count, records := countTranscriptWorkerMessages(t, f.tx, sessionID, requestID) if count != 0 || len(records) != 1 { t.Fatalf("HTTP handler edited Worker transcript: count=%d records=%+v", count, records) } select { case activity := <-live: if activity.ID != accepted.result.ActivityID || activity.NodeID == nil || *activity.NodeID != f.intentID || activity.Worker != "user" || activity.Kind != "user" || activity.Summary != message { t.Fatalf("broadcast activity=%+v", activity) } case <-time.After(time.Second): t.Fatal("accepted Worker message was not broadcast") } select { case duplicate := <-live: t.Fatalf("accepted Worker message broadcast twice: %+v", duplicate) default: } // The directed hand-off must outrank the normal frontier without changing the // persisted priority. This catches the one-worker race where the released slot // used to take an unrelated, higher-priority intent before the target reopened. claimed := f.engine.claimNext(f.task, "work#2") if claimed == nil || claimed.ID != f.intentID { t.Fatalf("directed claim=%+v, want target %d ahead of competitor %d", claimed, f.intentID, competitorID) } competitor, err := f.task.Store.GetNode(competitorID) if err != nil || competitor == nil || competitor.State != "open" { t.Fatalf("competitor was claimed before Worker message target: node=%+v err=%v", competitor, err) } request, nextMessage, hasMessage, err := f.task.Store.PendingIntentInterventionMessage(f.intentID) if err != nil || !hasMessage || request != requestID || nextMessage != message { t.Fatalf("pending Worker message request=%q message=%q present=%v err=%v", request, nextMessage, hasMessage, err) } taskID, _ := strconv.ParseInt(f.task.ID, 10, 64) if _, _, err := f.worker.ExecuteWithMessage(context.Background(), "work#2", taskID, nil, f.task.Store, claimed, nil, nil, nil, nil, request, nextMessage); err != nil { t.Fatalf("continue Worker conversation: %v", err) } if err := f.task.Store.MarkIntentInterventionHandoffClaimed(f.intentID); err != nil { t.Fatal(err) } count, records = countTranscriptWorkerMessages(t, f.tx, sessionID, requestID) if count != 1 || len(records) != 3 || records[1].ParentUUID != records[0].UUID || records[2].ParentUUID != records[1].UUID { t.Fatalf("Worker message did not form one normal chained turn: count=%d records=%+v", count, records) } if records[1].Message == nil || records[1].Message.Role != llm.RoleUser || !strings.Contains(records[1].Message.Text(), message) { t.Fatalf("Worker message was not recorded as the next user turn: %+v", records[1]) } if _, _, pendingAfterRun, err := f.task.Store.PendingIntentInterventionMessage(f.intentID); err != nil || pendingAfterRun { t.Fatalf("Worker message handoff remains pending=%v err=%v", pendingAfterRun, err) } retry, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, message) if err != nil || !retry.Duplicate || retry.ActivityID != accepted.result.ActivityID { t.Fatalf("idempotent engine retry=%+v err=%v", retry, err) } if _, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, "同 request_id 的另一条消息"); !errors.Is(err, errWorkInterventionConflict) { t.Fatalf("same request_id different message error=%v, want conflict", err) } count, _ = countTranscriptWorkerMessages(t, f.tx, sessionID, requestID) if count != 1 { t.Fatalf("engine retries appended %d Worker messages, want 1", count) } } func TestSendWorkerMessageContinuesAfterRequestContextCancellation(t *testing.T) { f := newInterventionProtocolFixture(t, 1) workCtx, cancelWork := context.WithCancelCause(context.Background()) f.engine.registerWork(f.intentID, cancelWork) s := &Server{m: f.m, engine: f.engine, ctx: context.Background()} requestBody, _ := json.Marshal(map[string]string{ "message": "中止当前动作,只验证权限边界", "request_id": "worker-message-disconnect-1", }) requestCtx, disconnect := context.WithCancel(context.Background()) disconnect() req := httptest.NewRequest(http.MethodPost, "/api/tasks/1/intents/1/messages", bytes.NewReader(requestBody)).WithContext(requestCtx) req.SetPathValue("id", f.task.ID) req.SetPathValue("iid", strconv.FormatInt(f.intentID, 10)) recorder := httptest.NewRecorder() done := make(chan struct{}) go func() { s.sendWorkerMessage(recorder, req) close(done) }() select { case <-workCtx.Done(): case <-time.After(time.Second): t.Fatal("disconnected request did not continue to cancel the worker") } settleRegisteredIntervention(t, f) select { case <-done: case <-time.After(2 * time.Second): t.Fatal("server-owned Worker message did not finish after browser disconnect") } if recorder.Code != http.StatusOK { t.Fatalf("disconnected Worker message status=%d body=%s", recorder.Code, recorder.Body.String()) } accepted, err := f.task.Store.IntentInterventionByRequest(f.intentID, "worker-message-disconnect-1") if err != nil || accepted == nil || accepted.Status != db.IntentInterventionAccepted { t.Fatalf("disconnected request acceptance=%+v err=%v", accepted, err) } } func TestWorkerMessageRejectsUnavailableTaskStatesWithoutReservation(t *testing.T) { cases := []struct { name string block func(*interventionProtocolFixture) }{ {name: "paused", block: func(f *interventionProtocolFixture) { f.task.updateLifecycle(func(state *taskLifecycleState) { state.Paused = true }) f.engine.Pause(f.task.ID, agent.AbortPausedByUser) }}, {name: "queued", block: func(f *interventionProtocolFixture) { f.task.updateLifecycle(func(state *taskLifecycleState) { state.Queued = true }) }}, {name: "terminal", block: func(f *interventionProtocolFixture) { f.task.updateLifecycle(func(state *taskLifecycleState) { state.Status = "done" }) }}, {name: "settling", block: func(f *interventionProtocolFixture) { f.engine.markSettling(f.task.ID) }}, {name: "deleting", block: func(f *interventionProtocolFixture) { f.engine.BeginDelete(f.task.ID) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newInterventionProtocolFixture(t, 1) tc.block(f) requestID := "worker-message-unavailable-" + tc.name if _, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, "must not persist"); !errors.Is(err, errWorkInterventionConflict) { t.Fatalf("unavailable state error=%v, want conflict", err) } reservation, err := f.task.Store.IntentInterventionByRequest(f.intentID, requestID) if err != nil || reservation != nil { t.Fatalf("unavailable state persisted reservation=%+v err=%v", reservation, err) } node, err := f.task.Store.GetNode(f.intentID) if err != nil || node == nil || node.State != "running" { t.Fatalf("unavailable state mutated intent=%+v err=%v", node, err) } count, _ := countTranscriptWorkerMessages(t, f.tx, agent.WorkerSessionID(f.task.ExpID, f.intentID), requestID) if count != 0 { t.Fatalf("unavailable state appended %d transcript interventions", count) } }) } } func TestWorkerWakeInterruptsLongIdlePoll(t *testing.T) { task := &Task{workerWake: make(chan struct{}, 1)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() started := make(chan struct{}) returned := make(chan bool, 1) go func() { close(started) returned <- waitWorker(ctx, task, 5*time.Second) }() <-started start := time.Now() task.NotifyWorker() select { case stopped := <-returned: if stopped { t.Fatal("worker wake was mistaken for context shutdown") } if elapsed := time.Since(start); elapsed > 250*time.Millisecond { t.Fatalf("worker wake took %s, want immediate wake", elapsed) } case <-time.After(time.Second): t.Fatal("worker wake waited for the idle polling interval") } } func TestSendWorkerMessageRejectsOversizedBodyAsEntityTooLarge(t *testing.T) { f := newInterventionProtocolFixture(t, 1) s := &Server{m: f.m, engine: f.engine, ctx: context.Background()} body := `{"message":"` + strings.Repeat("x", maxWorkerMessageBytes) + `","request_id":"oversized"}` req := httptest.NewRequest(http.MethodPost, "/api/tasks/1/intents/1/messages", strings.NewReader(body)) req.SetPathValue("id", f.task.ID) req.SetPathValue("iid", strconv.FormatInt(f.intentID, 10)) recorder := httptest.NewRecorder() s.sendWorkerMessage(recorder, req) if recorder.Code != http.StatusRequestEntityTooLarge { t.Fatalf("oversized Worker message status=%d body=%s", recorder.Code, recorder.Body.String()) } }