Files
Agent-System-Backend/internal/store/project_work_outbox_test.go
T
weichen 7cee124b8f Capture actual project work sources before reuse
Why: Project retrieval hits, tool results, and saved visible analysis must be individually traceable and fully analyzed before later project actions consume them.

Evidence: RED tests first showed missing run-source capture, tool-result capture, and run-wide preflight; go test -p 1 ./... and go build ./... passed; isolated PostgreSQL source, migration, audit, and context tests passed.

Impact: Persist actual run-bound sources with current-status checks and exact manifests behind the disabled X3 assembly gate; no live product path, migration, deployment, or release is enabled.

Refs: TT-N03, TT-R01; approved X3 stage-five plan r1
2026-10-14 16:25:22 +00:00

143 lines
6.3 KiB
Go

package store
import (
"crypto/sha256"
"fmt"
"testing"
"time"
core "agent-system-core"
)
func TestProjectMessageOutboxFollowsCommittedMessage(t *testing.T) {
st := openTestStore(t)
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
p, err := st.CreateProject("alice", "Project")
if err != nil {
t.Fatal(err)
}
projectConv, err := st.CreateConversation("alice", string(p.ID), "Work", "")
if err != nil {
t.Fatal(err)
}
privateConv, err := st.CreateConversation("alice", string(p.ID), "Private", "")
if err != nil {
t.Fatal(err)
}
msg := core.Message{ConversationID: projectConv.ID, Role: core.RoleUser, Parts: []core.Part{
{Type: core.PartText, Text: "要求效率和成本都低于人工"},
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "file-1"}},
}}
if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "request-1"); err != nil {
t.Fatal(err)
}
rows, err := st.PendingProjectWorkObservations(10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("message and artifact parts were not each recorded: %+v", rows)
}
byPart := map[int]ProjectWorkObservationRow{}
for _, row := range rows {
byPart[row.PartIndex] = row
}
wantHash := fmt.Sprintf("%x", sha256.Sum256([]byte(msg.Parts[0].Text)))
textPart := byPart[0]
if textPart.ProjectID != string(p.ID) || textPart.Owner != "alice" || textPart.SourceID != string(msg.ID) || textPart.SourceSHA256 != wantHash || textPart.Text != msg.Parts[0].Text || textPart.Delivery != "complete" || textPart.RunID != "" {
t.Fatalf("incorrect trusted user message observation: %+v", textPart)
}
artifactPart := byPart[1]
if artifactPart.Delivery != "partial" || artifactPart.Text != "" || artifactPart.SourceID != string(msg.ID) {
t.Fatalf("unparsed attachment was presented as analyzed text: %+v", artifactPart)
}
run := core.Run{ConversationID: projectConv.ID, ProjectID: p.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
if err := st.SetMessageRunIDAccountChecked("alice", string(msg.ID), string(run.ID), false); err != nil {
t.Fatal(err)
}
linked, err := st.PendingProjectWorkObservations(10)
if err != nil || len(linked) != 2 || linked[0].RunID != string(run.ID) || linked[1].RunID != string(run.ID) {
t.Fatalf("later trusted run link was not saved for every source part: %+v %v", linked, err)
}
runSources, err := st.ProjectRunObservations(string(run.ID))
if err != nil || len(runSources) != 2 {
t.Fatalf("run preflight did not find its linked user parts: %+v %v", runSources, err)
}
retry := core.Message{ConversationID: projectConv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "changed"}}}
if err := st.AppendMessageWithRequestAccountChecked("alice", false, &retry, "request-1"); err == nil {
t.Fatal("duplicate request did not surface existing message")
}
private := core.Message{ConversationID: privateConv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "private"}}}
if err := st.AppendMessageWithRequestAccountChecked("alice", false, &private, "request-2"); err != nil {
t.Fatal(err)
}
again, err := st.PendingProjectWorkObservations(10)
if err != nil || len(again) != 3 {
t.Fatalf("duplicate request generated an extra source or private source was lost: %+v %v", again, err)
}
for _, row := range again {
if row.Scope != "private" {
t.Fatalf("project locator alone changed a message's private scope: %+v", row)
}
}
if err := st.AckProjectWorkObservation(textPart.EventID, "receipt-1"); err != nil {
t.Fatal(err)
}
allParts, err := st.ProjectMessageObservations(string(msg.ID))
if err != nil || len(allParts) != 2 {
t.Fatalf("ACK erased exact action source manifest: %+v %v", allParts, err)
}
if err := st.AckProjectWorkObservation(textPart.EventID, "receipt-1"); err != nil {
t.Fatalf("same committed receipt was not idempotent: %v", err)
}
if err := st.AckProjectWorkObservation(textPart.EventID, "different"); err == nil {
t.Fatal("different observer receipt replaced the first one")
}
pending, err := st.PendingProjectWorkObservations(10)
if err != nil || len(pending) != 2 {
t.Fatalf("ACK did not remove only the delivered source: %+v %v", pending, err)
}
}
func TestAssistantObservationCommitsOnlyWithFencedRunSettlement(t *testing.T) {
st := openTestStore(t)
conv := seedConversation(t, st)
run := seedRun(t, st, conv, core.RunRunning)
now := time.Now().UTC()
run.Status, run.Phase, run.EndedAt = core.RunCompleted, core.PhaseDone, &now
answer := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant, Parts: []core.Part{
{Type: core.PartText, Text: "需要澄清效率的含义"},
{Type: core.PartPlan, Plan: &core.PlanSnapshot{Title: "可见分析计划", Steps: []core.PlanStep{{ID: "clarify", Text: "确认比较方向", Status: "pending"}}}},
}}
updated, err := st.SettleRun(&run, &answer)
if err != nil || !updated {
t.Fatalf("run settlement failed: updated=%t err=%v", updated, err)
}
rows, err := st.PendingProjectWorkObservations(10)
if err != nil || len(rows) != 2 {
t.Fatalf("final assistant observation was not committed with the run: %+v %v", rows, err)
}
byPart := map[int]ProjectWorkObservationRow{}
for _, row := range rows {
byPart[row.PartIndex] = row
}
if byPart[0].SourceKind != "assistant-message" || byPart[0].SourceID != string(answer.ID) || byPart[0].RunID != string(run.ID) || byPart[0].Text != answer.Parts[0].Text || byPart[1].Delivery != "complete" || byPart[1].Metadata["partType"] != string(core.PartPlan) || byPart[1].Text == "" {
t.Fatalf("visible analysis or final answer was lost: %+v", rows)
}
secondRun := seedRun(t, st, conv, core.RunRunning)
secondRun.Status, secondRun.Phase, secondRun.EndedAt = core.RunCompleted, core.PhaseDone, &now
failed := core.Message{ID: answer.ID, ConversationID: conv.ID, RunID: secondRun.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "different"}}}
if _, err := st.SettleRun(&secondRun, &failed); err == nil {
t.Fatal("duplicate final message did not fail")
}
again, err := st.PendingProjectWorkObservations(10)
if err != nil || len(again) != 2 {
t.Fatalf("failed settlement added a second observation: %+v %v", again, err)
}
}