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
143 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|