From 5d9f11a33902a8c7aaf72b83777d7281bf017ac5 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:36:19 -0500 Subject: [PATCH 01/16] workstream: agent pages stay the agent's, lookup by agent, markdown import The store now refuses, inside the save transaction, an agent write to a page the person owns, and an agent create of a page it would not own, so a page made the person's between a read and a save is still refused. ByAgent finds the workstream whose agent is a conversation. pagedoc.FromMarkdown turns markdown into the editor JSON the page renderer reads, live-block markers included, and the contract's example round-trips byte for byte. Co-Authored-By: Claude Opus 5.5 --- internal/workstream/pagedoc/frommarkdown.go | 285 ++++++++++++++++++ .../workstream/pagedoc/frommarkdown_test.go | 56 ++++ internal/workstream/pages.go | 6 + internal/workstream/pages_test.go | 4 + internal/workstream/workstream_agent.go | 30 ++ internal/workstream/workstream_agent_test.go | 106 +++++++ 6 files changed, 487 insertions(+) create mode 100644 internal/workstream/pagedoc/frommarkdown.go create mode 100644 internal/workstream/pagedoc/frommarkdown_test.go create mode 100644 internal/workstream/workstream_agent.go create mode 100644 internal/workstream/workstream_agent_test.go diff --git a/internal/workstream/pagedoc/frommarkdown.go b/internal/workstream/pagedoc/frommarkdown.go new file mode 100644 index 0000000000..fa7165a3d8 --- /dev/null +++ b/internal/workstream/pagedoc/frommarkdown.go @@ -0,0 +1,285 @@ +package pagedoc + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" +) + +var workstreamMarkdownHeading = regexp.MustCompile(`^(#+) +(.*)$`) + +var workstreamMarkdownList = regexp.MustCompile(`^( *)([-*+] |([0-9]+)[.)] )(.*)$`) + +var workstreamMarkdownLive = regexp.MustCompile(`^\[live: (jobs|waves)( milestone=([^\s\]]+))?\]$`) + +var workstreamMarkdownJob = regexp.MustCompile(`^\[live: job ([^\s#]*)#([0-9]+)\]$`) + +var workstreamMarkdownDecision = regexp.MustCompile(`^\[live: decision ([^\s\]]+)\]$`) + +// FromMarkdown accepts prose without requiring the agent to construct editor JSON. +func FromMarkdown(md string) json.RawMessage { + md = strings.ToValidUTF8(md, "�") + md = strings.ReplaceAll(strings.ReplaceAll(md, "\r\n", "\n"), "\r", "\n") + doc := map[string]any{"type": "doc", "content": workstreamMarkdownBlocks(strings.Split(md, "\n"), 0)} + out, _ := json.Marshal(doc) + return out +} + +func workstreamMarkdownNode(kind string, content []map[string]any) map[string]any { + return map[string]any{"type": kind, "content": content} +} + +func workstreamMarkdownBlocks(lines []string, depth int) []map[string]any { + out := make([]map[string]any, 0) + for i := 0; i < len(lines); { + line := strings.TrimSpace(lines[i]) + if line == "" || line == "---" { + i++ + continue + } + if fence, language := workstreamMarkdownFence(line); fence != "" { + node, next := workstreamMarkdownCode(lines, i, fence, language) + out = append(out, node) + i = next + continue + } + if match := workstreamMarkdownHeading.FindStringSubmatch(line); match != nil { + node := workstreamMarkdownNode("heading", workstreamMarkdownInline(match[2])) + node["attrs"] = map[string]any{"level": min(3, len(match[1]))} + out = append(out, node) + i++ + continue + } + if node := workstreamMarkdownLiveNode(line); node != nil { + out = append(out, node) + i++ + continue + } + if depth < 16 && workstreamMarkdownList.MatchString(lines[i]) { + node, next := workstreamMarkdownListNode(lines, i, depth) + out = append(out, node) + i = next + continue + } + parts := []string{workstreamMarkdownProse(line)} + i++ + for i < len(lines) && !workstreamMarkdownBoundary(lines[i], depth) { + parts = append(parts, workstreamMarkdownProse(lines[i])) + i++ + } + text := strings.Join(parts, " ") + out = append(out, workstreamMarkdownNode("paragraph", workstreamMarkdownInline(text))) + } + return out +} + +func workstreamMarkdownProse(line string) string { + return strings.TrimPrefix(strings.TrimSpace(line), "> ") +} + +func workstreamMarkdownBoundary(line string, depth int) bool { + line = strings.TrimSpace(line) + fence, _ := workstreamMarkdownFence(line) + return line == "" || line == "---" || fence != "" || workstreamMarkdownHeading.MatchString(line) || workstreamMarkdownLiveNode(line) != nil || depth < 16 && workstreamMarkdownList.MatchString(line) +} + +func workstreamMarkdownFence(line string) (string, string) { + if !strings.HasPrefix(line, "```") && !strings.HasPrefix(line, "~~~") { + return "", "" + } + i := 0 + for i < len(line) && line[i] == line[0] { + i++ + } + return line[:i], strings.TrimSpace(line[i:]) +} + +func workstreamMarkdownCode(lines []string, at int, fence, language string) (map[string]any, int) { + i := at + 1 + for i < len(lines) { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, fence) && strings.Trim(line, string(fence[0])) == "" { + break + } + i++ + } + text := strings.Join(lines[at+1:i], "\n") + node := workstreamMarkdownNode("codeBlock", []map[string]any{{"type": "text", "text": text}}) + var lang any + if language != "" { + lang = language + } + node["attrs"] = map[string]any{"language": lang} + return node, min(i+1, len(lines)) +} + +func workstreamMarkdownListNode(lines []string, at, depth int) (map[string]any, int) { + first := workstreamMarkdownList.FindStringSubmatch(lines[at]) + indent, ordered := len(first[1]), first[3] != "" + items := make([]map[string]any, 0) + i := at + for i < len(lines) { + match := workstreamMarkdownList.FindStringSubmatch(lines[i]) + if match == nil || len(match[1]) != indent || (match[3] != "") != ordered { + break + } + width := len(match[1]) + len(match[2]) + body := []string{match[4]} + i++ + for i < len(lines) { + line := lines[i] + if strings.TrimSpace(line) == "" { + if i+1 >= len(lines) || len(lines[i+1])-len(strings.TrimLeft(lines[i+1], " ")) <= indent { + break + } + } else if len(line)-len(strings.TrimLeft(line, " ")) <= indent { + break + } + cut := min(width, len(line)-len(strings.TrimLeft(line, " "))) + body = append(body, line[cut:]) + i++ + } + items = append(items, workstreamMarkdownNode("listItem", workstreamMarkdownBlocks(body, depth+1))) + } + kind := "bulletList" + if ordered { + kind = "orderedList" + } + node := workstreamMarkdownNode(kind, items) + if ordered { + start, err := strconv.Atoi(first[3]) + if err != nil { + start = 1 + } + node["attrs"] = map[string]any{"start": start} + } + return node, i +} + +func workstreamMarkdownLiveNode(line string) map[string]any { + attrs := map[string]any{"kind": nil, "milestone_id": nil, "factory": nil, "job": nil, "record_id": nil} + switch { + case workstreamMarkdownLive.MatchString(line): + match := workstreamMarkdownLive.FindStringSubmatch(line) + attrs["kind"] = match[1] + if match[3] != "" { + attrs["milestone_id"] = match[3] + } + case workstreamMarkdownJob.MatchString(line): + match := workstreamMarkdownJob.FindStringSubmatch(line) + n, err := strconv.Atoi(match[2]) + if err != nil { + return nil + } + attrs["kind"], attrs["factory"], attrs["job"] = "job", match[1], n + case workstreamMarkdownDecision.MatchString(line): + match := workstreamMarkdownDecision.FindStringSubmatch(line) + attrs["kind"], attrs["record_id"] = "decision", match[1] + default: + return nil + } + return map[string]any{"type": "liveBlock", "attrs": attrs} +} + +func workstreamMarkdownInline(text string) []map[string]any { + out := make([]map[string]any, 0) + var plain strings.Builder + flush := func() { + if plain.Len() > 0 { + out = append(out, map[string]any{"type": "text", "text": plain.String()}) + plain.Reset() + } + } + for i := 0; i < len(text); { + if text[i] == '\\' && i+1 < len(text) { + plain.WriteByte(text[i+1]) + i += 2 + continue + } + if strings.HasPrefix(text[i:], "**") || strings.HasPrefix(text[i:], "__") { + i += 2 + continue + } + nodes, used := workstreamMarkdownInlineSpecial(text[i:]) + if used > 0 { + flush() + out = append(out, nodes...) + i += used + continue + } + plain.WriteByte(text[i]) + i++ + } + flush() + return out +} + +func workstreamMarkdownInlineSpecial(text string) ([]map[string]any, int) { + if text[0] == '`' { + n := 1 + for n < len(text) && text[n] == '`' { + n++ + } + if end := strings.Index(text[n:], text[:n]); end >= 0 { + body := text[n : n+end] + if strings.HasPrefix(body, " ") && strings.HasSuffix(body, " ") && strings.TrimSpace(body) != "" { + body = body[1 : len(body)-1] + } + return []map[string]any{{"type": "text", "text": body, "marks": []map[string]any{{"type": "code"}}}}, n + end + n + } + } + if text[0] == '[' { + return workstreamMarkdownLink(text) + } + return nil, 0 +} + +func workstreamMarkdownLink(text string) ([]map[string]any, int) { + close := strings.Index(text, "](") + if close < 0 { + return nil, 0 + } + end := workstreamMarkdownLinkEnd(text, close+2) + if end < 0 { + return nil, 0 + } + href := workstreamMarkdownUnescape(text[close+2 : end]) + nodes := workstreamMarkdownInline(text[1:close]) + for _, node := range nodes { + marks, _ := node["marks"].([]map[string]any) + node["marks"] = append(marks, map[string]any{"type": "link", "attrs": map[string]any{"href": href}}) + } + return nodes, end + 1 +} + +func workstreamMarkdownLinkEnd(text string, start int) int { + depth := 0 + for i := start; i < len(text); i++ { + if text[i] == '\\' && i+1 < len(text) { + i++ + continue + } + switch text[i] { + case '(': + depth++ + case ')': + if depth == 0 { + return i + } + depth-- + } + } + return -1 +} + +func workstreamMarkdownUnescape(text string) string { + var out strings.Builder + for i := 0; i < len(text); i++ { + if text[i] == '\\' && i+1 < len(text) { + i++ + } + out.WriteByte(text[i]) + } + return out.String() +} diff --git a/internal/workstream/pagedoc/frommarkdown_test.go b/internal/workstream/pagedoc/frommarkdown_test.go new file mode 100644 index 0000000000..7f20fad0b7 --- /dev/null +++ b/internal/workstream/pagedoc/frommarkdown_test.go @@ -0,0 +1,56 @@ +package pagedoc + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +func TestWorkstreamFromMarkdownContractRoundTrip(t *testing.T) { + md, err := os.ReadFile("testdata/example.md") + if err != nil { + t.Fatal(err) + } + workstreamMarkdownRoundTrip(t, string(md), string(md)) + for _, md := range []string{ + "# One\n\n## Two\n\n### Three\n", + "- Parent\n - Child\n- Next\n", + "3. Parent\n 4. Child\n4. Next\n", + "Keep \\*literal\\* and `a_b` and [a `code`](https://example.com).\n", + "````go\na := \"```\"\n````\n", + "[live: jobs]\n\n[live: waves milestone=m2]\n\n[live: job api#0]\n\n[live: decision r2]\n", + } { + workstreamMarkdownRoundTrip(t, md, md) + } +} + +func TestWorkstreamFromMarkdownDegradesUnsupportedMarkup(t *testing.T) { + workstreamMarkdownRoundTrip(t, "See [guide](https://example.com/a(b)) and [more](https://example.com/a\\(b\\)).\n", "See [guide](https://example.com/a%28b%29) and [more](https://example.com/a%28b%29).\n") + workstreamMarkdownRoundTrip(t, "#### Deep\n\n> **Plain** __words__\ncontinued\n\n---\n\n+ First\n* Second\n\n7) Ordered\n\n~~~js\ncall()\n~~~\n", "### Deep\n\nPlain words continued\n\n- First\n- Second\n\n7. Ordered\n\n```js\ncall()\n```\n") + for _, md := range []string{"", "\n", "```\n", "[unfinished", "`unfinished", "text\\", string([]byte{255})} { + if !json.Valid(FromMarkdown(md)) { + t.Fatalf("invalid doc for %q", md) + } + } +} + +func TestWorkstreamFromMarkdownLiveAttributes(t *testing.T) { + var doc map[string]any + if err := json.Unmarshal(FromMarkdown("[live: jobs milestone=m1]"), &doc); err != nil { + t.Fatal(err) + } + node := doc["content"].([]any)[0].(map[string]any) + want := map[string]any{"kind": "jobs", "milestone_id": "m1", "factory": nil, "job": nil, "record_id": nil} + if node["type"] != "liveBlock" || !reflect.DeepEqual(node["attrs"], want) { + t.Fatalf("live block: %+v", node) + } +} + +func workstreamMarkdownRoundTrip(t *testing.T, input, want string) { + t.Helper() + got, err := Markdown(FromMarkdown(input)) + if err != nil || got != want { + t.Fatalf("round trip %q:\ngot %q\nwant %q\nerror %v", input, got, want, err) + } +} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go index c856f975ef..c0d1e9a5b0 100644 --- a/internal/workstream/pages.go +++ b/internal/workstream/pages.go @@ -240,6 +240,9 @@ func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawM } return ErrNotFound } + if by == "agent" && p.Owner != "agent" { + return ErrNotAgentPage + } if p.Version != base { return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} } @@ -377,6 +380,9 @@ func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, err if err != nil { return Page{}, Change{}, err } + if by == "agent" && owner != "agent" { + return Page{}, Change{}, invalid("the agent creates only its own pages") + } source, err := sourceJSON(p.From) if err != nil { return Page{}, Change{}, err diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go index 09eb222dc6..4b9b00e51d 100644 --- a/internal/workstream/pages_test.go +++ b/internal/workstream/pages_test.go @@ -28,6 +28,10 @@ func TestPartAPageSaveHistoryAndRestore(t *testing.T) { if err != nil { t.Fatal(err) } + owner := "agent" + if _, _, err := s.UpdatePage(ctx, first.ID, PagePatch{Owner: &owner}); err != nil { + t.Fatal(err) + } doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Saved"}]}]}`) *now = now.Add(time.Hour) saved, _, err := s.SavePage(ctx, first.ID, first.Version, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}) diff --git a/internal/workstream/workstream_agent.go b/internal/workstream/workstream_agent.go new file mode 100644 index 0000000000..fd2e09a148 --- /dev/null +++ b/internal/workstream/workstream_agent.go @@ -0,0 +1,30 @@ +package workstream + +import ( + "context" + "database/sql" + "errors" +) + +var ErrNotAgentPage = errors.New("the agent writes only its own pages") + +// ByAgent resolves the owner at call time because its conversation opens first. +func (s *Store) ByAgent(ctx context.Context, agentID string) (Workstream, error) { + var out Workstream + if agentID == "" { + return out, ErrNotFound + } + err := s.workspace.Read(ctx, func(tx *sql.Tx, version int) error { + if version < 3 { + return ErrNotFound + } + var id string + if err := tx.QueryRowContext(ctx, "SELECT id FROM workstreams WHERE agent_id=?", agentID).Scan(&id); err != nil { + return found(err) + } + var err error + out, err = getIn(ctx, tx, id) + return err + }) + return out, err +} diff --git a/internal/workstream/workstream_agent_test.go b/internal/workstream/workstream_agent_test.go new file mode 100644 index 0000000000..a657498f83 --- /dev/null +++ b/internal/workstream/workstream_agent_test.go @@ -0,0 +1,106 @@ +package workstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" +) + +// Contract (a) resolves a conversation to its workstream without creating one. +func TestWorkstreamByAgentResolvesOnlyItsOwner(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + for _, id := range []string{"", "missing"} { + if _, err := s.ByAgent(ctx, id); !errors.Is(err, ErrNotFound) { + t.Fatalf("unheld agent %q: %v", id, err) + } + } + w := createStream(t, s, "Agent") + got, err := s.ByAgent(ctx, w.AgentID) + if err != nil || !reflect.DeepEqual(got, w) { + t.Fatalf("owner: %+v %v", got, err) + } +} + +// Contract (d) includes ownership changing after the version was read. +func TestWorkstreamAgentCannotSaveThePersonsPage(t *testing.T) { + s, _, _ := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Ownership") + for _, race := range []bool{false, true} { + owner := "you" + if race { + owner = "agent" + } + p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes", Owner: owner}) + if err != nil { + t.Fatal(err) + } + base := p.Version + if race { + you := "you" + if _, _, err := s.UpdatePage(ctx, p.ID, PagePatch{Owner: &you}); err != nil { + t.Fatal(err) + } + } + before, _ := s.Page(ctx, p.ID, false) + history, _ := s.PageHistory(ctx, p.ID, 50) + stream, _ := s.Get(ctx, w.ID) + _, change, err := s.SavePage(ctx, p.ID, base, json.RawMessage(`{"type":"doc","content":[]}`), Attribution{By: "agent"}) + if !errors.Is(err, ErrNotAgentPage) || !reflect.DeepEqual(change, Change{}) { + t.Fatalf("race=%v: %+v %v", race, change, err) + } + after, _ := s.Page(ctx, p.ID, false) + afterHistory, _ := s.PageHistory(ctx, p.ID, 50) + afterStream, _ := s.Get(ctx, w.ID) + if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) || !reflect.DeepEqual(stream, afterStream) { + t.Fatalf("race=%v changed a refused page", race) + } + } + _, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Wrong owner", Owner: "you", Attribution: Attribution{By: "agent"}}) + if !errors.Is(err, ErrInvalid) || c.ID != "" { + t.Fatalf("agent creation with person owner: %+v %v", c, err) + } +} + +// Contract (e) keeps the same source and clock in the page and its history. +func TestWorkstreamAgentPagesCarryAttributionAndTime(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Attribution") + source := &Source{Kind: "chat", ID: w.AgentID, Label: "Plan discussion"} + who := Attribution{By: "agent", From: source} + p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Agent notes", Owner: "agent", Attribution: who}) + if err != nil { + t.Fatal(err) + } + workstreamCheckAgentPage(t, p, *now, source) + *now = now.Add(time.Minute) + p, _, err = s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), who) + if err != nil { + t.Fatal(err) + } + workstreamCheckAgentPage(t, p, *now, source) + history, err := s.PageHistory(ctx, p.ID, 50) + if err != nil || len(history) != 2 { + t.Fatalf("history: %+v %v", history, err) + } + for _, v := range history { + if v.SavedBy != "agent" || !reflect.DeepEqual(v.From, source) { + t.Fatalf("history attribution: %+v", v) + } + } + if !history[0].SavedAt.Equal(*now) { + t.Fatalf("history clock: %+v", history[0]) + } +} + +func workstreamCheckAgentPage(t *testing.T, p Page, now time.Time, source *Source) { + t.Helper() + if p.Owner != "agent" || p.ReadByJobs || p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(now) || !reflect.DeepEqual(p.UpdatedFrom, source) { + t.Fatalf("agent mark: %+v", p) + } +} From 1df620315022f8e0162c58f8b3a7f298449e1c88 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:36:27 -0500 Subject: [PATCH 02/16] session: a workstream agent's prompt section and six tools behind a door A conversation handed a WorkstreamDoor carries workstream_read, propose_decision, propose_question, propose_jobs, propose_goahead and write_agent_page, and a short prompt section naming its workstream and the rules: it proposes and the person keeps, it writes only its own pages, it never invents numbers and it starts nothing. Arguments outside each schema are refused before the door is touched, propose_jobs only appends drafts and retries a version conflict once, and a go-ahead proposal is a proposed decision that grants nothing. Without a door the belt and the page are unchanged, and task nodes, errands and clarifications never carry it. Co-Authored-By: Claude Opus 5.5 --- internal/session/actioncategory.go | 5 +- internal/session/inherit.go | 2 +- internal/session/manual_test.go | 18 + internal/session/prompt.go | 4 + internal/session/questionconversation.go | 3 +- internal/session/session.go | 3 + internal/session/tools.go | 1 + internal/session/workstream_contract.go | 86 +++ internal/session/workstream_factory_test.go | 108 ++++ internal/session/workstream_limits.go | 15 + internal/session/workstream_schema.go | 118 +++++ internal/session/workstream_tools.go | 491 ++++++++++++++++++ internal/session/workstream_tools_test.go | 352 +++++++++++++ .../session/workstream_validation_test.go | 37 ++ 14 files changed, 1239 insertions(+), 4 deletions(-) create mode 100644 internal/session/workstream_contract.go create mode 100644 internal/session/workstream_factory_test.go create mode 100644 internal/session/workstream_limits.go create mode 100644 internal/session/workstream_schema.go create mode 100644 internal/session/workstream_tools.go create mode 100644 internal/session/workstream_tools_test.go create mode 100644 internal/session/workstream_validation_test.go diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index d7a56971a3..525d82fa57 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -224,7 +224,7 @@ func ActionCategoryForTool(tool string) ActionCategory { // A manager looking at its team: the states, and a member's page. "team_status", "team_read", // The foreman reading the factory floor (a mark is a proposal, not a change). - "factory_floor", "factory_status": + "factory_floor", "factory_status", "workstream_read": return ActionRead // Changing something that exists. @@ -234,7 +234,8 @@ func ActionCategoryForTool(tool string) ActionCategory { // Writing content or generating an artifact. case "write", "generate_image", "generate_music", "generate_video", - "remember", "propose_subharness", "factory_add", "factory_recipe", "factory_item", "build_harness", "calendar_create": + "remember", "propose_subharness", "factory_add", "factory_recipe", "factory_item", "build_harness", "calendar_create", + "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page": return ActionCreate // Executing and waiting on what it prints. diff --git a/internal/session/inherit.go b/internal/session/inherit.go index 3113ed686e..b4fa6f16ca 100644 --- a/internal/session/inherit.go +++ b/internal/session/inherit.go @@ -386,7 +386,7 @@ func adoptSeed(child *Agent, quick *quickTaskSpec) { // prompt over the top of it an hour later — the same mark a Config that // carries a System gets at construction (agent.go), said here because this // page arrives after the agent was built. - child.system = promotedSystem(quick.system) + child.system = promotedSystem(workstreamWithoutPage(quick.system)) child.systemOwn = false messages[0] = textMessage("system", child.system) child.messages = messages diff --git a/internal/session/manual_test.go b/internal/session/manual_test.go index 933e98ff90..0c886e956f 100644 --- a/internal/session/manual_test.go +++ b/internal/session/manual_test.go @@ -132,6 +132,24 @@ func TestTheManualMentionsTheDesignThreadsOwnTool(t *testing.T) { } } +// Contract (h) checks the offered belt, including its six conditional tools. +func TestTheManualMentionsTheWorkstreamAgentsTools(t *testing.T) { + agent := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}}} + agent.tools = agent.belt() + names := map[string]bool{} + for _, tool := range agent.offeredTools() { + if !manual.Chat().Mentions(tool.Name) { + t.Errorf("the chat manual does not mention %s", tool.Name) + } + names[tool.Name] = true + } + for _, name := range []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} { + if !names[name] { + t.Errorf("the workstream agent does not offer %s", name) + } + } +} + // The manual tool answers out of the CHAT's pages and must never reach the // resident's. Both corpora ship in this binary, and a chat that answered from // the wrong one would describe a product the person is not using — fluently, diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 582229d166..3b5904a726 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -399,6 +399,10 @@ func renderSystemAt(config Config, now time.Time) string { out.WriteString(catalog) } + if config.mayWorkstream() { + out.WriteString("\n\n") + out.WriteString(workstreamPage(config)) + } out.WriteString(workerFooter(config, now)) return out.String() } diff --git a/internal/session/questionconversation.go b/internal/session/questionconversation.go index 613d5336df..a5aa7305d6 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -96,6 +96,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { } a.mu.Lock() cfg := a.config + cfg.Workstream = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} cfg.rootSession = a.id @@ -107,7 +108,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { if cfg.ApprovalPolicy == nil { cfg.ApprovalPolicy = a.config.ApprovalPolicy } - cfg.System = a.system + cfg.System = workstreamWithoutPage(a.system) if a.guardianOverride != nil { cfg.Guardian = *a.guardianOverride } diff --git a/internal/session/session.go b/internal/session/session.go index 196a892bab..d986e424f8 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2523,6 +2523,9 @@ type Config struct { // foremanFloorDoor), on a launch that has a Factory door. Floor FloorDoor + // Workstream is set by the app only on a workstream's own agent. Nil is no door. + Workstream WorkstreamDoor + // factoryWindow overrides how long one factory card holds its tool call // open, for tests that watch the window end. Zero is [factoryCardWindow]. factoryWindow time.Duration diff --git a/internal/session/tools.go b/internal/session/tools.go index dd5d1177b6..e6820913ab 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -273,6 +273,7 @@ func (a *Agent) belt() []bare.Tool { // factory_floor (tools_floor.go) is the foreman's read of the whole floor // and its marks, absent on the same law: no floor behind it, no verb. tools = append(tools, a.floorTools()...) + tools = append(tools, a.workstreamTools()...) tools = append(tools, a.harnessTools()...) // The saved PROGRAMS, and the list that says which ones there are // (tools_subharness.go). They are conditional on the same terms the three diff --git a/internal/session/workstream_contract.go b/internal/session/workstream_contract.go new file mode 100644 index 0000000000..77708345ea --- /dev/null +++ b/internal/session/workstream_contract.go @@ -0,0 +1,86 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/workstream" +) + +// WorkstreamDoor is the workstream agent's one way into its own workstream. +type WorkstreamDoor interface { + Brief(context.Context) (WorkstreamBrief, error) + Read(context.Context, WorkstreamReadAsk) (json.RawMessage, error) + Propose(context.Context, WorkstreamProposal) (WorkstreamProposed, error) + Plan(context.Context) (WorkstreamPlanState, error) + SetPlan(context.Context, int, WorkstreamPlanState) (WorkstreamPlanState, error) + WritePage(context.Context, WorkstreamPageWrite) (WorkstreamPageWritten, error) +} + +var ErrWorkstreamMissing = errors.New("No workstream holds this conversation.") + +var ErrWorkstreamDone = errors.New("This workstream is done; nothing was added or written.") + +var ErrWorkstreamVersion = errors.New("the workstream version changed") + +var ErrWorkstreamNotAgentPage = errors.New("That page is the person's. You write only your own pages; suggesting edits on theirs is not available yet.") + +var ErrWorkstreamTooLarge = errors.New("That page is too large; the encoded document must be at most 1 MiB.") + +const WorkstreamGoAheadProposalPrefix = "Proposed go-ahead (not granted): " + +type WorkstreamBrief struct { + Name string + Why string +} + +type WorkstreamReadAsk struct { + What string `json:"what"` + PageID string `json:"page_id,omitempty"` + Kind string `json:"kind,omitempty"` + State string `json:"state,omitempty"` +} + +type WorkstreamProposal struct { + Kind string + Text string + Source *workstream.Source + GoAhead *WorkstreamGoAheadAsk +} + +type WorkstreamProposed struct { + ID string +} + +type WorkstreamPlanState struct { + Version int + Milestones []workstream.Milestone + Waves []workstream.Wave +} + +type WorkstreamGoAheadAsk struct { + Scope string `json:"scope"` + Factory string `json:"factory,omitempty"` + Job int `json:"job,omitempty"` + Wave int `json:"wave,omitempty"` + MilestoneID string `json:"milestone_id,omitempty"` + CapUSD float64 `json:"cap_usd"` + EndsOn string `json:"ends_on"` + Why string `json:"why"` +} + +type WorkstreamPageWrite struct { + PageID string `json:"page_id,omitempty"` + Title string `json:"title,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Markdown string `json:"markdown"` + UpdatedFrom string `json:"updated_from"` + Source *workstream.Source `json:"source,omitempty"` +} + +type WorkstreamPageWritten struct { + ID string + Title string + Version int +} diff --git a/internal/session/workstream_factory_test.go b/internal/session/workstream_factory_test.go new file mode 100644 index 0000000000..4af91f6511 --- /dev/null +++ b/internal/session/workstream_factory_test.go @@ -0,0 +1,108 @@ +package session + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +type workstreamForbiddenFactory struct { + t *testing.T +} + +func (d workstreamForbiddenFactory) Add(context.Context, factory.Item) (int, error) { + d.t.Fatal("propose_jobs called Factory.Add") + return 0, nil +} + +func (d workstreamForbiddenFactory) Preview(context.Context, int, ItemChange) (factory.Item, factory.Item, error) { + d.t.Fatal("propose_jobs called FactoryItem.Preview") + return factory.Item{}, factory.Item{}, nil +} + +func (d workstreamForbiddenFactory) Apply(context.Context, int, ItemChange) (factory.Item, error) { + d.t.Fatal("propose_jobs called FactoryItem.Apply") + return factory.Item{}, nil +} + +func (d workstreamForbiddenFactory) EditRun(context.Context, string, factory.RunEdit) (factory.Item, []string, error) { + d.t.Fatal("propose_jobs called FactoryRun.EditRun") + return factory.Item{}, nil, nil +} + +func (d workstreamForbiddenFactory) StartRun(context.Context, string) (string, error) { + d.t.Fatal("propose_jobs called FactoryRun.StartRun") + return "", nil +} + +func (d workstreamForbiddenFactory) BankStage(context.Context, string, factory.Kind, string) error { + d.t.Fatal("propose_jobs called Recipe.BankStage") + return nil +} + +func (d workstreamForbiddenFactory) BankPolicy(context.Context, string, string) error { + d.t.Fatal("propose_jobs called Recipe.BankPolicy") + return nil +} + +func (d workstreamForbiddenFactory) BankHabit(context.Context, string, string) error { + d.t.Fatal("propose_jobs called Recipe.BankHabit") + return nil +} + +func (d workstreamForbiddenFactory) Snapshot(context.Context) (factory.Snapshot, error) { + d.t.Fatal("propose_jobs called Floor.Snapshot") + return factory.Snapshot{}, nil +} + +func (d workstreamForbiddenFactory) Mark(context.Context, []int, bool) error { + d.t.Fatal("propose_jobs called Floor.Mark") + return nil +} + +func (d workstreamForbiddenFactory) Chat(string) JobChat { + d.t.Fatal("propose_jobs called JobChat.Chat") + return nil +} + +func (d workstreamForbiddenFactory) Spend(float64) { + d.t.Fatal("propose_jobs called JobBudget.Spend") +} + +func (d workstreamForbiddenFactory) Refusal() string { + d.t.Fatal("propose_jobs called JobBudget.Refusal") + return "" +} + +func (d workstreamForbiddenFactory) Report(context.Context, factory.StageResult) error { + d.t.Fatal("propose_jobs called Stage.Report") + return nil +} + +func (d workstreamForbiddenFactory) Edit(context.Context, factory.PlanEdit) error { + d.t.Fatal("propose_jobs called Stage.Edit") + return nil +} + +// Contract (c) puts failing doors behind every Factory capability during the call. +func TestWorkstreamJobsDoNotCallAnyFactoryDoor(t *testing.T) { + door := &workstreamFakeDoor{} + forbidden := workstreamForbiddenFactory{t: t} + a := &Agent{config: Config{Workstream: door, Factory: forbidden, FactoryItem: forbidden, FactoryRun: forbidden, Recipe: forbidden, Floor: forbidden, JobChat: forbidden, JobBudget: forbidden, Stage: forbidden}} + for _, tool := range a.workstreamTools() { + if tool.Name != "propose_jobs" { + continue + } + got, failed, err := tool.Execute(context.Background(), json.RawMessage(`{"jobs":[{"title":"Draft"}]}`)) + if err != nil || failed || got != "Added 1 draft jobs to wave 1 of the plan. They are drafts: nothing runs until the person launches them." { + t.Fatalf("draft proposal: %q %v %v", got, failed, err) + } + if len(door.saves) != 1 || door.saves[0].Waves[0].Items[0].Kind != "draft" { + t.Fatalf("not a draft: %+v", door.saves) + } + return + } + t.Fatal("missing propose_jobs") +} diff --git a/internal/session/workstream_limits.go b/internal/session/workstream_limits.go new file mode 100644 index 0000000000..c6d4564438 --- /dev/null +++ b/internal/session/workstream_limits.go @@ -0,0 +1,15 @@ +package session + +const workstreamLineMax = 200 + +const workstreamRecordMax = 2000 + +const workstreamDraftTextMax = 4000 + +const workstreamJobsMax = 20 + +const workstreamGoAheadCapMax = 10000 + +const workstreamGoAheadDaysMax = 90 + +const workstreamGoAheadWhyMax = 1000 diff --git a/internal/session/workstream_schema.go b/internal/session/workstream_schema.go new file mode 100644 index 0000000000..0b98d1a43a --- /dev/null +++ b/internal/session/workstream_schema.go @@ -0,0 +1,118 @@ +package session + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +var workstreamSourceSchema = fmt.Sprintf(`{"type":"object","properties":{"kind":{"type":"string","enum":["factory_job","page","url"]},"id":{"type":"string"},"factory":{"type":"string"},"label":{"type":"string","maxLength":%d}},"required":["kind","id"],"additionalProperties":false}`, workstreamLineMax) + +const workstreamReadSchema = `{"type":"object","properties":{"what":{"type":"string","enum":["workstream","pages","page","records","plan","goaheads"]},"page_id":{"type":"string"},"kind":{"type":"string","enum":["decision","question"]},"state":{"type":"string","enum":["proposed","kept","dropped","superseded"]}},"required":["what"],"additionalProperties":false}` + +var workstreamRecordSchema = fmt.Sprintf(`{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":%d},"source":%s},"required":["text"],"additionalProperties":false}`, workstreamRecordMax, workstreamSourceSchema) + +var workstreamJobsSchema = fmt.Sprintf(`{"type":"object","properties":{"jobs":{"type":"array","minItems":1,"maxItems":%d,"items":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":%d},"repo":{"type":"string","maxLength":%d},"text":{"type":"string","maxLength":%d}},"required":["title"],"additionalProperties":false}},"wave":{"type":"integer"},"wave_title":{"type":"string","maxLength":%d},"milestone_id":{"type":"string"}},"required":["jobs"],"additionalProperties":false}`, workstreamJobsMax, workstreamLineMax, workstreamLineMax, workstreamDraftTextMax, workstreamLineMax) + +var workstreamGoAheadSchema = fmt.Sprintf(`{"type":"object","properties":{"scope":{"type":"string","enum":["job","wave","milestone"]},"factory":{"type":"string"},"job":{"type":"integer"},"wave":{"type":"integer"},"milestone_id":{"type":"string"},"cap_usd":{"type":"number","exclusiveMinimum":0,"maximum":%d},"ends_on":{"type":"string"},"why":{"type":"string","minLength":1,"maxLength":%d}},"required":["scope","cap_usd","ends_on","why"],"additionalProperties":false}`, workstreamGoAheadCapMax, workstreamGoAheadWhyMax) + +var workstreamPageSchema = fmt.Sprintf(`{"type":"object","properties":{"page_id":{"type":"string"},"title":{"type":"string","minLength":1,"maxLength":%d},"parent_id":{"type":"string"},"markdown":{"type":"string"},"updated_from":{"type":"string","minLength":1,"maxLength":%d},"source":%s},"required":["markdown","updated_from"],"additionalProperties":false}`, workstreamLineMax, workstreamLineMax, workstreamSourceSchema) + +type workstreamArgumentSchema struct { + Type string `json:"type"` + Properties map[string]workstreamArgumentSchema `json:"properties"` + Required []string `json:"required"` + Enum []string `json:"enum"` + Items *workstreamArgumentSchema `json:"items"` +} + +func workstreamDecode(args json.RawMessage, schema workstreamArgumentSchema, target any) error { + if err := workstreamCheckArguments(args, schema, "arguments"); err != nil { + return err + } + return decodeToolArguments(args, target) +} + +func workstreamCheckArguments(args json.RawMessage, schema workstreamArgumentSchema, path string) error { + if strings.TrimSpace(string(args)) == "null" { + return fmt.Errorf("%s must not be null", path) + } + switch schema.Type { + case "object": + return workstreamCheckObject(args, schema, path) + case "integer", "number": + return workstreamCheckNumber(args, schema.Type, path) + case "array": + var items []json.RawMessage + if err := json.Unmarshal(args, &items); err != nil { + return fmt.Errorf("%s must be an array", path) + } + for i, item := range items { + if err := workstreamCheckArguments(item, *schema.Items, fmt.Sprintf("%s[%d]", path, i)); err != nil { + return err + } + } + case "string": + var value string + if err := json.Unmarshal(args, &value); err != nil { + return fmt.Errorf("%s must be a string", path) + } + if len(schema.Enum) > 0 && !workstreamHasWord(schema.Enum, value) { + return fmt.Errorf("%s must be one of %s", path, strings.Join(schema.Enum, ", ")) + } + } + return nil +} + +func workstreamCheckNumber(args json.RawMessage, kind, path string) error { + if kind == "integer" { + var value int + if err := json.Unmarshal(args, &value); err != nil { + return fmt.Errorf("%s must be an integer", path) + } + return nil + } + var value float64 + if err := json.Unmarshal(args, &value); err != nil { + return fmt.Errorf("%s must be a number", path) + } + return nil +} + +func workstreamCheckObject(args json.RawMessage, schema workstreamArgumentSchema, path string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(args, &fields); err != nil || fields == nil { + return fmt.Errorf("%s must be a JSON object", path) + } + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, ok := schema.Properties[name]; !ok { + return fmt.Errorf("Unknown field %q is not allowed.", name) + } + } + for _, name := range names { + if err := workstreamCheckArguments(fields[name], schema.Properties[name], name); err != nil { + return err + } + } + for _, name := range schema.Required { + if _, ok := fields[name]; !ok { + return fmt.Errorf("%s is required", name) + } + } + return nil +} + +func workstreamHasWord(words []string, word string) bool { + for _, candidate := range words { + if candidate == word { + return true + } + } + return false +} diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go new file mode 100644 index 0000000000..6d7f367def --- /dev/null +++ b/internal/session/workstream_tools.go @@ -0,0 +1,491 @@ +package session + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +const workstreamReadMax = 60000 + +const workstreamPlanChanged = "The plan changed while you were proposing; nothing was added. Read the plan and propose again." + +const workstreamPromptRules = "\n- You propose; the person keeps. Decisions, questions, jobs and go-aheads you raise with propose_decision, propose_question, propose_jobs and propose_goahead are proposals until the person keeps them.\n" + + "- You write only your own pages, with write_agent_page. The person's pages are theirs; you do not change them.\n" + + "- You never invent numbers. Counts, costs, states and dates come from workstream_read; on a page, use a live block such as [live: jobs] instead of writing a number.\n" + + "- You start nothing. Jobs start only when the person launches them or inside a go-ahead they granted." + +func (c Config) mayWorkstream() bool { + return c.Workstream != nil && !c.readingTask() && !c.InTask && !c.Errand +} + +func workstreamPage(config Config) string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + brief, err := config.Workstream.Brief(ctx) + intro := "You are the agent of a workstream. Read it with workstream_read before you answer." + if err == nil && strings.TrimSpace(brief.Name) != "" { + intro = fmt.Sprintf("You are the agent of the workstream %q.", workstreamCut(strings.Join(strings.Fields(brief.Name), " "), 48)) + if why := strings.Join(strings.Fields(brief.Why), " "); why != "" { + intro += " Why it exists: " + workstreamCut(why, 64) + } + } + return "WORKSTREAM\n" + intro + workstreamPromptRules +} + +func workstreamWithoutPage(page string) string { + start := strings.Index(page, "\n\nWORKSTREAM\n") + if start < 0 { + return page + } + end := strings.Index(page[start:], workstreamPromptRules) + if end < 0 { + return page + } + return page[:start] + page[start+end+len(workstreamPromptRules):] +} + +func (a *Agent) workstreamTools() []bare.Tool { + return a.workstreamToolsAt(time.Now) +} + +func (a *Agent) workstreamToolsAt(now func() time.Time) []bare.Tool { + if !a.config.mayWorkstream() { + return nil + } + door := a.config.Workstream + return []bare.Tool{ + workstreamTool("workstream_read", "Read this workstream's recorded facts. Absent numbers are unknown; never estimate them.", workstreamReadSchema, func(ctx context.Context, ask WorkstreamReadAsk) (string, error) { + return workstreamRead(ctx, door, ask) + }), + workstreamRecordTool(door, "decision"), + workstreamRecordTool(door, "question"), + workstreamTool("propose_jobs", "Append draft jobs to the plan. Nothing is created in the Factory or started; the person launches drafts.", workstreamJobsSchema, func(ctx context.Context, ask workstreamJobsAsk) (string, error) { + return workstreamProposeJobs(ctx, door, ask) + }), + workstreamTool("propose_goahead", "Propose a go-ahead as a decision. The cap and end date are proposals; this grants nothing.", workstreamGoAheadSchema, func(ctx context.Context, ask WorkstreamGoAheadAsk) (string, error) { + return workstreamProposeGoAhead(ctx, door, ask, now()) + }), + workstreamTool("write_agent_page", "Write only your own pages, marked as agent-written with their source and time. The person's pages are refused.", workstreamPageSchema, func(ctx context.Context, ask WorkstreamPageWrite) (string, error) { + return workstreamWritePage(ctx, door, ask) + }), + } +} + +func workstreamTool[T any](name, description, schemaJSON string, run func(context.Context, T) (string, error)) bare.Tool { + var schema workstreamArgumentSchema + _ = json.Unmarshal([]byte(schemaJSON), &schema) + return bare.Tool{ + Name: name, + Description: description, + Schema: json.RawMessage(schemaJSON), + Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { + var ask T + if err := workstreamDecode(args, schema, &ask); err != nil { + return "Invalid arguments: " + err.Error(), true, nil + } + if err := workstreamFieldRules(name, args); err != nil { + return "Invalid arguments: " + err.Error(), true, nil + } + text, err := run(ctx, ask) + if err != nil { + return err.Error(), true, nil + } + return text, false, nil + }, + } +} + +func workstreamFieldRules(name string, args json.RawMessage) error { + var fields map[string]json.RawMessage + _ = json.Unmarshal(args, &fields) + if err := workstreamReferenceFields(fields); err != nil { + return err + } + _, page := fields["page_id"] + switch name { + case "workstream_read": + var what string + _ = json.Unmarshal(fields["what"], &what) + if page != (what == "page") { + return errors.New("page_id is required for and only for page") + } + if what != "records" { + return workstreamRefuseFields(fields, "kind", "state") + } + case "propose_jobs": + if _, wave := fields["wave"]; wave { + return workstreamRefuseFields(fields, "wave_title", "milestone_id") + } + case "propose_goahead": + return workstreamGoAheadFields(fields) + case "write_agent_page": + if page { + return workstreamRefuseFields(fields, "title", "parent_id") + } + } + return nil +} + +func workstreamReferenceFields(fields map[string]json.RawMessage) error { + for _, name := range []string{"page_id", "parent_id", "milestone_id"} { + if raw, exists := fields[name]; exists { + var id string + _ = json.Unmarshal(raw, &id) + if strings.TrimSpace(id) == "" { + return fmt.Errorf("%s must name an existing record", name) + } + } + } + if raw, exists := fields["source"]; exists { + var source map[string]json.RawMessage + _ = json.Unmarshal(raw, &source) + var kind string + _ = json.Unmarshal(source["kind"], &kind) + if kind != "factory_job" { + return workstreamRefuseFields(source, "factory") + } + } + return nil +} + +func workstreamRefuseFields(fields map[string]json.RawMessage, names ...string) error { + for _, name := range names { + if _, exists := fields[name]; exists { + return fmt.Errorf("%s is not allowed for this request", name) + } + } + return nil +} + +func workstreamGoAheadFields(fields map[string]json.RawMessage) error { + var scope string + _ = json.Unmarshal(fields["scope"], &scope) + switch scope { + case "job": + return workstreamRefuseFields(fields, "wave", "milestone_id") + case "wave": + return workstreamRefuseFields(fields, "factory", "job", "milestone_id") + case "milestone": + return workstreamRefuseFields(fields, "factory", "job", "wave") + } + return nil +} + +func workstreamRead(ctx context.Context, door WorkstreamDoor, ask WorkstreamReadAsk) (string, error) { + if ask.What == "page" && strings.TrimSpace(ask.PageID) == "" { + return "", errors.New("page_id is required for page") + } + raw, err := door.Read(ctx, ask) + if err != nil { + return "", err + } + text := string(raw) + if ask.What == "workstream" { + text = "Numbers absent below are unknown; say so and never estimate them.\n" + text + } + if len(text) > workstreamReadMax { + const tail = "\nThe workstream read was cut." + text = workstreamCut(text, workstreamReadMax-len(tail)) + tail + } + return text, nil +} + +func workstreamCut(text string, limit int) string { + if len(text) <= limit { + return text + } + for limit > 0 && !utf8.RuneStart(text[limit]) { + limit-- + } + return text[:limit] +} + +type workstreamRecordAsk struct { + Text string `json:"text"` + Source *workstream.Source `json:"source,omitempty"` +} + +func workstreamRecordTool(door WorkstreamDoor, kind string) bare.Tool { + return workstreamTool("propose_"+kind, "Propose a "+kind+". Only the person can keep it; it is always proposed by the agent.", workstreamRecordSchema, func(ctx context.Context, ask workstreamRecordAsk) (string, error) { + if err := workstreamText(ask.Text, "text", workstreamRecordMax, true, false); err != nil { + return "", err + } + if err := workstreamSourceText(ask.Source); err != nil { + return "", err + } + p, err := door.Propose(ctx, WorkstreamProposal{Kind: kind, Text: ask.Text, Source: ask.Source}) + if err != nil { + return "", err + } + return "Proposed " + kind + " " + p.ID + ". Nothing is kept until the person keeps it.", nil + }) +} + +func workstreamText(text, field string, limit int, required, line bool) error { + if !utf8.ValidString(text) || len(text) > limit || required && strings.TrimSpace(text) == "" { + return fmt.Errorf("%s must be %smost %d bytes", field, workstreamTextBound(required), limit) + } + for _, r := range text { + if unicode.IsControl(r) && (line || r != '\n' && r != '\t') { + return fmt.Errorf("%s has a control character or more than one line", field) + } + } + return nil +} + +func workstreamTextBound(required bool) string { + if required { + return "non-empty and at " + } + return "at " +} + +func workstreamSourceText(source *workstream.Source) error { + if source == nil { + return nil + } + if err := workstreamText(source.Label, "source.label", workstreamLineMax, false, true); err != nil { + return err + } + if source.Factory != "" && source.Kind != "factory_job" { + return errors.New("source.factory is allowed only for factory_job") + } + return workstreamText(source.ID, "source.id", 4096, true, false) +} + +type workstreamDraftAsk struct { + Title string `json:"title"` + Repo string `json:"repo,omitempty"` + Text string `json:"text,omitempty"` +} + +type workstreamJobsAsk struct { + Jobs []workstreamDraftAsk `json:"jobs"` + Wave *int `json:"wave,omitempty"` + WaveTitle string `json:"wave_title,omitempty"` + MilestoneID string `json:"milestone_id,omitempty"` +} + +func workstreamValidateJobs(ask workstreamJobsAsk) error { + if len(ask.Jobs) < 1 || len(ask.Jobs) > workstreamJobsMax { + return fmt.Errorf("jobs must contain 1–%d drafts", workstreamJobsMax) + } + if ask.Wave != nil && *ask.Wave <= 0 { + return errors.New("wave must be an existing positive wave number") + } + if err := workstreamText(ask.WaveTitle, "wave_title", workstreamLineMax, false, true); err != nil { + return err + } + for _, job := range ask.Jobs { + if err := workstreamValidateDraft(job); err != nil { + return err + } + } + return nil +} + +func workstreamValidateDraft(job workstreamDraftAsk) error { + if err := workstreamText(job.Title, "title", workstreamLineMax, true, true); err != nil { + return err + } + if err := workstreamText(job.Repo, "repo", workstreamLineMax, false, true); err != nil { + return err + } + return workstreamText(job.Text, "text", workstreamDraftTextMax, false, false) +} + +func workstreamProposeJobs(ctx context.Context, door WorkstreamDoor, ask workstreamJobsAsk) (string, error) { + if err := workstreamValidateJobs(ask); err != nil { + return "", err + } + drafts, err := workstreamDrafts(ask.Jobs) + if err != nil { + return "", err + } + for attempt := 0; attempt < 2; attempt++ { + plan, err := door.Plan(ctx) + if err != nil { + return "", err + } + next, wave, err := workstreamAppendDrafts(plan, ask, drafts) + if err != nil { + return "", err + } + _, err = door.SetPlan(ctx, plan.Version, next) + if errors.Is(err, ErrWorkstreamVersion) { + continue + } + if err != nil { + return "", err + } + return fmt.Sprintf("Added %d draft jobs to wave %d of the plan. They are drafts: nothing runs until the person launches them.", len(drafts), wave), nil + } + return "", errors.New(workstreamPlanChanged) +} + +func workstreamDrafts(jobs []workstreamDraftAsk) ([]workstream.PlanItem, error) { + out := make([]workstream.PlanItem, 0, len(jobs)) + for _, job := range jobs { + var id [8]byte + if _, err := rand.Read(id[:]); err != nil { + return nil, err + } + out = append(out, workstream.PlanItem{Kind: "draft", DraftID: "draft_" + hex.EncodeToString(id[:]), Title: job.Title, Repo: job.Repo, Text: job.Text}) + } + return out, nil +} + +func workstreamAppendDrafts(plan WorkstreamPlanState, ask workstreamJobsAsk, drafts []workstream.PlanItem) (WorkstreamPlanState, int, error) { + plan.Waves = append([]workstream.Wave(nil), plan.Waves...) + if ask.Wave != nil { + for i, wave := range plan.Waves { + if wave.N == *ask.Wave { + plan.Waves[i].Items = append(append([]workstream.PlanItem(nil), wave.Items...), drafts...) + return plan, wave.N, nil + } + } + return plan, 0, errors.New("wave must name an existing plan wave") + } + if ask.MilestoneID != "" && !workstreamHasMilestone(plan, ask.MilestoneID) { + return plan, 0, errors.New("milestone_id must name an existing plan milestone") + } + n := 1 + if len(plan.Waves) > 0 { + n = plan.Waves[len(plan.Waves)-1].N + 1 + } + plan.Waves = append(plan.Waves, workstream.Wave{N: n, Title: ask.WaveTitle, MilestoneID: ask.MilestoneID, AutoStart: false, Items: append([]workstream.PlanItem(nil), drafts...)}) + return plan, n, nil +} + +func workstreamHasMilestone(plan WorkstreamPlanState, id string) bool { + for _, milestone := range plan.Milestones { + if milestone.ID == id { + return true + } + } + return false +} + +func workstreamProposeGoAhead(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk, now time.Time) (string, error) { + if err := WorkstreamValidateGoAhead(ask, now); err != nil { + return "", err + } + scope, err := workstreamGoAheadScope(ctx, door, ask) + if err != nil { + return "", err + } + text := fmt.Sprintf("%s%s, up to $%.2f, until %s. %s", WorkstreamGoAheadProposalPrefix, scope, ask.CapUSD, ask.EndsOn, ask.Why) + p, err := door.Propose(ctx, WorkstreamProposal{Kind: "decision", Text: text, GoAhead: &ask}) + if err != nil { + return "", err + } + return "Proposed a go-ahead as decision " + p.ID + ". It grants nothing; the person grants it if they agree.", nil +} + +// WorkstreamValidateGoAhead is shared with the app so its clock validates proposals too. +func WorkstreamValidateGoAhead(ask WorkstreamGoAheadAsk, now time.Time) error { + if !(ask.CapUSD > 0 && ask.CapUSD <= workstreamGoAheadCapMax) { + return fmt.Errorf("cap_usd must be greater than 0 and at most %d", workstreamGoAheadCapMax) + } + end, err := time.Parse("2006-01-02", ask.EndsOn) + today := now.UTC().Truncate(24 * time.Hour) + if err != nil || !end.After(today) || end.After(today.AddDate(0, 0, workstreamGoAheadDaysMax)) { + return fmt.Errorf("ends_on must be YYYY-MM-DD, after today in UTC and at most %d days ahead", workstreamGoAheadDaysMax) + } + if err := workstreamText(ask.Why, "why", workstreamGoAheadWhyMax, true, false); err != nil { + return err + } + return workstreamValidateGoAheadTarget(ask) +} + +func workstreamValidateGoAheadTarget(ask WorkstreamGoAheadAsk) error { + if workstreamGoAheadOtherFields(ask) { + return errors.New("The go-ahead has fields for another scope.") + } + switch ask.Scope { + case "job": + if ask.Factory == "" || ask.Job <= 0 { + return errors.New("job scope requires only factory and a positive job") + } + case "wave": + if ask.Wave <= 0 { + return errors.New("wave scope requires only a positive wave") + } + case "milestone": + if ask.MilestoneID == "" { + return errors.New("milestone scope requires only milestone_id") + } + default: + return errors.New("scope must be job, wave or milestone") + } + return nil +} + +func workstreamGoAheadOtherFields(ask WorkstreamGoAheadAsk) bool { + switch ask.Scope { + case "job": + return ask.Wave != 0 || ask.MilestoneID != "" + case "wave": + return ask.Factory != "" || ask.Job != 0 || ask.MilestoneID != "" + case "milestone": + return ask.Factory != "" || ask.Job != 0 || ask.Wave != 0 + } + return false +} + +func workstreamGoAheadScope(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk) (string, error) { + if ask.Scope == "job" { + return fmt.Sprintf("job %s#%d", ask.Factory, ask.Job), nil + } + plan, err := door.Plan(ctx) + if err != nil { + return "", err + } + if ask.Scope == "wave" { + for _, wave := range plan.Waves { + if wave.N == ask.Wave { + return fmt.Sprintf("wave %d", wave.N), nil + } + } + return "", errors.New("wave must name an existing plan wave") + } + for _, milestone := range plan.Milestones { + if milestone.ID == ask.MilestoneID { + return fmt.Sprintf("milestone \"%s\" (%s)", milestone.Title, milestone.ID), nil + } + } + return "", errors.New("milestone_id must name an existing plan milestone") +} + +func workstreamWritePage(ctx context.Context, door WorkstreamDoor, ask WorkstreamPageWrite) (string, error) { + if ask.PageID == "" { + if err := workstreamText(ask.Title, "title", workstreamLineMax, true, true); err != nil { + return "", err + } + } + if err := workstreamText(ask.UpdatedFrom, "updated_from", workstreamLineMax, true, true); err != nil { + return "", err + } + if err := workstreamSourceText(ask.Source); err != nil { + return "", err + } + p, err := door.WritePage(ctx, ask) + if errors.Is(err, ErrWorkstreamVersion) { + return "", errors.New("The page changed while you were writing. Read it again before writing.") + } + if err != nil { + return "", err + } + return fmt.Sprintf("Wrote %s (%s, version %d), marked as written by the agent from %q.", p.Title, p.ID, p.Version, ask.UpdatedFrom), nil +} diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go new file mode 100644 index 0000000000..a072f9f9e4 --- /dev/null +++ b/internal/session/workstream_tools_test.go @@ -0,0 +1,352 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +type workstreamFakeDoor struct { + brief WorkstreamBrief + briefError error + read json.RawMessage + readAsk WorkstreamReadAsk + proposals []WorkstreamProposal + plan WorkstreamPlanState + reads int + saves []WorkstreamPlanState + bases []int + conflicts int + pageWrites []WorkstreamPageWrite + pageError error + otherCalls int + briefBound bool + briefExpiry time.Duration +} + +func (d *workstreamFakeDoor) Brief(ctx context.Context) (WorkstreamBrief, error) { + if deadline, ok := ctx.Deadline(); ok { + d.briefBound = true + d.briefExpiry = time.Until(deadline) + } + return d.brief, d.briefError +} + +func (d *workstreamFakeDoor) Read(_ context.Context, ask WorkstreamReadAsk) (json.RawMessage, error) { + d.otherCalls++ + d.readAsk = ask + return d.read, nil +} + +func (d *workstreamFakeDoor) Propose(_ context.Context, p WorkstreamProposal) (WorkstreamProposed, error) { + d.otherCalls++ + d.proposals = append(d.proposals, p) + return WorkstreamProposed{ID: "record-1"}, nil +} + +func (d *workstreamFakeDoor) Plan(context.Context) (WorkstreamPlanState, error) { + d.reads++ + return d.plan, nil +} + +func (d *workstreamFakeDoor) SetPlan(_ context.Context, base int, p WorkstreamPlanState) (WorkstreamPlanState, error) { + d.saves = append(d.saves, p) + d.bases = append(d.bases, base) + if len(d.saves) <= d.conflicts { + d.plan.Version++ + d.plan.Waves = append(d.plan.Waves, workstream.Wave{N: 9, Title: "Person's wave", AutoStart: true, Items: []workstream.PlanItem{}}) + return WorkstreamPlanState{}, ErrWorkstreamVersion + } + d.plan = p + d.plan.Version++ + return d.plan, nil +} + +func (d *workstreamFakeDoor) WritePage(_ context.Context, p WorkstreamPageWrite) (WorkstreamPageWritten, error) { + d.otherCalls++ + d.pageWrites = append(d.pageWrites, p) + return WorkstreamPageWritten{ID: "page-1", Title: "Notes", Version: 2}, d.pageError +} + +func workstreamToolForTest(t *testing.T, d *workstreamFakeDoor, name string) bare.Tool { + t.Helper() + a := &Agent{config: Config{Workstream: d}} + now := func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) } + for _, tool := range a.workstreamToolsAt(now) { + if tool.Name == name { + return tool + } + } + t.Fatalf("missing %s", name) + return bare.Tool{} +} + +func workstreamCallForTest(t *testing.T, d *workstreamFakeDoor, name, args string, refused bool) string { + t.Helper() + got, failed, err := workstreamToolForTest(t, d, name).Execute(context.Background(), json.RawMessage(args)) + if err != nil || failed != refused { + t.Fatalf("%s: %q failed=%v err=%v", name, got, failed, err) + } + return got +} + +// Contract (a) preserves the ordinary page and belt byte for byte. +func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { + cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()} + now := time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC) + plain := &Agent{config: cfg} + belt := plain.belt() + page := renderSystemAt(cfg, now) + d := &workstreamFakeDoor{brief: WorkstreamBrief{Name: "Usage", Why: "Explain recorded usage"}} + cfg.Workstream = d + with := &Agent{config: cfg} + if len(with.belt()) != len(belt)+6 || len(with.workstreamTools()) != 6 { + t.Fatal("the workstream must add exactly six tools") + } + if !reflect.DeepEqual(workstreamOrdinaryBelt(belt), workstreamOrdinaryBelt(with.belt())) { + t.Fatal("the workstream changed the ordinary belt's bytes") + } + section := "\n\n" + workstreamPage(cfg) + rendered := renderSystemAt(cfg, now) + if !strings.Contains(rendered, `workstream "Usage"`) || !strings.Contains(rendered, "Explain recorded usage") || strings.Replace(rendered, section, "", 1) != page { + t.Fatal("the gated section changed the ordinary page") + } + if !d.briefBound || d.briefExpiry > 2*time.Second { + t.Fatalf("brief timeout: %v", d.briefExpiry) + } + for _, mutate := range []func(*Config){ + func(c *Config) { c.InTask = true }, + func(c *Config) { c.Errand = true }, + func(c *Config) { c.taskControl = &TaskNode{spec: taskSpec{quick: &quickTaskSpec{reading: true}}} }, + func(c *Config) { c.Workstream = nil }, + } { + child := cfg + mutate(&child) + a := &Agent{config: child} + if len(a.workstreamTools()) != 0 || strings.Contains(renderSystemAt(child, now), "WORKSTREAM\n") { + t.Fatal("a child or plain agent inherited the workstream") + } + } + if workstreamWithoutPage(rendered) != page { + t.Fatal("a child prompt retained the workstream section") + } + quick := &quickTaskSpec{system: rendered} + quick.seed = append(quick.seed, textMessage("system", rendered)) + child := &Agent{} + adoptSeed(child, quick) + if child.system != promotedSystem(page) || messageContentText(quick.seed[0]) != rendered { + t.Fatal("a promoted worker retained the workstream section or changed its parent's seed") + } + d.briefError = errors.New("not linked yet") + if !strings.Contains(workstreamPage(cfg), "Read it with workstream_read before you answer.") { + t.Fatal("the early prompt must remain usable") + } + d.briefError = nil + d.brief = WorkstreamBrief{Name: strings.Repeat(`"`, 256), Why: strings.Repeat("why ", 1000)} + if len(workstreamPage(cfg)) > 900 { + t.Fatal("the workstream prompt section exceeded its budget") + } +} + +func workstreamOrdinaryBelt(tools []bare.Tool) []string { + names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} + var out []string + for _, tool := range tools { + if !workstreamHasWord(names, tool.Name) { + out = append(out, tool.Name+"\n"+tool.Description+"\n"+string(tool.Schema)) + } + } + return out +} + +// Contract (b) appends only drafts and preserves an existing wave's settings. +func TestWorkstreamJobsAppendToExistingWave(t *testing.T) { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Version: 3, Waves: []workstream.Wave{{N: 2, AutoStart: true, Items: []workstream.PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}}} + got := workstreamCallForTest(t, d, "propose_jobs", `{"wave":2,"jobs":[{"title":"One"},{"title":"Two"}]}`, false) + wave := d.saves[0].Waves[0] + if got != "Added 2 draft jobs to wave 2 of the plan. They are drafts: nothing runs until the person launches them." || !wave.AutoStart || len(wave.Items) != 3 || wave.Items[0].Job != 7 || wave.Items[1].DraftID == wave.Items[2].DraftID { + t.Fatalf("appended wave: %q %+v", got, wave) + } +} + +// Contract (g) labels unknown readings and caps text without splitting UTF-8. +func TestWorkstreamReadCapsTextAndNamesUnknownNumbers(t *testing.T) { + d := &workstreamFakeDoor{read: json.RawMessage(`{"name":"Usage"}`)} + got := workstreamCallForTest(t, d, "workstream_read", `{"what":"workstream"}`, false) + if got != "Numbers absent below are unknown; say so and never estimate them.\n"+string(d.read) { + t.Fatalf("unknown numbers: %q", got) + } + d.read = json.RawMessage(`{"markdown":"` + strings.Repeat("é", 40000) + `"}`) + got = workstreamCallForTest(t, d, "workstream_read", `{"what":"page","page_id":"p1"}`, false) + if len(got) > 60000 || !strings.HasSuffix(got, "\nThe workstream read was cut.") || d.readAsk.PageID != "p1" { + t.Fatalf("capped read: %d %+v", len(got), d.readAsk) + } +} + +// Contract (e) returns attribution; a page version conflict is refused without retry. +func TestWorkstreamPageWritesAndConflictsHaveExactAnswers(t *testing.T) { + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, "write_agent_page", `{"title":"Notes","markdown":"Words","updated_from":"Discussion"}`, false) + if got != `Wrote Notes (page-1, version 2), marked as written by the agent from "Discussion".` || len(d.pageWrites) != 1 || d.pageWrites[0].Source != nil { + t.Fatalf("write receipt: %q %+v", got, d.pageWrites) + } + d.pageError = ErrWorkstreamVersion + got = workstreamCallForTest(t, d, "write_agent_page", `{"page_id":"p1","markdown":"Words","updated_from":"Discussion"}`, true) + if got != "The page changed while you were writing. Read it again before writing." || len(d.pageWrites) != 2 { + t.Fatalf("conflict receipt: %q %+v", got, d.pageWrites) + } +} + +// Contract (g) also applies to nested objects and fields for another operation. +func TestWorkstreamArgumentsRefuseNestedFactsAndScopeMixing(t *testing.T) { + cases := []struct { + name string + args string + key string + }{ + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":true}}`, "kept"}, + {"propose_jobs", `{"jobs":[{"title":"Draft","job":7}]}`, "job"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":1,"wave_title":"New"}`, "wave_title"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":1,"milestone_id":"m1"}`, "milestone_id"}, + {"write_agent_page", `{"page_id":"p1","title":"Rename","markdown":"Words","updated_from":"Discussion"}`, "title"}, + {"write_agent_page", `{"page_id":"p1","parent_id":"p2","markdown":"Words","updated_from":"Discussion"}`, "parent_id"}, + {"propose_goahead", `{"scope":"wave","wave":1,"job":0,"cap_usd":1,"ends_on":"2026-11-04","why":"Words"}`, "job"}, + {"workstream_read", `{"what":"pages","page_id":"p1"}`, "page_id"}, + {"workstream_read", `{"what":"plan","state":"kept"}`, "state"}, + {"workstream_read", `{"what":"plan","kind":"decision"}`, "kind"}, + } + for _, tc := range cases { + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, tc.name, tc.args, true) + if !strings.Contains(got, tc.key) || d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("%s: %q %+v", tc.name, got, d) + } + } +} + +// Contracts (b) and (g) bound proposed permission by the UTC day and dollar cap. +func TestWorkstreamGoAheadDatesAndCapsAreOnlyProposals(t *testing.T) { + now := time.Date(2026, 11, 3, 23, 0, 0, 0, time.FixedZone("west", -5*60*60)) + base := WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 10000, EndsOn: "2026-11-05", Why: "Review"} + if err := WorkstreamValidateGoAhead(base, now); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*WorkstreamGoAheadAsk){ + func(a *WorkstreamGoAheadAsk) { a.CapUSD = 0 }, + func(a *WorkstreamGoAheadAsk) { a.CapUSD = 10000.01 }, + func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-04" }, + func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2027-02-03" }, + func(a *WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-05T00:00:00Z" }, + func(a *WorkstreamGoAheadAsk) { a.Why = "" }, + } { + ask := base + mutate(&ask) + if err := WorkstreamValidateGoAhead(ask, now); err == nil { + t.Fatalf("accepted %+v", ask) + } + } +} + +// Contract (b) exposes no way to keep a record or grant a go-ahead. +func TestWorkstreamProposalsAreOnlyWords(t *testing.T) { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} + for _, kind := range []string{"decision", "question"} { + got := workstreamCallForTest(t, d, "propose_"+kind, `{"text":"Keep this?"}`, false) + want := "Proposed " + kind + " record-1. Nothing is kept until the person keeps it." + if got != want || d.proposals[len(d.proposals)-1].Kind != kind || d.proposals[len(d.proposals)-1].Source != nil { + t.Fatalf("proposal: %q %+v", got, d.proposals) + } + } + got := workstreamCallForTest(t, d, "propose_goahead", `{"scope":"wave","wave":2,"cap_usd":12.5,"ends_on":"2026-11-04","why":"Review the result"}`, false) + p := d.proposals[len(d.proposals)-1] + if got != "Proposed a go-ahead as decision record-1. It grants nothing; the person grants it if they agree." || p.Kind != "decision" || p.Text != "Proposed go-ahead (not granted): wave 2, up to $12.50, until 2026-11-04. Review the result" { + t.Fatalf("go-ahead: %q %+v", got, p) + } +} + +// Contract (f) rereads and reapplies once, preserving every existing plan field. +func TestWorkstreamJobsRetryOnlyOneVersionConflict(t *testing.T) { + for _, conflicts := range []int{0, 1, 2} { + d := &workstreamFakeDoor{conflicts: conflicts, plan: WorkstreamPlanState{Version: 4, Milestones: []workstream.Milestone{{ID: "m1", Title: "Ship"}}, Waves: []workstream.Wave{{N: 3, Title: "Existing", MilestoneID: "m1", AutoStart: true, Items: []workstream.PlanItem{{Kind: "job", Factory: "f", Job: 7}}}}}} + before := d.plan + got := workstreamCallForTest(t, d, "propose_jobs", `{"jobs":[{"title":"Draft","repo":"api","text":"Read first"}]}`, conflicts == 2) + wantCalls := min(conflicts+1, 2) + if len(d.saves) != wantCalls || d.reads != wantCalls || d.otherCalls != 0 { + t.Fatalf("conflicts=%d calls=%+v", conflicts, d) + } + if !reflect.DeepEqual(d.saves[0].Waves[0], before.Waves[0]) || !reflect.DeepEqual(d.saves[0].Milestones, before.Milestones) { + t.Fatal("existing plan changed") + } + last := d.saves[len(d.saves)-1] + wave := last.Waves[len(last.Waves)-1] + if wave.AutoStart || len(wave.Items) != 1 || wave.Items[0].Kind != "draft" || !strings.HasPrefix(wave.Items[0].DraftID, "draft_") { + t.Fatalf("not drafts: %+v", wave) + } + if conflicts == 2 && got != "The plan changed while you were proposing; nothing was added. Read the plan and propose again." { + t.Fatalf("second conflict: %q", got) + } + if conflicts == 1 && (wave.N != 10 || d.bases[1] != 5 || last.Waves[1].Title != "Person's wave") { + t.Fatalf("did not reapply to the new plan: %+v", last) + } + } +} + +// Contract (d) surfaces an ownership refusal without pretending to write. +func TestWorkstreamPageOwnershipRefusal(t *testing.T) { + d := &workstreamFakeDoor{pageError: ErrWorkstreamNotAgentPage} + got := workstreamCallForTest(t, d, "write_agent_page", `{"page_id":"page-1","markdown":"Words","updated_from":"Discussion"}`, true) + if got != "That page is the person's. You write only your own pages; suggesting edits on theirs is not available yet." { + t.Fatalf("refusal: %q", got) + } +} + +// Contract (g) refuses unknown fields before touching any door. +func TestWorkstreamToolsRefuseInventedFacts(t *testing.T) { + names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} + fields := []string{"kept", "state", "cost_usd", "spend_usd", "count", "merged", "budget_usd", "auto_start", "version"} + for _, name := range names { + for _, field := range fields { + if name == "workstream_read" && field == "state" { + continue + } + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, name, `{"`+field+`":true}`, true) + if !strings.Contains(got, field) || d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("%s accepted %s: %q", name, field, got) + } + } + var schema map[string]any + if err := json.Unmarshal(workstreamToolForTest(t, &workstreamFakeDoor{}, name).Schema, &schema); err != nil || schema["additionalProperties"] != false { + t.Fatalf("%s schema: %+v %v", name, schema, err) + } + properties := schema["properties"].(map[string]any) + for _, field := range fields { + if name == "workstream_read" && field == "state" { + continue + } + if _, exists := properties[field]; exists { + t.Fatalf("%s schema accepts invented %s", name, field) + } + } + } +} + +// Contract (h) assigns an action family to every workstream tool. +func TestWorkstreamToolActionCategories(t *testing.T) { + for _, tool := range (&Agent{config: Config{Workstream: &workstreamFakeDoor{}}}).workstreamTools() { + want := ActionCreate + if tool.Name == "workstream_read" { + want = ActionRead + } + if got := ActionCategoryForTool(tool.Name); got != want { + t.Errorf("%s: %s, want %s", tool.Name, got, want) + } + } +} diff --git a/internal/session/workstream_validation_test.go b/internal/session/workstream_validation_test.go new file mode 100644 index 0000000000..31af7edeac --- /dev/null +++ b/internal/session/workstream_validation_test.go @@ -0,0 +1,37 @@ +package session + +import ( + "strings" + "testing" +) + +// Contract (g) validates byte bounds and JSON shapes before any read or write. +func TestWorkstreamToolBoundsRefuseBeforeTheDoor(t *testing.T) { + cases := []struct { + name string + args string + }{ + {"propose_decision", `{"text":""}`}, + {"propose_decision", `{"text":"` + strings.Repeat("é", 1001) + `"}`}, + {"propose_question", `{"text":"Words","source":{"kind":"url","id":"https://example.com","label":"` + strings.Repeat("x", 201) + `"}}`}, + {"propose_jobs", `{"jobs":[]}`}, + {"propose_jobs", `{"jobs":[{"title":"` + strings.Repeat("x", 201) + `"}]}`}, + {"propose_jobs", `{"jobs":[{"title":"Draft","repo":"two\nlines"}]}`}, + {"propose_jobs", `{"jobs":[{"title":"Draft","text":"` + strings.Repeat("x", 4001) + `"}]}`}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":"1"}`}, + {"propose_goahead", `{"scope":"job","factory":"f","job":7,"cap_usd":"1","ends_on":"2026-11-04","why":"Words"}`}, + {"write_agent_page", `{"markdown":"Words","updated_from":"Discussion"}`}, + {"write_agent_page", `{"title":"Notes","markdown":"Words","updated_from":"two\nlines"}`}, + {"workstream_read", `[]`}, + {"workstream_read", `null`}, + {"workstream_read", `{"what":null}`}, + {"workstream_read", `{"what":"page"}`}, + } + for _, tc := range cases { + d := &workstreamFakeDoor{} + workstreamCallForTest(t, d, tc.name, tc.args, true) + if d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("invalid %s reached the door: %+v", tc.name, d) + } + } +} From 01f0e35683b954da705f6956dcb4e000fce7569a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:36:27 -0500 Subject: [PATCH 03/16] app: give a workstream's agent its door, and re-read spend after its turns The app sets the workstream door on the conversation whose created-by is workstream and on no other; the door finds its workstream at each call, since the agent is opened a moment before the workstream row exists. Its writes are attributed to the agent and send workstream.changed, and a workstream agent's settled turn sends one too so its spend is read again. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 6 +- cmd/codeaf/appworkstreams.go | 6 +- cmd/codeaf/appworkstreams_agent.go | 335 ++++++++++++++++++++++++ cmd/codeaf/appworkstreams_agent_test.go | 328 +++++++++++++++++++++++ cmd/codeaf/engine.go | 1 + 5 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 cmd/codeaf/appworkstreams_agent.go create mode 100644 cmd/codeaf/appworkstreams_agent_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index d3ad7b5bad..718e985d7b 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -201,6 +201,7 @@ func runApp(args []string) error { // ONE SUPERVISOR FOR EVERY CONVERSATION THE APP BUILDS (appservices.go). services := openAppServices() appServiceDoor.Store(services) + appWorkstreamAgents.Store(backend) backend.attachServices(services) guard.Go("app/crews-start", func() { backend.crews() }) // THE APP IS AN OPEN WINDOW for as long as it runs, so the clock that runs @@ -2981,7 +2982,10 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if held { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - b.spendMoved(func(spend appwire.Spend) { b.moneyAfter(a, live, spend.TodayUSD) }) + b.spendMoved(func(spend appwire.Spend) { + b.moneyAfter(a, live, spend.TodayUSD) + b.workstreamAgentTurnSettled(id) + }) } // ── entry ids ─────────────────────────────────────────────────────────────── diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 4be96ab2d6..75b1fb9d66 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -98,6 +98,8 @@ func appWorkstreamError(err error) error { return appwire.Errorf(appwire.CodeCycle, "%s", err) case errors.Is(err, workstream.ErrTooLarge): return appwire.Errorf(appwire.CodeTooLarge, "%s", err) + case errors.Is(err, workstream.ErrNotAgentPage): + return appwire.Errorf(appwire.CodeBadParams, "%s", err) } return appCollectionError(err) } @@ -206,8 +208,8 @@ func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.Works // workstreamAgentAsk is the single agent-creation hook for workstreams. Empty // text is a bare open in prepareCreate and cannot start a model turn. func workstreamAgentAsk(p appwire.WorkstreamCreateParams) appwire.CreateAsk { - // WP-10 attaches the workstream system prompt and tools here. - return appwire.CreateAsk{Model: workstreamValue(p.Model), CreatedBy: "workstream"} + // Engine assembly attaches the prompt and tools through appWorkstreamAgentDoor. + return appwire.CreateAsk{Model: workstreamValue(p.Model), CreatedBy: workstreamCreatedBy} } // workstreamPreflight checks store-owned refusals without writing or migrating. diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go new file mode 100644 index 0000000000..4f03ad78af --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent.go @@ -0,0 +1,335 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + "sync/atomic" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" + "github.com/Agent-Field/codeaf/internal/workstream/pagedoc" +) + +const workstreamCreatedBy = "workstream" + +var appWorkstreamAgents atomic.Pointer[appBackend] + +type workstreamAgentDoor struct { + backend *appBackend + agentID string +} + +var _ session.WorkstreamDoor = (*workstreamAgentDoor)(nil) + +func appWorkstreamAgentDoor(sessionFile string) session.WorkstreamDoor { + if b := appWorkstreamAgents.Load(); b != nil { + return b.workstreamAgentDoor(sessionFile) + } + return nil +} + +func (b *appBackend) workstreamAgentDoor(sessionFile string) session.WorkstreamDoor { + id := appAgentID(sessionFile) + if b.createdBy(id) != workstreamCreatedBy { + return nil + } + return &workstreamAgentDoor{backend: b, agentID: id} +} + +func (d *workstreamAgentDoor) workstreamOwner(ctx context.Context, writing bool) (*workstream.Store, workstream.Workstream, error) { + s, err := d.backend.workstreamStore(false) + if err != nil { + return nil, workstream.Workstream{}, err + } + if s == nil { + return nil, workstream.Workstream{}, session.ErrWorkstreamMissing + } + w, err := s.ByAgent(ctx, d.agentID) + if errors.Is(err, workstream.ErrNotFound) { + err = session.ErrWorkstreamMissing + } + if err == nil && writing && !w.DoneAt.IsZero() { + err = session.ErrWorkstreamDone + } + return s, w, err +} + +func (d *workstreamAgentDoor) Brief(ctx context.Context) (session.WorkstreamBrief, error) { + _, w, err := d.workstreamOwner(ctx, false) + return session.WorkstreamBrief{Name: w.Name, Why: w.Why}, err +} + +func (d *workstreamAgentDoor) Read(ctx context.Context, ask session.WorkstreamReadAsk) (json.RawMessage, error) { + s, w, err := d.workstreamOwner(ctx, false) + if err != nil { + return nil, err + } + switch ask.What { + case "workstream": + out, err := d.backend.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + if err != nil { + return nil, err + } + return json.Marshal(out.Workstream) + case "pages": + return workstreamAgentReadPages(ctx, s, w.ID) + case "page": + p, err := workstreamAgentPage(ctx, s, w.ID, ask.PageID, true) + if err != nil { + return nil, err + } + return json.Marshal(struct { + appwire.WorkstreamPageNode + Markdown *string `json:"markdown"` + }{appWorkstreamPageNode(p.PageNode), p.Markdown}) + case "records": + return workstreamAgentReadRecords(ctx, s, w.ID, ask) + case "plan": + p, err := s.Plan(ctx, w.ID) + if err != nil { + return nil, err + } + return json.Marshal(appwire.WorkstreamPlanGetResult{Plan: appWorkstreamPlan(p)}) + case "goaheads": + return workstreamAgentReadGoAheads(ctx, s, w.ID) + } + return nil, errors.New("what must name a workstream part") +} + +func workstreamAgentReadPages(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { + pages, err := s.Pages(ctx, id) + if err != nil { + return nil, err + } + out := appwire.WorkstreamPageListResult{Pages: []appwire.WorkstreamPageNode{}} + for _, p := range pages { + out.Pages = append(out.Pages, appWorkstreamPageNode(p)) + } + return json.Marshal(out) +} + +func workstreamAgentReadRecords(ctx context.Context, s *workstream.Store, id string, ask session.WorkstreamReadAsk) (json.RawMessage, error) { + records, err := s.Records(ctx, id, ask.Kind, ask.State) + if err != nil { + return nil, err + } + out := appwire.WorkstreamRecordListResult{Records: []appwire.WorkstreamRecord{}} + for _, r := range records { + out.Records = append(out.Records, appWorkstreamRecord(r)) + } + return json.Marshal(out) +} + +func workstreamAgentReadGoAheads(ctx context.Context, s *workstream.Store, id string) (json.RawMessage, error) { + grants, err := s.GoAheads(ctx, id, true) + if err != nil { + return nil, err + } + out := appwire.WorkstreamGoAheadListResult{GoAheads: []appwire.WorkstreamGoAhead{}} + for _, g := range grants { + out.GoAheads = append(out.GoAheads, appWorkstreamGoAhead(g)) + } + return json.Marshal(out) +} + +func workstreamAgentPage(ctx context.Context, s *workstream.Store, id, pageID string, markdown bool) (workstream.Page, error) { + p, err := s.Page(ctx, pageID, markdown) + if err == nil && p.WorkstreamID != id { + err = workstream.ErrNotFound + } + return p, err +} + +func (d *workstreamAgentDoor) Propose(ctx context.Context, p session.WorkstreamProposal) (session.WorkstreamProposed, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamProposed{}, err + } + if p.GoAhead != nil { + p.Kind = "decision" + p.Text, err = d.workstreamGoAheadText(ctx, s, w, *p.GoAhead) + if err != nil { + return session.WorkstreamProposed{}, err + } + } + source, err := d.workstreamSource(ctx, s, w, p.Source) + if err != nil { + return session.WorkstreamProposed{}, err + } + r, change, err := s.ProposeRecord(ctx, workstream.RecordPropose{WorkstreamID: w.ID, Kind: p.Kind, Text: p.Text, Source: source, By: "agent", Kept: false}) + d.backend.workstreamChanged(change) + return session.WorkstreamProposed{ID: r.ID}, workstreamAgentError(err) +} + +func (d *workstreamAgentDoor) workstreamSource(ctx context.Context, s *workstream.Store, w workstream.Workstream, asked *workstream.Source) (*workstream.Source, error) { + if asked == nil { + return &workstream.Source{Kind: "chat", ID: d.agentID}, nil + } + source := *asked + if source.ChatID != "" || source.Kind != "factory_job" && source.Factory != "" { + return nil, errors.New("The source has fields for another kind.") + } + switch source.Kind { + case "factory_job": + n, err := strconv.Atoi(source.ID) + if err != nil || strconv.Itoa(n) != source.ID || !workstreamAgentMember(w, source.Factory, n) { + return nil, errors.New("The source job must be a member of this workstream, with its factory and decimal job number.") + } + case "page": + if _, err := workstreamAgentPage(ctx, s, w.ID, source.ID, false); err != nil { + return nil, err + } + case "url": + if err := workstreamAgentSourceURL(source.ID); err != nil { + return nil, err + } + default: + return nil, errors.New("source.kind must be factory_job, page or url") + } + return &source, nil +} + +func workstreamAgentSourceURL(id string) error { + u, err := url.Parse(id) + if err != nil || u.Host == "" || u.Scheme != "http" && u.Scheme != "https" { + return errors.New("A URL source must be an http(s):// URL.") + } + return nil +} + +func workstreamAgentMember(w workstream.Workstream, factory string, number int) bool { + if factory == "" || number <= 0 { + return false + } + for _, job := range w.Jobs() { + if job.Factory == factory && job.Number == number { + return true + } + } + return false +} + +func (d *workstreamAgentDoor) Plan(ctx context.Context) (session.WorkstreamPlanState, error) { + s, w, err := d.workstreamOwner(ctx, false) + if err != nil { + return session.WorkstreamPlanState{}, err + } + p, err := s.Plan(ctx, w.ID) + return workstreamAgentPlan(p), err +} + +func workstreamAgentPlan(p workstream.Plan) session.WorkstreamPlanState { + return session.WorkstreamPlanState{Version: p.Version, Milestones: p.Milestones, Waves: p.Waves} +} + +func (d *workstreamAgentDoor) SetPlan(ctx context.Context, base int, p session.WorkstreamPlanState) (session.WorkstreamPlanState, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamPlanState{}, err + } + saved, change, err := s.SetPlan(ctx, w.ID, base, p.Milestones, p.Waves, workstream.Attribution{By: "agent"}) + d.backend.workstreamChanged(change) + return workstreamAgentPlan(saved), workstreamAgentError(err) +} + +func (d *workstreamAgentDoor) workstreamGoAheadText(ctx context.Context, s *workstream.Store, w workstream.Workstream, ask session.WorkstreamGoAheadAsk) (string, error) { + if err := session.WorkstreamValidateGoAhead(ask, d.backend.now()); err != nil { + return "", err + } + scope, err := workstreamAgentGoAheadScope(ctx, s, w, ask) + if err != nil { + return "", err + } + return fmt.Sprintf("%s%s, up to $%.2f, until %s. %s", session.WorkstreamGoAheadProposalPrefix, scope, ask.CapUSD, ask.EndsOn, ask.Why), nil +} + +func workstreamAgentGoAheadScope(ctx context.Context, s *workstream.Store, w workstream.Workstream, ask session.WorkstreamGoAheadAsk) (string, error) { + if ask.Scope == "job" { + if !workstreamAgentMember(w, ask.Factory, ask.Job) { + return "", errors.New("The proposed go-ahead job must be a member of this workstream.") + } + return fmt.Sprintf("job %s#%d", ask.Factory, ask.Job), nil + } + p, err := s.Plan(ctx, w.ID) + if err != nil { + return "", err + } + if ask.Scope == "wave" { + for _, wave := range p.Waves { + if wave.N == ask.Wave { + return fmt.Sprintf("wave %d", wave.N), nil + } + } + return "", errors.New("The proposed go-ahead wave must exist in the plan.") + } + for _, milestone := range p.Milestones { + if milestone.ID == ask.MilestoneID { + return fmt.Sprintf("milestone \"%s\" (%s)", milestone.Title, milestone.ID), nil + } + } + return "", errors.New("The proposed go-ahead milestone must exist in the plan.") +} + +func (d *workstreamAgentDoor) WritePage(ctx context.Context, ask session.WorkstreamPageWrite) (session.WorkstreamPageWritten, error) { + s, w, err := d.workstreamOwner(ctx, true) + if err != nil { + return session.WorkstreamPageWritten{}, err + } + source, err := d.workstreamSource(ctx, s, w, ask.Source) + if err != nil { + return session.WorkstreamPageWritten{}, err + } + source.Label = ask.UpdatedFrom + doc := pagedoc.FromMarkdown(ask.Markdown) + if len(doc) > workstream.DocMax { + return session.WorkstreamPageWritten{}, session.ErrWorkstreamTooLarge + } + p, change, err := workstreamAgentWritePage(ctx, s, w.ID, ask, doc, workstream.Attribution{By: "agent", From: source}) + d.backend.workstreamChanged(change) + return session.WorkstreamPageWritten{ID: p.ID, Title: p.Title, Version: p.Version}, workstreamAgentError(err) +} + +func workstreamAgentWritePage(ctx context.Context, s *workstream.Store, id string, ask session.WorkstreamPageWrite, doc json.RawMessage, who workstream.Attribution) (workstream.Page, workstream.Change, error) { + if ask.PageID == "" { + return s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: id, ParentID: ask.ParentID, Title: ask.Title, Owner: "agent", ReadByJobs: false, Doc: doc, Attribution: who}) + } + p, err := workstreamAgentPage(ctx, s, id, ask.PageID, false) + if err != nil { + return workstream.Page{}, workstream.Change{}, err + } + return s.SavePage(ctx, p.ID, p.Version, doc, who) +} + +func workstreamAgentError(err error) error { + var stale *workstream.VersionConflict + switch { + case errors.As(err, &stale): + return session.ErrWorkstreamVersion + case errors.Is(err, workstream.ErrNotAgentPage): + return session.ErrWorkstreamNotAgentPage + case errors.Is(err, workstream.ErrTooLarge): + return session.ErrWorkstreamTooLarge + case errors.Is(err, workstream.ErrConflict): + return session.ErrWorkstreamDone + } + return err +} + +func (b *appBackend) workstreamAgentTurnSettled(id string) { + if b.createdBy(id) != workstreamCreatedBy { + return + } + s, err := b.workstreamStore(false) + if err != nil || s == nil { + return + } + w, err := s.ByAgent(context.Background(), id) + if err == nil { + b.workstreamChanged(workstream.Change{ID: w.ID, Parts: []workstream.ChangedPart{workstream.PartWorkstream}}) + } +} diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go new file mode 100644 index 0000000000..fcd90b41bd --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_test.go @@ -0,0 +1,328 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func workstreamAgentFixture(t *testing.T) (*appBackend, *appRecorder, appwire.Workstream, session.WorkstreamDoor) { + t.Helper() + b, rec, _, _ := workstreamBackend(t) + b.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) } + w := workstreamCreateForTest(t, b, "Agent", workstreamJobRef("7")) + if _, err := b.keepCreatedBy(w.AgentID, workstreamCreatedBy); err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), w.AgentID, "transcript.jsonl") + return b, rec, w, b.workstreamAgentDoor(file) +} + +// Contract (a) binds the capability to its own conversation, even before creation. +func TestWorkstreamAgentDoorIsScopedAndResolvesLazily(t *testing.T) { + b, _, _, person := workstreamBackend(t) + file := filepath.Join(t.TempDir(), "workstream-agent-1", "transcript.jsonl") + if b.workstreamAgentDoor(file) != nil || b.workstreamAgentDoor(filepath.Join(t.TempDir(), person.ID, "transcript.jsonl")) != nil { + t.Fatal("a person's conversation has a workstream door") + } + if _, err := b.keepCreatedBy("workstream-agent-1", workstreamCreatedBy); err != nil { + t.Fatal(err) + } + door := b.workstreamAgentDoor(file) + if door == nil { + t.Fatal("a workstream conversation needs a door before the row exists") + } + ctx := context.Background() + _, err := door.Brief(ctx) + if !errors.Is(err, session.ErrWorkstreamMissing) { + t.Fatalf("early brief: %v", err) + } + _, readErr := door.Read(ctx, session.WorkstreamReadAsk{What: "workstream"}) + _, proposeErr := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words"}) + _, planErr := door.Plan(ctx) + _, setErr := door.SetPlan(ctx, 0, session.WorkstreamPlanState{}) + _, pageErr := door.WritePage(ctx, session.WorkstreamPageWrite{Title: "Notes", UpdatedFrom: "Discussion"}) + for _, err := range []error{readErr, proposeErr, planErr, setErr, pageErr} { + if !errors.Is(err, session.ErrWorkstreamMissing) || err.Error() != "No workstream holds this conversation." { + t.Fatalf("early tool: %v", err) + } + } + w := workstreamCreateForTest(t, b, "Lazy") + brief, err := door.Brief(ctx) + if err != nil || brief.Name != w.Name { + t.Fatalf("late resolution: %+v %v", brief, err) + } + previous := appWorkstreamAgents.Swap(nil) + t.Cleanup(func() { appWorkstreamAgents.Store(previous) }) + if appWorkstreamAgentDoor(file) != nil { + t.Fatal("an engine outside the app has no backend") + } + appWorkstreamAgents.Store(b) + if appWorkstreamAgentDoor(file) == nil { + t.Fatal("the app engine must receive the door") + } +} + +// Contract (b) records proposals by the agent and never creates grants. +func TestWorkstreamAgentRecordsStayProposed(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + for _, kind := range []string{"decision", "question"} { + if _, err := door.Propose(ctx, session.WorkstreamProposal{Kind: kind, Text: "Keep this?"}); err != nil { + t.Fatal(err) + } + } + plan := session.WorkstreamPlanState{Milestones: []workstream.Milestone{{ID: "m1", Title: "Ship"}}, Waves: []workstream.Wave{{N: 2, Items: []workstream.PlanItem{}}}} + if _, err := door.SetPlan(ctx, 0, plan); err != nil { + t.Fatal(err) + } + cases := []struct { + ask session.WorkstreamGoAheadAsk + text string + }{ + {session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 12.5, EndsOn: "2026-11-04", Why: "Review"}, "Proposed go-ahead (not granted): job f#7, up to $12.50, until 2026-11-04. Review"}, + {session.WorkstreamGoAheadAsk{Scope: "wave", Wave: 2, CapUSD: 20, EndsOn: "2026-11-04", Why: "Read"}, "Proposed go-ahead (not granted): wave 2, up to $20.00, until 2026-11-04. Read"}, + {session.WorkstreamGoAheadAsk{Scope: "milestone", MilestoneID: "m1", CapUSD: 30, EndsOn: "2026-11-04", Why: "Ship"}, "Proposed go-ahead (not granted): milestone \"Ship\" (m1), up to $30.00, until 2026-11-04. Ship"}, + } + s, _ := b.workstreamStore(false) + for _, tc := range cases { + p, err := door.Propose(ctx, session.WorkstreamProposal{Kind: "question", Text: "untrusted", GoAhead: &tc.ask}) + if err != nil { + t.Fatal(err) + } + rows, err := s.Records(ctx, w.ID, "decision", "proposed") + if err != nil || rows[0].ID != p.ID || rows[0].Text != tc.text { + t.Fatalf("go-ahead text: %+v %v", rows, err) + } + } + rows, err := s.Records(ctx, w.ID, "", "") + if err != nil || len(rows) != 5 { + t.Fatalf("records: %+v %v", rows, err) + } + for _, row := range rows { + if row.State != "proposed" || row.By != "agent" || row.Source == nil || row.Source.Kind != "chat" || row.Source.ID != w.AgentID || !row.CreatedAt.Equal(b.now()) { + t.Fatalf("proposal attribution: %+v", row) + } + } + grants, err := s.GoAheads(ctx, w.ID, true) + if err != nil || len(grants) != 0 { + t.Fatalf("a proposal granted permission: %+v %v", grants, err) + } + if len(workstreamEvents(rec)) != 7 { + t.Fatalf("each committed write needs one event: %+v", workstreamEvents(rec)) + } +} + +// Contract (c) leaves membership, stage and Factory seams alone when saving drafts. +func TestWorkstreamAgentDraftPlanNeverTouchesFactory(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + b.workstreams.queue = func(context.Context, string) (api.Queue, error) { + t.Fatal("plan write called the Factory queue") + return api.Queue{}, nil + } + b.workstreams.job = func(context.Context, string, int) (api.Job, error) { + t.Fatal("plan write called a Factory job") + return api.Job{}, nil + } + s, _ := b.workstreamStore(false) + before, _ := s.Get(ctx, w.ID) + p, err := door.Plan(ctx) + if err != nil { + t.Fatal(err) + } + p.Waves = []workstream.Wave{{N: 1, AutoStart: false, Items: []workstream.PlanItem{{Kind: "draft", DraftID: "draft_fixed", Title: "Proposed"}}}} + saved, err := door.SetPlan(ctx, p.Version, p) + if err != nil || saved.Waves[0].Items[0].Kind != "draft" { + t.Fatalf("draft plan: %+v %v", saved, err) + } + after, _ := s.Get(ctx, w.ID) + if !reflect.DeepEqual(before.Members, after.Members) || after.Stage != "shaping" { + t.Fatalf("drafts changed membership or stage: %+v", after) + } + if _, err := door.SetPlan(ctx, 0, p); !errors.Is(err, session.ErrWorkstreamVersion) { + t.Fatalf("typed conflict: %v", err) + } +} + +// Contracts (d) and (e) cover receipts, attribution, history and refusal without events. +func TestWorkstreamAgentPagesAreOwnedAndMarked(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + s, _ := b.workstreamStore(false) + nodes, _ := s.Pages(ctx, w.ID) + before, _ := s.Page(ctx, nodes[0].ID, false) + history, _ := s.PageHistory(ctx, before.ID, 50) + events := len(workstreamEvents(rec)) + _, err := door.WritePage(ctx, session.WorkstreamPageWrite{PageID: before.ID, Markdown: "Forbidden", UpdatedFrom: "Discussion"}) + after, _ := s.Page(ctx, before.ID, false) + afterHistory, _ := s.PageHistory(ctx, before.ID, 50) + if !errors.Is(err, session.ErrWorkstreamNotAgentPage) || !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) || len(workstreamEvents(rec)) != events { + t.Fatalf("person page refusal: %v", err) + } + ask := session.WorkstreamPageWrite{Title: "Notes", ParentID: before.ID, Markdown: "# Notes\n\n[live: jobs]\n", UpdatedFrom: "Discussion"} + created, err := door.WritePage(ctx, ask) + if err != nil || created.Version != 1 { + t.Fatalf("create: %+v %v", created, err) + } + p, _ := s.Page(ctx, created.ID, true) + if p.Owner != "agent" || p.ReadByJobs || p.ParentID != before.ID || p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(b.now()) || p.UpdatedFrom.Label != "Discussion" || p.UpdatedFrom.ID != w.AgentID || *p.Markdown != ask.Markdown { + t.Fatalf("marked page: %+v", p) + } + b.now = func() time.Time { return time.Date(2026, 11, 3, 13, 0, 0, 0, time.UTC) } + ask = session.WorkstreamPageWrite{PageID: p.ID, Markdown: "Updated", UpdatedFrom: "Review", Source: &workstream.Source{Kind: "factory_job", Factory: "f", ID: "7", Label: "Replaced"}} + updated, err := door.WritePage(ctx, ask) + if err != nil || updated.Version != 2 { + t.Fatalf("update: %+v %v", updated, err) + } + p, _ = s.Page(ctx, p.ID, false) + history, _ = s.PageHistory(ctx, p.ID, 50) + if p.UpdatedBy != "agent" || !p.UpdatedAt.Equal(b.now()) || p.UpdatedFrom.Label != "Review" || p.UpdatedFrom.Kind != "factory_job" || history[0].SavedBy != "agent" || !history[0].SavedAt.Equal(b.now()) || !reflect.DeepEqual(p.UpdatedFrom, history[0].From) || len(workstreamEvents(rec)) != events+2 { + t.Fatalf("updated attribution: %+v %+v", p, history) + } + if wireCode(appWorkstreamError(workstream.ErrNotAgentPage)) != appwire.CodeBadParams { + t.Fatal("the wire needs a bad_params ownership refusal") + } +} + +// Contract (a) scopes reads to the owner while preserving the app's wire shapes. +func TestWorkstreamAgentReadUsesWireShapesAndScopesPages(t *testing.T) { + b, _, w, door := workstreamAgentFixture(t) + ctx := context.Background() + s, _ := b.workstreamStore(false) + nodes, _ := s.Pages(ctx, w.ID) + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: nodes[0].ID}) + var page map[string]json.RawMessage + if err != nil || json.Unmarshal(raw, &page) != nil || page["doc"] != nil || string(page["markdown"]) != `""` || page["id"] == nil { + t.Fatalf("page read: %s %v", raw, err) + } + other := workstreamCreateForTest(t, b, "Other") + otherPages, _ := s.Pages(ctx, other.ID) + if _, err := door.Read(ctx, session.WorkstreamReadAsk{What: "page", PageID: otherPages[0].ID}); !errors.Is(err, workstream.ErrNotFound) { + t.Fatalf("cross-workstream read: %v", err) + } + got, err := door.Read(ctx, session.WorkstreamReadAsk{What: "workstream"}) + result, _ := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: w.ID}) + want, _ := json.Marshal(result.Workstream) + if err != nil || string(got) != string(want) { + t.Fatalf("workstream wire: %s %s %v", got, want, err) + } + for _, part := range []string{"pages", "records", "plan", "goaheads"} { + raw, err := door.Read(ctx, session.WorkstreamReadAsk{What: part}) + var object map[string]json.RawMessage + if err != nil || json.Unmarshal(raw, &object) != nil || object[part] == nil { + t.Fatalf("%s wire: %s %v", part, raw, err) + } + } +} + +// Contracts (b), (d) and (g) refuse invalid sources and writes to a done workstream. +func TestWorkstreamAgentInvalidSourcesAndDoneWritesChangeNothing(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + other := workstreamCreateForTest(t, b, "Other") + s, _ := b.workstreamStore(false) + pages, _ := s.Pages(ctx, other.ID) + events := len(workstreamEvents(rec)) + for _, source := range []*workstream.Source{ + {Kind: "factory_job", Factory: "f", ID: "8"}, + {Kind: "factory_job", ID: "7"}, + {Kind: "factory_job", Factory: "f", ID: "07"}, + {Kind: "page", ID: pages[0].ID}, + {Kind: "url", ID: "file:///private"}, + {Kind: "url", ID: "https://"}, + {Kind: "chat", ID: "another-chat"}, + } { + if _, err := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words", Source: source}); err == nil { + t.Fatalf("accepted source %+v", source) + } + if _, err := door.WritePage(ctx, session.WorkstreamPageWrite{Title: "Notes", Markdown: "Words", UpdatedFrom: "Discussion", Source: source}); err == nil { + t.Fatalf("accepted page source %+v", source) + } + } + if len(workstreamEvents(rec)) != events { + t.Fatal("a refusal emitted a change") + } + if _, _, err := s.MarkDone(ctx, w.ID, "Done"); err != nil { + t.Fatal(err) + } + _, proposalErr := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words"}) + _, planErr := door.SetPlan(ctx, 0, session.WorkstreamPlanState{}) + _, pageErr := door.WritePage(ctx, session.WorkstreamPageWrite{Title: "Notes", Markdown: "Words", UpdatedFrom: "Discussion"}) + for _, err := range []error{proposalErr, planErr, pageErr} { + if !errors.Is(err, session.ErrWorkstreamDone) { + t.Fatalf("done write: %v", err) + } + } + if len(workstreamEvents(rec)) != events { + t.Fatal("a done write emitted a change") + } +} + +// Spend freshness in section 5 announces only the owning workstream. +func TestWorkstreamAgentSettledTurnRefreshesOnlyItsWorkstream(t *testing.T) { + b, rec, w, _ := workstreamAgentFixture(t) + events := len(workstreamEvents(rec)) + b.workstreamAgentTurnSettled("person") + if _, err := b.keepCreatedBy("unheld", workstreamCreatedBy); err != nil { + t.Fatal(err) + } + b.workstreamAgentTurnSettled("unheld") + if len(workstreamEvents(rec)) != events { + t.Fatal("an unheld conversation emitted a workstream change") + } + b.workstreamAgentTurnSettled(w.AgentID) + all := workstreamEvents(rec) + if len(all) != events+1 || all[len(all)-1].ID != w.ID || !reflect.DeepEqual(all[len(all)-1].Parts, []string{"workstream"}) { + t.Fatalf("turn settlement: %+v", all) + } +} + +// Contract (g) bounds encoded editor JSON and emits nothing after refusal. +func TestWorkstreamAgentRejectsOversizeEditorDocument(t *testing.T) { + _, rec, _, door := workstreamAgentFixture(t) + events := len(workstreamEvents(rec)) + _, err := door.WritePage(context.Background(), session.WorkstreamPageWrite{Title: "Big", Markdown: strings.Repeat("x", workstream.DocMax), UpdatedFrom: "Discussion"}) + if !errors.Is(err, session.ErrWorkstreamTooLarge) || len(workstreamEvents(rec)) != events { + t.Fatalf("oversize write: %v", err) + } +} + +// Contracts (b) and (g) validate membership, plan targets, scopes, cap and date. +func TestWorkstreamAgentGoAheadTargetsAreValidatedWithoutGranting(t *testing.T) { + b, rec, w, door := workstreamAgentFixture(t) + ctx := context.Background() + events := len(workstreamEvents(rec)) + base := session.WorkstreamGoAheadAsk{Scope: "job", Factory: "f", Job: 7, CapUSD: 1, EndsOn: "2026-11-04", Why: "Review"} + for _, mutate := range []func(*session.WorkstreamGoAheadAsk){ + func(a *session.WorkstreamGoAheadAsk) { a.Job = 8 }, + func(a *session.WorkstreamGoAheadAsk) { a.Factory = "another" }, + func(a *session.WorkstreamGoAheadAsk) { a.Scope, a.Factory, a.Job, a.Wave = "wave", "", 0, 9 }, + func(a *session.WorkstreamGoAheadAsk) { a.Scope, a.Factory, a.Job, a.MilestoneID = "milestone", "", 0, "missing" }, + func(a *session.WorkstreamGoAheadAsk) { a.CapUSD = 0 }, + func(a *session.WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-03" }, + func(a *session.WorkstreamGoAheadAsk) { a.Wave = 1 }, + } { + ask := base + mutate(&ask) + if _, err := door.Propose(ctx, session.WorkstreamProposal{GoAhead: &ask}); err == nil { + t.Fatalf("accepted %+v", ask) + } + } + s, _ := b.workstreamStore(false) + records, _ := s.Records(ctx, w.ID, "", "") + grants, _ := s.GoAheads(ctx, w.ID, true) + if len(records) != 0 || len(grants) != 0 || len(workstreamEvents(rec)) != events { + t.Fatal("a refused proposal changed records, grants or events") + } +} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 5e126b436c..b4865d8725 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1152,6 +1152,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // `factory_floor`, now that the hello has settled which conversation this // is: on the foreman's belt and no other (factory_foreman.go). cfg.Floor = foremanFloorDoor(floorStore, workspace, cfg.SessionFile) + cfg.Workstream = appWorkstreamAgentDoor(cfg.SessionFile) // THE THREE ROAD-DEPENDENT CAPABILITIES ARE DECIDED IN ONE PLACE, and this // door no longer keeps its own answer to any of them (chatv3_lanes.go). Each From 032c4573b9a1463b4eef588baad119d1ff4a7769 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:36:36 -0500 Subject: [PATCH 04/16] manual: what a workstream's agent can do Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/collections.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 825d277aff..2d567ebc64 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -169,3 +169,30 @@ Updating, marking done and reopening persist the first observed start and return those numbers using the same reads as getting the workstream. Unavailable readings stay absent; a measured zero is present. Budgets and autonomy settings do not themselves start work. + +## What a workstream's agent can do: proposals and its own pages + +A workstream's own agent in the app has six tools. Other conversations, tasks +and errands do not receive them. `workstream_read` reads the workstream, pages, +decisions, questions, plan and go-aheads. Counts, costs, states and dates come +from records. An absent number is unknown; the agent must say so and never +estimate it. Page live blocks such as `[live: jobs]` show current facts. + +`propose_decision` and `propose_question` create proposals by the agent. The +agent proposes; the person keeps. `propose_jobs` adds 1–20 draft jobs to the +plan, leaving existing waves and settings intact. Drafts create no Factory +jobs and start nothing; the person launches them. A plan conflict is retried +once against a fresh read, then refused if it changes again. + +`propose_goahead` makes a proposed decision beginning `Proposed go-ahead (not +granted): `. Its scope, cap and end date are proposals. It grants nothing; the +person chooses the real permission. The proposed cap is greater than zero and +at most $10,000; its end date is after today in UTC and within 90 days. + +`write_agent_page` creates or updates only the agent's own pages. Every write +carries an AI mark, its written-at time and a named source; history keeps that +attribution. New pages have Read by jobs off. The agent refuses the person's +pages with “That page is the person's. You write only your own pages; +suggesting edits on theirs is not available yet.” Page documents are limited +to 1 MiB. A page that changed must be read again before writing. Done +workstreams refuse all these writes. From 80dfd1e45ddf7019f4e6b05cea6030006bf6cbd7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:37:01 -0500 Subject: [PATCH 05/16] docs: change entry for the workstream agent's tools Co-Authored-By: Claude Opus 5.5 --- .../2289-app-workstream-agent-tools.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/changes/unreleased/2289-app-workstream-agent-tools.md diff --git a/docs/changes/unreleased/2289-app-workstream-agent-tools.md b/docs/changes/unreleased/2289-app-workstream-agent-tools.md new file mode 100644 index 0000000000..d1483b30d8 --- /dev/null +++ b/docs/changes/unreleased/2289-app-workstream-agent-tools.md @@ -0,0 +1,16 @@ +--- +kind: added +title: Workstream agents propose records and draft jobs and write their own pages +pr: 2289 +surface: [app, chat] +invalidates: + - A workstream's agent previously had no workstream tools or prompt; it now receives both in the app. +--- + +Each workstream's own agent gets six tools and a short workstream prompt. +Decisions and questions remain proposals; job proposals append plan drafts +without starting jobs. Go-ahead proposals are decision records and grant +nothing. Page writes enforce agent ownership in the store transaction and +preserve the agent mark, source and time in page history. Reads use recorded +facts, and settled agent turns notify the app to refresh workstream spend. +Ordinary conversations and child workers receive no workstream capability. From 11c41687f57b69fc5576b2b428e44c78e3d01f8f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 00:44:04 -0500 Subject: [PATCH 06/16] app: gofmt the workstream agent door test Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams_agent_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go index fcd90b41bd..7febcd928b 100644 --- a/cmd/codeaf/appworkstreams_agent_test.go +++ b/cmd/codeaf/appworkstreams_agent_test.go @@ -308,7 +308,9 @@ func TestWorkstreamAgentGoAheadTargetsAreValidatedWithoutGranting(t *testing.T) func(a *session.WorkstreamGoAheadAsk) { a.Job = 8 }, func(a *session.WorkstreamGoAheadAsk) { a.Factory = "another" }, func(a *session.WorkstreamGoAheadAsk) { a.Scope, a.Factory, a.Job, a.Wave = "wave", "", 0, 9 }, - func(a *session.WorkstreamGoAheadAsk) { a.Scope, a.Factory, a.Job, a.MilestoneID = "milestone", "", 0, "missing" }, + func(a *session.WorkstreamGoAheadAsk) { + a.Scope, a.Factory, a.Job, a.MilestoneID = "milestone", "", 0, "missing" + }, func(a *session.WorkstreamGoAheadAsk) { a.CapUSD = 0 }, func(a *session.WorkstreamGoAheadAsk) { a.EndsOn = "2026-11-03" }, func(a *session.WorkstreamGoAheadAsk) { a.Wave = 1 }, From ba35a47fd202cbdf04cadd78dd34fc3d23bfb169 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 01:06:48 -0500 Subject: [PATCH 07/16] workstream: agent writes check done in their transaction; markdown import is linear An agent's record or page write now refuses a done workstream inside the same write transaction, so marking done between the agent's read and its write leaves nothing behind. Imported list items always begin with a paragraph, and unmatched backticks or brackets are consumed in one pass instead of rescanning the rest of the line for each one. Co-Authored-By: Claude Opus 5.5 --- internal/workstream/pagedoc/frommarkdown.go | 190 ++++++++++++------ .../workstream/pagedoc/frommarkdown_test.go | 80 ++++++++ internal/workstream/pages.go | 31 ++- internal/workstream/records.go | 2 +- internal/workstream/workstream_agent.go | 27 +++ .../workstream/workstream_agent_done_test.go | 136 +++++++++++++ 6 files changed, 396 insertions(+), 70 deletions(-) create mode 100644 internal/workstream/workstream_agent_done_test.go diff --git a/internal/workstream/pagedoc/frommarkdown.go b/internal/workstream/pagedoc/frommarkdown.go index fa7165a3d8..acb836c9d5 100644 --- a/internal/workstream/pagedoc/frommarkdown.go +++ b/internal/workstream/pagedoc/frommarkdown.go @@ -124,23 +124,9 @@ func workstreamMarkdownListNode(lines []string, at, depth int) (map[string]any, if match == nil || len(match[1]) != indent || (match[3] != "") != ordered { break } - width := len(match[1]) + len(match[2]) - body := []string{match[4]} - i++ - for i < len(lines) { - line := lines[i] - if strings.TrimSpace(line) == "" { - if i+1 >= len(lines) || len(lines[i+1])-len(strings.TrimLeft(lines[i+1], " ")) <= indent { - break - } - } else if len(line)-len(strings.TrimLeft(line, " ")) <= indent { - break - } - cut := min(width, len(line)-len(strings.TrimLeft(line, " "))) - body = append(body, line[cut:]) - i++ - } - items = append(items, workstreamMarkdownNode("listItem", workstreamMarkdownBlocks(body, depth+1))) + body, next := workstreamMarkdownListBody(lines, i, indent, match) + items = append(items, workstreamMarkdownListItem(body, depth+1)) + i = next } kind := "bulletList" if ordered { @@ -157,6 +143,34 @@ func workstreamMarkdownListNode(lines []string, at, depth int) (map[string]any, return node, i } +func workstreamMarkdownListBody(lines []string, at, indent int, match []string) ([]string, int) { + width := len(match[1]) + len(match[2]) + body := []string{match[4]} + i := at + 1 + for i < len(lines) { + line := lines[i] + if strings.TrimSpace(line) == "" { + if i+1 >= len(lines) || len(lines[i+1])-len(strings.TrimLeft(lines[i+1], " ")) <= indent { + break + } + } else if len(line)-len(strings.TrimLeft(line, " ")) <= indent { + break + } + cut := min(width, len(line)-len(strings.TrimLeft(line, " "))) + body = append(body, line[cut:]) + i++ + } + return body, i +} + +func workstreamMarkdownListItem(lines []string, depth int) map[string]any { + content := workstreamMarkdownBlocks(lines, depth) + if len(content) == 0 || content[0]["type"] != "paragraph" { + content = append([]map[string]any{workstreamMarkdownNode("paragraph", []map[string]any{})}, content...) + } + return workstreamMarkdownNode("listItem", content) +} + func workstreamMarkdownLiveNode(line string) map[string]any { attrs := map[string]any{"kind": nil, "milestone_id": nil, "factory": nil, "job": nil, "record_id": nil} switch { @@ -183,6 +197,11 @@ func workstreamMarkdownLiveNode(line string) map[string]any { } func workstreamMarkdownInline(text string) []map[string]any { + index := workstreamMarkdownInlineIndex{ + code: workstreamMarkdownCodeRuns(text), + parens: workstreamMarkdownParens(text), + linkClose: strings.Index(text, "]("), + } out := make([]map[string]any, 0) var plain strings.Builder flush := func() { @@ -201,10 +220,14 @@ func workstreamMarkdownInline(text string) []map[string]any { i += 2 continue } - nodes, used := workstreamMarkdownInlineSpecial(text[i:]) + nodes, used := index.special(text, i) if used > 0 { - flush() - out = append(out, nodes...) + if nodes == nil { + plain.WriteString(text[i : i+used]) + } else { + flush() + out = append(out, nodes...) + } i += used continue } @@ -215,62 +238,113 @@ func workstreamMarkdownInline(text string) []map[string]any { return out } -func workstreamMarkdownInlineSpecial(text string) ([]map[string]any, int) { - if text[0] == '`' { - n := 1 - for n < len(text) && text[n] == '`' { - n++ +type workstreamMarkdownRun struct { + end int + close int +} + +type workstreamMarkdownInlineIndex struct { + code map[int]workstreamMarkdownRun + parens map[int]int + linkClose int +} + +// Indexing runs once lets an unmatched opener consume its entire run as text. +func workstreamMarkdownCodeRuns(text string) map[int]workstreamMarkdownRun { + out := make(map[int]workstreamMarkdownRun) + next := make(map[int]int) + for i := len(text) - 1; i >= 0; { + if text[i] != '`' { + i-- + continue } - if end := strings.Index(text[n:], text[:n]); end >= 0 { - body := text[n : n+end] - if strings.HasPrefix(body, " ") && strings.HasSuffix(body, " ") && strings.TrimSpace(body) != "" { - body = body[1 : len(body)-1] - } - return []map[string]any{{"type": "text", "text": body, "marks": []map[string]any{{"type": "code"}}}}, n + end + n + end := i + 1 + for i >= 0 && text[i] == '`' { + i-- } + start := i + 1 + out[start] = workstreamMarkdownRun{end: end, close: workstreamMarkdownNextRun(next, end-start)} + if end-start > 1 { + // An escaped first backtick leaves the suffix available as an opener. + out[start+1] = workstreamMarkdownRun{end: end, close: workstreamMarkdownNextRun(next, end-start-1)} + } + next[end-start] = start } - if text[0] == '[' { - return workstreamMarkdownLink(text) - } - return nil, 0 + return out } -func workstreamMarkdownLink(text string) ([]map[string]any, int) { - close := strings.Index(text, "](") - if close < 0 { - return nil, 0 +func workstreamMarkdownNextRun(next map[int]int, width int) int { + if at, ok := next[width]; ok { + return at } - end := workstreamMarkdownLinkEnd(text, close+2) - if end < 0 { - return nil, 0 - } - href := workstreamMarkdownUnescape(text[close+2 : end]) - nodes := workstreamMarkdownInline(text[1:close]) - for _, node := range nodes { - marks, _ := node["marks"].([]map[string]any) - node["marks"] = append(marks, map[string]any{"type": "link", "attrs": map[string]any{"href": href}}) - } - return nodes, end + 1 + return -1 } -func workstreamMarkdownLinkEnd(text string, start int) int { - depth := 0 - for i := start; i < len(text); i++ { +// Balanced destinations are indexed once, including failures, for every link. +func workstreamMarkdownParens(text string) map[int]int { + out := make(map[int]int) + var stack []int + for i := 0; i < len(text); i++ { if text[i] == '\\' && i+1 < len(text) { i++ continue } switch text[i] { case '(': - depth++ + stack = append(stack, i) case ')': - if depth == 0 { - return i + if len(stack) > 0 { + last := len(stack) - 1 + out[stack[last]] = i + stack = stack[:last] } - depth-- } } - return -1 + return out +} + +func (index *workstreamMarkdownInlineIndex) special(text string, at int) ([]map[string]any, int) { + if text[at] == '`' { + run := index.code[at] + if run.close < 0 { + return nil, run.end - at + } + body := text[run.end:run.close] + if strings.HasPrefix(body, " ") && strings.HasSuffix(body, " ") && strings.TrimSpace(body) != "" { + body = body[1 : len(body)-1] + } + width := run.end - at + return []map[string]any{{"type": "text", "text": body, "marks": []map[string]any{{"type": "code"}}}}, run.close + width - at + } + if text[at] == '[' { + return index.link(text, at) + } + return nil, 0 +} + +func (index *workstreamMarkdownInlineIndex) link(text string, at int) ([]map[string]any, int) { + if index.linkClose >= 0 && index.linkClose < at { + index.linkClose = strings.Index(text[at:], "](") + if index.linkClose >= 0 { + index.linkClose += at + } + } + close := index.linkClose + if close < 0 { + return nil, 0 + } + end, ok := index.parens[close+1] + if !ok { + return nil, 0 + } + href := workstreamMarkdownUnescape(text[close+2 : end]) + // The first ]( bounds this label, so parsing it cannot recurse into links. + nodes := workstreamMarkdownInline(text[at+1 : close]) + for _, node := range nodes { + marks, _ := node["marks"].([]map[string]any) + node["marks"] = append(marks, map[string]any{"type": "link", "attrs": map[string]any{"href": href}}) + } + return nodes, end - at + 1 } func workstreamMarkdownUnescape(text string) string { diff --git a/internal/workstream/pagedoc/frommarkdown_test.go b/internal/workstream/pagedoc/frommarkdown_test.go index 7f20fad0b7..49a46a2466 100644 --- a/internal/workstream/pagedoc/frommarkdown_test.go +++ b/internal/workstream/pagedoc/frommarkdown_test.go @@ -4,7 +4,11 @@ import ( "encoding/json" "os" "reflect" + "strings" "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/guard" ) func TestWorkstreamFromMarkdownContractRoundTrip(t *testing.T) { @@ -54,3 +58,79 @@ func workstreamMarkdownRoundTrip(t *testing.T, input, want string) { t.Fatalf("round trip %q:\ngot %q\nwant %q\nerror %v", input, got, want, err) } } + +func TestWorkstreamFromMarkdownListItemsStartWithParagraphs(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"- \n", "-\n"}, + {"- # Heading\n", "- # Heading\n"}, + {"- [live: jobs]\n", "- [live: jobs]\n"}, + {"- ```go\n call()\n ```\n", "- ```go\n call()\n ```\n"}, + {"- - Nested\n", "- - Nested\n"}, + {"- Parent\n - # Nested heading\n - \n", "- Parent\n - # Nested heading\n -\n"}, + } + for _, tc := range cases { + var doc map[string]any + if err := json.Unmarshal(FromMarkdown(tc.input), &doc); err != nil { + t.Fatal(err) + } + if count := workstreamCheckListParagraphs(t, doc); count == 0 { + t.Fatalf("no list items in %q", tc.input) + } + workstreamMarkdownRoundTrip(t, tc.input, tc.want) + } +} + +func workstreamCheckListParagraphs(t *testing.T, node map[string]any) int { + t.Helper() + count := 0 + kids := children(node) + if node["type"] == "listItem" { + count++ + if len(kids) == 0 || kids[0]["type"] != "paragraph" { + t.Fatalf("list item must begin with a paragraph: %+v", node) + } + } + for _, child := range kids { + count += workstreamCheckListParagraphs(t, child) + } + return count +} + +func TestWorkstreamFromMarkdownUnmatchedDelimitersAreBounded(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"backticks", "Notes " + strings.Repeat("`", 200000), "Notes " + strings.Repeat("\\`", 200000) + "\n"}, + {"brackets", strings.Repeat("[", 200000), strings.Repeat("\\[", 200000) + "\n"}, + {"unfinished link", strings.Repeat("[", 200000) + "](unfinished", strings.Repeat("\\[", 200000) + "\\](unfinished\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + finished := make(chan json.RawMessage, 1) + guard.Go("workstream/markdown-delimiters-test", func() { finished <- FromMarkdown(tc.input) }) + select { + case doc := <-finished: + if !json.Valid(doc) { + t.Fatal("conversion returned invalid editor JSON") + } + got, err := Markdown(doc) + if err != nil || got != tc.want { + t.Fatalf("conversion lost delimiter text: got %d bytes, want %d; %v", len(got), len(tc.want), err) + } + case <-time.After(5 * time.Second): + t.Fatal("malformed inline input exceeded the conversion bound") + } + }) + } +} + +func TestWorkstreamFromMarkdownCodeRequiresMatchingRunLengths(t *testing.T) { + workstreamMarkdownRoundTrip(t, "Notes ``a ` b`` and `c`.\n", "Notes ``a ` b`` and `c`.\n") + workstreamMarkdownRoundTrip(t, "Notes ``unclosed ` tail.\n", "Notes \\`\\`unclosed \\` tail.\n") + workstreamMarkdownRoundTrip(t, "Notes \\``code`.\n", "Notes \\``code`.\n") +} diff --git a/internal/workstream/pages.go b/internal/workstream/pages.go index c0d1e9a5b0..4611a0c599 100644 --- a/internal/workstream/pages.go +++ b/internal/workstream/pages.go @@ -240,8 +240,8 @@ func (s *Store) savePage(ctx context.Context, id string, base int, doc json.RawM } return ErrNotFound } - if by == "agent" && p.Owner != "agent" { - return ErrNotAgentPage + if err := workstreamPageWriteAllowed(ctx, tx, p, by); err != nil { + return err } if p.Version != base { return &VersionConflict{ID: id, Version: p.Version, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy} @@ -368,35 +368,44 @@ func placePage(ctx context.Context, tx *sql.Tx, p Page, parent, after string, la } return true, nil } -func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, error) { +func workstreamPreparePageCreate(p PageCreate) (PageCreate, any, error) { if err := validTitle(p.Title); err != nil { - return Page{}, Change{}, err + return p, nil, err } owner, err := actor(p.Owner) if err != nil { - return Page{}, Change{}, err + return p, nil, err } by, err := actor(p.By) if err != nil { - return Page{}, Change{}, err + return p, nil, err } if by == "agent" && owner != "agent" { - return Page{}, Change{}, invalid("the agent creates only its own pages") + return p, nil, invalid("the agent creates only its own pages") } source, err := sourceJSON(p.From) if err != nil { - return Page{}, Change{}, err + return p, nil, err } if p.Doc == nil { p.Doc = json.RawMessage(emptyDoc) } if err := checkDoc(p.Doc); err != nil { + return p, nil, err + } + p.Owner, p.By = owner, by + return p, source, nil +} + +func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, error) { + p, source, err := workstreamPreparePageCreate(p) + if err != nil { return Page{}, Change{}, err } var out Page var c Change err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if _, err := getIn(ctx, tx, p.WorkstreamID); err != nil { + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, p.By); err != nil { return err } id, err := s.newID() @@ -406,14 +415,14 @@ func (s *Store) CreatePage(ctx context.Context, p PageCreate) (Page, Change, err at := s.now().UnixMilli() // A temporary root position distinct from every packed position makes the // common placement helper handle creation and movement identically. - if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc) VALUES (?,?,?,?,?,2147483647,1,?,?,?,?)`, id, p.WorkstreamID, p.Title, owner, p.ReadByJobs, at, by, source, string(p.Doc)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO workstream_pages(id,workstream_id,title,owner,read_by_jobs,position,version,updated_at,updated_by,updated_from,doc) VALUES (?,?,?,?,?,2147483647,1,?,?,?,?)`, id, p.WorkstreamID, p.Title, p.Owner, p.ReadByJobs, at, p.By, source, string(p.Doc)); err != nil { return err } page, _, err := livePageIn(ctx, tx, id) if err != nil { return err } - if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, by, source); err != nil { + if _, err := placePage(ctx, tx, page, p.ParentID, p.AfterID, p.AfterID == "", at, p.By, source); err != nil { return err } if err := saveVersion(ctx, tx, id); err != nil { diff --git a/internal/workstream/records.go b/internal/workstream/records.go index 6b47ae28eb..7d8e80dbda 100644 --- a/internal/workstream/records.go +++ b/internal/workstream/records.go @@ -98,7 +98,7 @@ func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Cha var out Record var c Change err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if _, err := getIn(ctx, tx, p.WorkstreamID); err != nil { + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, by); err != nil { return err } id, err := s.newID() diff --git a/internal/workstream/workstream_agent.go b/internal/workstream/workstream_agent.go index fd2e09a148..395c02d8c0 100644 --- a/internal/workstream/workstream_agent.go +++ b/internal/workstream/workstream_agent.go @@ -8,6 +8,33 @@ import ( var ErrNotAgentPage = errors.New("the agent writes only its own pages") +var ErrDone = errors.New("the workstream is done") + +// workstreamWriteAllowed checks done in the transaction that owns the write. +func workstreamWriteAllowed(ctx context.Context, tx *sql.Tx, id, by string) error { + w, err := getIn(ctx, tx, id) + if err != nil { + return err + } + if by == "agent" && !w.DoneAt.IsZero() { + return ErrDone + } + return nil +} + +func workstreamPageWriteAllowed(ctx context.Context, tx *sql.Tx, p Page, by string) error { + if by != "agent" { + return nil + } + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, by); err != nil { + return err + } + if p.Owner != "agent" { + return ErrNotAgentPage + } + return nil +} + // ByAgent resolves the owner at call time because its conversation opens first. func (s *Store) ByAgent(ctx context.Context, agentID string) (Workstream, error) { var out Workstream diff --git a/internal/workstream/workstream_agent_done_test.go b/internal/workstream/workstream_agent_done_test.go new file mode 100644 index 0000000000..c6a9a859fd --- /dev/null +++ b/internal/workstream/workstream_agent_done_test.go @@ -0,0 +1,136 @@ +package workstream + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" +) + +type workstreamWriteSnapshot struct { + stream Workstream + records []Record + pages []Page + history map[string][]PageVersion +} + +func workstreamSnapshotWrites(t *testing.T, s *Store, id string) workstreamWriteSnapshot { + t.Helper() + ctx := context.Background() + w, err := s.Get(ctx, id) + if err != nil { + t.Fatal(err) + } + records, err := s.Records(ctx, id, "", "") + if err != nil { + t.Fatal(err) + } + nodes, err := s.Pages(ctx, id) + if err != nil { + t.Fatal(err) + } + out := workstreamWriteSnapshot{stream: w, records: records, history: map[string][]PageVersion{}} + for _, node := range nodes { + p, err := s.Page(ctx, node.ID, false) + if err != nil { + t.Fatal(err) + } + out.pages = append(out.pages, p) + history, err := s.PageHistory(ctx, node.ID, 50) + if err != nil { + t.Fatal(err) + } + out.history[node.ID] = history + } + return out +} + +func TestWorkstreamDoneRefusesAgentWritesInsideTheirTransactions(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Done writes") + p, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Notes", Owner: "agent"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + before := workstreamSnapshotWrites(t, s, w.ID) + *now = now.Add(time.Hour) + who := Attribution{By: "agent"} + writes := []struct { + name string + call func() (Change, error) + }{ + {"record", func() (Change, error) { + _, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Late proposal", By: "agent"}) + return c, err + }}, + {"create", func() (Change, error) { + _, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Late page", Owner: "agent", Attribution: who}) + return c, err + }}, + {"save", func() (Change, error) { + _, c, err := s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), who) + return c, err + }}, + {"restore", func() (Change, error) { + _, c, err := s.RestorePage(ctx, p.ID, p.Version, p.Version, who) + return c, err + }}, + } + for _, write := range writes { + t.Run(write.name, func(t *testing.T) { + c, err := write.call() + if !errors.Is(err, ErrDone) || !reflect.DeepEqual(c, Change{}) { + t.Fatalf("done write: %+v %v", c, err) + } + if after := workstreamSnapshotWrites(t, s, w.ID); !reflect.DeepEqual(before, after) { + t.Fatal("a refused write changed records, pages, history or the workstream") + } + }) + } +} + +func TestWorkstreamDoneStillAllowsThePersonsPageWrites(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Person pages") + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + p, c, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "Person notes"}) + if err != nil || c.ID != w.ID || p.Owner != "you" || p.UpdatedBy != "you" { + t.Fatalf("person create: %+v %+v %v", p, c, err) + } + p, c, err = s.SavePage(ctx, p.ID, p.Version, json.RawMessage(emptyDoc), Attribution{}) + if err != nil || c.ID != w.ID || p.Version != 2 || p.UpdatedBy != "you" { + t.Fatalf("person save: %+v %+v %v", p, c, err) + } + history, err := s.PageHistory(ctx, p.ID, 50) + if err != nil || len(history) != 2 || history[0].SavedBy != "you" { + t.Fatalf("person history: %+v %v", history, err) + } +} + +func TestWorkstreamDoneStillAllowsThePersonsRecords(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Person records") + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + r, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "decision", Text: "Person decision", Kept: true}) + if err != nil || c.ID != w.ID || r.By != "you" || r.State != "kept" { + t.Fatalf("person record: %+v %+v %v", r, c, err) + } + after, err := s.Get(ctx, w.ID) + if err != nil || !after.UpdatedAt.Equal(*now) || after.DoneAt.IsZero() { + t.Fatalf("person write timestamp: %+v %v", after, err) + } +} From 47e904474ae523aa7ad0795951c83159fd9858df Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 01:06:48 -0500 Subject: [PATCH 08/16] session: workstream tools follow the argument and retry laws The prompt's workstream brief is a snapshot that never touches the store, so a re-render under the session lock cannot wait on it. Arguments decode only through decodeToolArguments, a repeated key is refused at any depth, every argument refusal is marked, and the plan's one retry is written without a counting loop. A go-ahead's proposed cap is dollars, excused from the whole-number law in its own file. Co-Authored-By: Claude Opus 5.5 --- internal/session/prompt.go | 5 +- internal/session/toolargs_test.go | 2 + internal/session/workstream_contract.go | 3 +- internal/session/workstream_schema.go | 117 ++++++++++---- internal/session/workstream_tools.go | 150 ++++++++++-------- internal/session/workstream_tools_test.go | 140 +++++++++++----- .../session/workstream_validation_test.go | 122 +++++++++++++- 7 files changed, 397 insertions(+), 142 deletions(-) diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 3b5904a726..ef40bedc69 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -399,10 +399,7 @@ func renderSystemAt(config Config, now time.Time) string { out.WriteString(catalog) } - if config.mayWorkstream() { - out.WriteString("\n\n") - out.WriteString(workstreamPage(config)) - } + out.WriteString(workstreamSection(config)) out.WriteString(workerFooter(config, now)) return out.String() } diff --git a/internal/session/toolargs_test.go b/internal/session/toolargs_test.go index 3a95f60aa6..0cae4045f1 100644 --- a/internal/session/toolargs_test.go +++ b/internal/session/toolargs_test.go @@ -489,6 +489,8 @@ func TestNoWholeNumberArgumentIsDeclaredANumber(t *testing.T) { // whole-number-shaped anywhere else is still caught. fractionalIn := map[string]map[string]bool{ "tools_factory_item.go": {"cap": true}, + // A GO-AHEAD'S proposed cap is dollars, a float64 where twelve fifty is ordinary. + "workstream_schema.go": {"cap_usd": true}, } for _, path := range toolArgumentSources(t) { body, err := os.ReadFile(path) diff --git a/internal/session/workstream_contract.go b/internal/session/workstream_contract.go index 77708345ea..8ae48426c9 100644 --- a/internal/session/workstream_contract.go +++ b/internal/session/workstream_contract.go @@ -10,7 +10,8 @@ import ( // WorkstreamDoor is the workstream agent's one way into its own workstream. type WorkstreamDoor interface { - Brief(context.Context) (WorkstreamBrief, error) + // Brief returns a non-blocking snapshot, or false when none is ready. + Brief() (WorkstreamBrief, bool) Read(context.Context, WorkstreamReadAsk) (json.RawMessage, error) Propose(context.Context, WorkstreamProposal) (WorkstreamProposed, error) Plan(context.Context) (WorkstreamPlanState, error) diff --git a/internal/session/workstream_schema.go b/internal/session/workstream_schema.go index 0b98d1a43a..52051534ed 100644 --- a/internal/session/workstream_schema.go +++ b/internal/session/workstream_schema.go @@ -1,8 +1,10 @@ package session import ( + "bytes" "encoding/json" "fmt" + "io" "sort" "strings" ) @@ -27,25 +29,93 @@ type workstreamArgumentSchema struct { Items *workstreamArgumentSchema `json:"items"` } -func workstreamDecode(args json.RawMessage, schema workstreamArgumentSchema, target any) error { - if err := workstreamCheckArguments(args, schema, "arguments"); err != nil { +func workstreamDecode(args json.RawMessage, schema workstreamArgumentSchema, target any) (map[string]any, error) { + if err := decodeToolArguments(args, target); err != nil { + return nil, err + } + if err := workstreamUniqueKeys(args); err != nil { + return nil, err + } + var value any + if err := decodeToolArguments(args, &value); err != nil { + return nil, err + } + if err := workstreamCheckArguments(value, schema, "arguments"); err != nil { + return nil, err + } + return value.(map[string]any), nil +} + +// Walk the original token stream so repeated fields survive typed decoding. +func workstreamUniqueKeys(args json.RawMessage) error { + decoder := json.NewDecoder(bytes.NewReader(args)) + decoder.UseNumber() + if err := workstreamUniqueValue(decoder); err != nil { return err } - return decodeToolArguments(args, target) + if _, err := decoder.Token(); err != io.EOF { + return fmt.Errorf("arguments must contain one JSON value") + } + return nil } -func workstreamCheckArguments(args json.RawMessage, schema workstreamArgumentSchema, path string) error { - if strings.TrimSpace(string(args)) == "null" { +func workstreamUniqueValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + return workstreamUniqueObject(decoder) + case '[': + for decoder.More() { + if err := workstreamUniqueValue(decoder); err != nil { + return err + } + } + _, err := decoder.Token() + return err + } + return fmt.Errorf("unexpected JSON delimiter %q", delimiter) +} + +func workstreamUniqueObject(decoder *json.Decoder) error { + seen := make(map[string]bool) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return err + } + name, ok := token.(string) + if !ok { + return fmt.Errorf("object keys must be strings") + } + if seen[name] { + return fmt.Errorf("Duplicate field %q is not allowed.", name) + } + seen[name] = true + if err := workstreamUniqueValue(decoder); err != nil { + return err + } + } + _, err := decoder.Token() + return err +} + +func workstreamCheckArguments(value any, schema workstreamArgumentSchema, path string) error { + if value == nil { return fmt.Errorf("%s must not be null", path) } switch schema.Type { case "object": - return workstreamCheckObject(args, schema, path) - case "integer", "number": - return workstreamCheckNumber(args, schema.Type, path) + return workstreamCheckObject(value, schema, path) case "array": - var items []json.RawMessage - if err := json.Unmarshal(args, &items); err != nil { + items, ok := value.([]any) + if !ok { return fmt.Errorf("%s must be an array", path) } for i, item := range items { @@ -54,35 +124,20 @@ func workstreamCheckArguments(args json.RawMessage, schema workstreamArgumentSch } } case "string": - var value string - if err := json.Unmarshal(args, &value); err != nil { + text, ok := value.(string) + if !ok { return fmt.Errorf("%s must be a string", path) } - if len(schema.Enum) > 0 && !workstreamHasWord(schema.Enum, value) { + if len(schema.Enum) > 0 && !workstreamHasWord(schema.Enum, text) { return fmt.Errorf("%s must be one of %s", path, strings.Join(schema.Enum, ", ")) } } return nil } -func workstreamCheckNumber(args json.RawMessage, kind, path string) error { - if kind == "integer" { - var value int - if err := json.Unmarshal(args, &value); err != nil { - return fmt.Errorf("%s must be an integer", path) - } - return nil - } - var value float64 - if err := json.Unmarshal(args, &value); err != nil { - return fmt.Errorf("%s must be a number", path) - } - return nil -} - -func workstreamCheckObject(args json.RawMessage, schema workstreamArgumentSchema, path string) error { - var fields map[string]json.RawMessage - if err := json.Unmarshal(args, &fields); err != nil || fields == nil { +func workstreamCheckObject(value any, schema workstreamArgumentSchema, path string) error { + fields, ok := value.(map[string]any) + if !ok { return fmt.Errorf("%s must be a JSON object", path) } names := make([]string, 0, len(fields)) diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go index 6d7f367def..ec79eb9717 100644 --- a/internal/session/workstream_tools.go +++ b/internal/session/workstream_tools.go @@ -29,12 +29,17 @@ func (c Config) mayWorkstream() bool { return c.Workstream != nil && !c.readingTask() && !c.InTask && !c.Errand } +func workstreamSection(config Config) string { + if !config.mayWorkstream() { + return "" + } + return "\n\n" + workstreamPage(config) +} + func workstreamPage(config Config) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - brief, err := config.Workstream.Brief(ctx) + brief, ok := config.Workstream.Brief() intro := "You are the agent of a workstream. Read it with workstream_read before you answer." - if err == nil && strings.TrimSpace(brief.Name) != "" { + if ok && strings.TrimSpace(brief.Name) != "" { intro = fmt.Sprintf("You are the agent of the workstream %q.", workstreamCut(strings.Join(strings.Fields(brief.Name), " "), 48)) if why := strings.Join(strings.Fields(brief.Why), " "); why != "" { intro += " Why it exists: " + workstreamCut(why, 64) @@ -91,14 +96,19 @@ func workstreamTool[T any](name, description, schemaJSON string, run func(contex Schema: json.RawMessage(schemaJSON), Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { var ask T - if err := workstreamDecode(args, schema, &ask); err != nil { - return "Invalid arguments: " + err.Error(), true, nil + fields, err := workstreamDecode(args, schema, &ask) + if err != nil { + return rejectToolArguments(ctx, invalidArgumentsPrefix+err.Error()), true, nil } - if err := workstreamFieldRules(name, args); err != nil { - return "Invalid arguments: " + err.Error(), true, nil + if err := workstreamFieldRules(fields, ask); err != nil { + return rejectToolArguments(ctx, invalidArgumentsPrefix+err.Error()), true, nil } text, err := run(ctx, ask) if err != nil { + var argument *toolArgumentError + if errors.As(err, &argument) { + return rejectToolArguments(ctx, err.Error()), true, nil + } return err.Error(), true, nil } return text, false, nil @@ -106,30 +116,26 @@ func workstreamTool[T any](name, description, schemaJSON string, run func(contex } } -func workstreamFieldRules(name string, args json.RawMessage) error { - var fields map[string]json.RawMessage - _ = json.Unmarshal(args, &fields) - if err := workstreamReferenceFields(fields); err != nil { +func workstreamFieldRules(fields map[string]any, ask any) error { + if err := workstreamReferenceFields(fields, ask); err != nil { return err } _, page := fields["page_id"] - switch name { - case "workstream_read": - var what string - _ = json.Unmarshal(fields["what"], &what) - if page != (what == "page") { + switch ask := ask.(type) { + case WorkstreamReadAsk: + if page != (ask.What == "page") { return errors.New("page_id is required for and only for page") } - if what != "records" { + if ask.What != "records" { return workstreamRefuseFields(fields, "kind", "state") } - case "propose_jobs": - if _, wave := fields["wave"]; wave { + case workstreamJobsAsk: + if ask.Wave != nil { return workstreamRefuseFields(fields, "wave_title", "milestone_id") } - case "propose_goahead": - return workstreamGoAheadFields(fields) - case "write_agent_page": + case WorkstreamGoAheadAsk: + return workstreamGoAheadFields(fields, ask) + case WorkstreamPageWrite: if page { return workstreamRefuseFields(fields, "title", "parent_id") } @@ -137,29 +143,36 @@ func workstreamFieldRules(name string, args json.RawMessage) error { return nil } -func workstreamReferenceFields(fields map[string]json.RawMessage) error { +func workstreamReferenceFields(fields map[string]any, ask any) error { + ids := make(map[string]string) + var source *workstream.Source + switch ask := ask.(type) { + case WorkstreamReadAsk: + ids["page_id"] = ask.PageID + case workstreamJobsAsk: + ids["milestone_id"] = ask.MilestoneID + case WorkstreamGoAheadAsk: + ids["milestone_id"] = ask.MilestoneID + case WorkstreamPageWrite: + ids["page_id"] = ask.PageID + ids["parent_id"] = ask.ParentID + source = ask.Source + case workstreamRecordAsk: + source = ask.Source + } for _, name := range []string{"page_id", "parent_id", "milestone_id"} { - if raw, exists := fields[name]; exists { - var id string - _ = json.Unmarshal(raw, &id) - if strings.TrimSpace(id) == "" { - return fmt.Errorf("%s must name an existing record", name) - } + if _, exists := fields[name]; exists && strings.TrimSpace(ids[name]) == "" { + return fmt.Errorf("%s must name an existing record", name) } } - if raw, exists := fields["source"]; exists { - var source map[string]json.RawMessage - _ = json.Unmarshal(raw, &source) - var kind string - _ = json.Unmarshal(source["kind"], &kind) - if kind != "factory_job" { - return workstreamRefuseFields(source, "factory") - } + if source != nil && source.Kind != "factory_job" { + fields, _ := fields["source"].(map[string]any) + return workstreamRefuseFields(fields, "factory") } return nil } -func workstreamRefuseFields(fields map[string]json.RawMessage, names ...string) error { +func workstreamRefuseFields(fields map[string]any, names ...string) error { for _, name := range names { if _, exists := fields[name]; exists { return fmt.Errorf("%s is not allowed for this request", name) @@ -168,10 +181,8 @@ func workstreamRefuseFields(fields map[string]json.RawMessage, names ...string) return nil } -func workstreamGoAheadFields(fields map[string]json.RawMessage) error { - var scope string - _ = json.Unmarshal(fields["scope"], &scope) - switch scope { +func workstreamGoAheadFields(fields map[string]any, ask WorkstreamGoAheadAsk) error { + switch ask.Scope { case "job": return workstreamRefuseFields(fields, "wave", "milestone_id") case "wave": @@ -219,10 +230,10 @@ type workstreamRecordAsk struct { func workstreamRecordTool(door WorkstreamDoor, kind string) bare.Tool { return workstreamTool("propose_"+kind, "Propose a "+kind+". Only the person can keep it; it is always proposed by the agent.", workstreamRecordSchema, func(ctx context.Context, ask workstreamRecordAsk) (string, error) { if err := workstreamText(ask.Text, "text", workstreamRecordMax, true, false); err != nil { - return "", err + return "", &toolArgumentError{repair: err.Error()} } if err := workstreamSourceText(ask.Source); err != nil { - return "", err + return "", &toolArgumentError{repair: err.Error()} } p, err := door.Propose(ctx, WorkstreamProposal{Kind: kind, Text: ask.Text, Source: ask.Source}) if err != nil { @@ -307,31 +318,40 @@ func workstreamValidateDraft(job workstreamDraftAsk) error { func workstreamProposeJobs(ctx context.Context, door WorkstreamDoor, ask workstreamJobsAsk) (string, error) { if err := workstreamValidateJobs(ask); err != nil { - return "", err + return "", &toolArgumentError{repair: err.Error()} } drafts, err := workstreamDrafts(ask.Jobs) if err != nil { return "", err } - for attempt := 0; attempt < 2; attempt++ { - plan, err := door.Plan(ctx) - if err != nil { - return "", err - } - next, wave, err := workstreamAppendDrafts(plan, ask, drafts) - if err != nil { - return "", err - } - _, err = door.SetPlan(ctx, plan.Version, next) - if errors.Is(err, ErrWorkstreamVersion) { - continue - } + plan, err := door.Plan(ctx) + if err != nil { + return "", err + } + wave, err := workstreamSaveDrafts(ctx, door, plan, ask, drafts) + if errors.Is(err, ErrWorkstreamVersion) { + plan, err = door.Plan(ctx) if err != nil { return "", err } - return fmt.Sprintf("Added %d draft jobs to wave %d of the plan. They are drafts: nothing runs until the person launches them.", len(drafts), wave), nil + wave, err = workstreamSaveDrafts(ctx, door, plan, ask, drafts) + } + if errors.Is(err, ErrWorkstreamVersion) { + return "", errors.New(workstreamPlanChanged) + } + if err != nil { + return "", err + } + return fmt.Sprintf("Added %d draft jobs to wave %d of the plan. They are drafts: nothing runs until the person launches them.", len(drafts), wave), nil +} + +func workstreamSaveDrafts(ctx context.Context, door WorkstreamDoor, plan WorkstreamPlanState, ask workstreamJobsAsk, drafts []workstream.PlanItem) (int, error) { + next, wave, err := workstreamAppendDrafts(plan, ask, drafts) + if err != nil { + return 0, err } - return "", errors.New(workstreamPlanChanged) + _, err = door.SetPlan(ctx, plan.Version, next) + return wave, err } func workstreamDrafts(jobs []workstreamDraftAsk) ([]workstream.PlanItem, error) { @@ -379,7 +399,7 @@ func workstreamHasMilestone(plan WorkstreamPlanState, id string) bool { func workstreamProposeGoAhead(ctx context.Context, door WorkstreamDoor, ask WorkstreamGoAheadAsk, now time.Time) (string, error) { if err := WorkstreamValidateGoAhead(ask, now); err != nil { - return "", err + return "", &toolArgumentError{repair: err.Error()} } scope, err := workstreamGoAheadScope(ctx, door, ask) if err != nil { @@ -471,14 +491,14 @@ func workstreamGoAheadScope(ctx context.Context, door WorkstreamDoor, ask Workst func workstreamWritePage(ctx context.Context, door WorkstreamDoor, ask WorkstreamPageWrite) (string, error) { if ask.PageID == "" { if err := workstreamText(ask.Title, "title", workstreamLineMax, true, true); err != nil { - return "", err + return "", &toolArgumentError{repair: err.Error()} } } if err := workstreamText(ask.UpdatedFrom, "updated_from", workstreamLineMax, true, true); err != nil { - return "", err + return "", &toolArgumentError{repair: err.Error()} } if err := workstreamSourceText(ask.Source); err != nil { - return "", err + return "", &toolArgumentError{repair: err.Error()} } p, err := door.WritePage(ctx, ask) if errors.Is(err, ErrWorkstreamVersion) { diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go index a072f9f9e4..6ab5ae47c8 100644 --- a/internal/session/workstream_tools_test.go +++ b/internal/session/workstream_tools_test.go @@ -3,40 +3,34 @@ package session import ( "context" "encoding/json" - "errors" "reflect" "strings" "testing" "time" "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/workstream" ) type workstreamFakeDoor struct { - brief WorkstreamBrief - briefError error - read json.RawMessage - readAsk WorkstreamReadAsk - proposals []WorkstreamProposal - plan WorkstreamPlanState - reads int - saves []WorkstreamPlanState - bases []int - conflicts int - pageWrites []WorkstreamPageWrite - pageError error - otherCalls int - briefBound bool - briefExpiry time.Duration + brief WorkstreamBrief + briefReady bool + read json.RawMessage + readAsk WorkstreamReadAsk + proposals []WorkstreamProposal + plan WorkstreamPlanState + reads int + saves []WorkstreamPlanState + bases []int + conflicts int + pageWrites []WorkstreamPageWrite + pageError error + otherCalls int } -func (d *workstreamFakeDoor) Brief(ctx context.Context) (WorkstreamBrief, error) { - if deadline, ok := ctx.Deadline(); ok { - d.briefBound = true - d.briefExpiry = time.Until(deadline) - } - return d.brief, d.briefError +func (d *workstreamFakeDoor) Brief() (WorkstreamBrief, bool) { + return d.brief, d.briefReady } func (d *workstreamFakeDoor) Read(_ context.Context, ask WorkstreamReadAsk) (json.RawMessage, error) { @@ -104,7 +98,7 @@ func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { plain := &Agent{config: cfg} belt := plain.belt() page := renderSystemAt(cfg, now) - d := &workstreamFakeDoor{brief: WorkstreamBrief{Name: "Usage", Why: "Explain recorded usage"}} + d := &workstreamFakeDoor{brief: WorkstreamBrief{Name: "Usage", Why: "Explain recorded usage"}, briefReady: true} cfg.Workstream = d with := &Agent{config: cfg} if len(with.belt()) != len(belt)+6 || len(with.workstreamTools()) != 6 { @@ -118,9 +112,15 @@ func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { if !strings.Contains(rendered, `workstream "Usage"`) || !strings.Contains(rendered, "Explain recorded usage") || strings.Replace(rendered, section, "", 1) != page { t.Fatal("the gated section changed the ordinary page") } - if !d.briefBound || d.briefExpiry > 2*time.Second { - t.Fatalf("brief timeout: %v", d.briefExpiry) + if workstreamWithoutPage(rendered) != page { + t.Fatal("a child prompt retained the workstream section") } + workstreamCheckPromotedPrompt(t, rendered, page) +} + +func TestWorkstreamAgentToolsAndPromptStayOffChildren(t *testing.T) { + cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}} + now := time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC) for _, mutate := range []func(*Config){ func(c *Config) { c.InTask = true }, func(c *Config) { c.Errand = true }, @@ -134,9 +134,10 @@ func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { t.Fatal("a child or plain agent inherited the workstream") } } - if workstreamWithoutPage(rendered) != page { - t.Fatal("a child prompt retained the workstream section") - } +} + +func workstreamCheckPromotedPrompt(t *testing.T, rendered, page string) { + t.Helper() quick := &quickTaskSpec{system: rendered} quick.seed = append(quick.seed, textMessage("system", rendered)) child := &Agent{} @@ -144,17 +145,47 @@ func TestWorkstreamAgentToolsAndPromptAreScoped(t *testing.T) { if child.system != promotedSystem(page) || messageContentText(quick.seed[0]) != rendered { t.Fatal("a promoted worker retained the workstream section or changed its parent's seed") } - d.briefError = errors.New("not linked yet") +} + +func TestWorkstreamPromptUsesSnapshotOrFallback(t *testing.T) { + d := &workstreamFakeDoor{} + cfg := Config{Workstream: d} if !strings.Contains(workstreamPage(cfg), "Read it with workstream_read before you answer.") { t.Fatal("the early prompt must remain usable") } - d.briefError = nil + d.briefReady = true d.brief = WorkstreamBrief{Name: strings.Repeat(`"`, 256), Why: strings.Repeat("why ", 1000)} if len(workstreamPage(cfg)) > 900 { t.Fatal("the workstream prompt section exceeded its budget") } } +func TestWorkstreamPromptFallbackRendersImmediatelyUnderTheSessionLock(t *testing.T) { + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}}} + finished := make(chan string, 1) + guard.Go("session/workstream-prompt-test", func() { + finished <- workstreamPromptUnderLock(a) + }) + select { + case page := <-finished: + if !strings.Contains(page, "Read it with workstream_read before you answer.") { + t.Fatal("a missing snapshot must render the fallback") + } + if !a.mu.TryLock() { + t.Fatal("prompt rendering retained the session lock") + } + defer a.mu.Unlock() + case <-time.After(3 * time.Second): + t.Fatal("the prompt did not render promptly without a store") + } +} + +func workstreamPromptUnderLock(a *Agent) string { + a.mu.Lock() + defer a.mu.Unlock() + return renderSystemAt(a.config, time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC)) +} + func workstreamOrdinaryBelt(tools []bare.Tool) []string { names := []string{"workstream_read", "propose_decision", "propose_question", "propose_jobs", "propose_goahead", "write_agent_page"} var out []string @@ -284,16 +315,45 @@ func TestWorkstreamJobsRetryOnlyOneVersionConflict(t *testing.T) { if !reflect.DeepEqual(d.saves[0].Waves[0], before.Waves[0]) || !reflect.DeepEqual(d.saves[0].Milestones, before.Milestones) { t.Fatal("existing plan changed") } - last := d.saves[len(d.saves)-1] - wave := last.Waves[len(last.Waves)-1] - if wave.AutoStart || len(wave.Items) != 1 || wave.Items[0].Kind != "draft" || !strings.HasPrefix(wave.Items[0].DraftID, "draft_") { - t.Fatalf("not drafts: %+v", wave) - } - if conflicts == 2 && got != "The plan changed while you were proposing; nothing was added. Read the plan and propose again." { - t.Fatalf("second conflict: %q", got) - } - if conflicts == 1 && (wave.N != 10 || d.bases[1] != 5 || last.Waves[1].Title != "Person's wave") { - t.Fatalf("did not reapply to the new plan: %+v", last) + workstreamCheckRetriedDrafts(t, d, conflicts, got) + } +} + +func workstreamCheckRetriedDrafts(t *testing.T, d *workstreamFakeDoor, conflicts int, got string) { + t.Helper() + last := d.saves[len(d.saves)-1] + wave := last.Waves[len(last.Waves)-1] + if wave.AutoStart || len(wave.Items) != 1 || wave.Items[0].Kind != "draft" || !strings.HasPrefix(wave.Items[0].DraftID, "draft_") { + t.Fatalf("not drafts: %+v", wave) + } + if conflicts == 2 && got != "The plan changed while you were proposing; nothing was added. Read the plan and propose again." { + t.Fatalf("second conflict: %q", got) + } + if conflicts == 1 && (wave.N != 10 || d.bases[1] != 5 || last.Waves[1].Title != "Person's wave") { + t.Fatalf("did not reapply to the new plan: %+v", last) + } + if conflicts > 0 && d.saves[0].Waves[1].Items[0].DraftID != wave.Items[0].DraftID { + t.Fatal("retry changed the draft identity") + } +} + +func TestWorkstreamArgumentsRefuseDuplicateKeysBeforeTheDoor(t *testing.T) { + cases := []struct { + name string + args string + key string + }{ + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":true},"source":{"kind":"url","id":"https://example.com"}}`, "source"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","id":"https://example.com"}}`, "id"}, + {"write_agent_page", `{"title":"Notes","markdown":"First","markdown":"Second","updated_from":"Discussion"}`, "markdown"}, + {"propose_jobs", `{"jobs":[{"title":"First","title":"Second"}]}`, "title"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com"},"sour\u0063e":{"kind":"url","id":"https://example.com"}}`, "source"}, + } + for _, tc := range cases { + d := &workstreamFakeDoor{} + got := workstreamCallForTest(t, d, tc.name, tc.args, true) + if !strings.Contains(got, tc.key) || d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("%s: %q %+v", tc.name, got, d) } } } diff --git a/internal/session/workstream_validation_test.go b/internal/session/workstream_validation_test.go index 31af7edeac..72bedd710a 100644 --- a/internal/session/workstream_validation_test.go +++ b/internal/session/workstream_validation_test.go @@ -1,6 +1,9 @@ package session import ( + "context" + "encoding/json" + "errors" "strings" "testing" ) @@ -29,9 +32,126 @@ func TestWorkstreamToolBoundsRefuseBeforeTheDoor(t *testing.T) { } for _, tc := range cases { d := &workstreamFakeDoor{} - workstreamCallForTest(t, d, tc.name, tc.args, true) + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, tc.name).Execute(ctx, json.RawMessage(tc.args)) + if err != nil || !bad || !mark.RejectedBeforeRun() { + t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) + } if d.otherCalls+d.reads+len(d.saves) != 0 { t.Fatalf("invalid %s reached the door: %+v", tc.name, d) } } } + +func TestWorkstreamArgumentRefusalsAreMarked(t *testing.T) { + for _, tc := range []struct { + name string + args string + key string + }{ + {"workstream_read", `{"what":`, "valid JSON"}, + {"workstream_read", `{"what":"page"}`, "page_id"}, + {"workstream_read", `{"what":"pages","page_id":""}`, "page_id"}, + {"workstream_read", `{"what":"plan","kind":"decision"}`, "kind"}, + {"workstream_read", `"{\"what\":\"pages\"}"`, "JSON object"}, + {"propose_decision", `{"text":"Words","kept":false}`, "kept"}, + {"propose_question", `{"text":"Words","source":{"kind":"url","id":"https://example.com","kept":false}}`, "kept"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","id":"https://example.com"}}`, "id"}, + {"propose_decision", `{"text":"Words","source":{"kind":"url","id":"https://example.com","factory":""}}`, "factory"}, + {"propose_decision", `{"text":"Words","source":null}`, "source"}, + {"propose_decision", `{"text":"Words","source":"{\"kind\":\"url\",\"id\":\"https://example.com\",\"kept\":true}"}`, "source"}, + {"propose_jobs", `{"jobs":[{"title":"Draft","kept":false}]}`, "kept"}, + {"propose_jobs", `{"jobs":[null]}`, "jobs[0]"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":null}`, "wave"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":2,"wave_title":""}`, "wave_title"}, + {"propose_jobs", `{"jobs":[{"title":"Draft"}],"wave":2,"milestone_id":""}`, "milestone_id"}, + {"propose_goahead", `{"scope":"wave","wave":2,"factory":"","cap_usd":12.5,"ends_on":"2026-11-04","why":"Review"}`, "factory"}, + {"write_agent_page", `{"page_id":"p1","title":"","markdown":"Words","updated_from":"Discussion"}`, "title"}, + {"write_agent_page", `{"page_id":"p1","parent_id":"","markdown":"Words","updated_from":"Discussion"}`, "parent_id"}, + } { + d := &workstreamFakeDoor{} + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, tc.name).Execute(ctx, json.RawMessage(tc.args)) + if err != nil || !bad || !mark.RejectedBeforeRun() || !strings.Contains(text, tc.key) { + t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) + } + if d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatalf("invalid %s reached the door: %+v", tc.name, d) + } + } +} + +func TestWorkstreamDoorErrorsAreNotArgumentRejections(t *testing.T) { + for _, problem := range []error{ErrWorkstreamVersion, ErrWorkstreamNotAgentPage, ErrWorkstreamDone, errors.New("Invalid arguments: from the page store")} { + d := &workstreamFakeDoor{pageError: problem} + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, "write_agent_page").Execute(ctx, json.RawMessage(`{"page_id":"p1","markdown":"Words","updated_from":"Discussion"}`)) + if err != nil || !bad || mark.RejectedBeforeRun() || len(d.pageWrites) != 1 { + t.Fatalf("%v: %q bad=%v rejected=%v err=%v", problem, text, bad, mark.RejectedBeforeRun(), err) + } + } +} + +func TestWorkstreamSchemasAreWellFormedAndCarryNoReference(t *testing.T) { + for _, tool := range (&Agent{config: Config{Workstream: &workstreamFakeDoor{}}}).workstreamTools() { + if !json.Valid(tool.Schema) { + t.Fatalf("%s has a malformed schema", tool.Name) + } + for _, mark := range []string{`"$ref"`, `"$defs"`, `"definitions"`} { + if strings.Contains(string(tool.Schema), mark) { + t.Fatalf("%s schema carries %s", tool.Name, mark) + } + } + } +} + +func TestWorkstreamWholeNumbersUseTheSharedArgumentGrammar(t *testing.T) { + for _, number := range []string{"2.0", "2e0"} { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} + workstreamCallForTest(t, d, "propose_jobs", `{"wave":`+number+`,"jobs":[{"title":"Draft"}]}`, false) + if len(d.saves) != 1 || len(d.saves[0].Waves[0].Items) != 1 { + t.Fatalf("whole-valued wave %s did not append one draft: %+v", number, d) + } + } + for _, tc := range []struct { + number string + want string + }{ + {"2.5", `Invalid arguments: wave takes a whole number: send {"wave":2}, not 2.5`}, + {`"2"`, `Invalid arguments: wave takes a whole number: send {"wave":2}, not "2"`}, + } { + d := &workstreamFakeDoor{} + ctx, mark := withToolRejection(context.Background()) + text, bad, err := workstreamToolForTest(t, d, "propose_jobs").Execute(ctx, json.RawMessage(`{"wave":`+tc.number+`,"jobs":[{"title":"Draft"}]}`)) + if err != nil || !bad || !mark.RejectedBeforeRun() || text != tc.want { + t.Fatalf("wave %s: %q bad=%v rejected=%v err=%v", tc.number, text, bad, mark.RejectedBeforeRun(), err) + } + if d.otherCalls+d.reads+len(d.saves) != 0 { + t.Fatal("an invalid wave reached the door") + } + } +} + +func TestWorkstreamGoAheadDecodesFractionalDollarsAndWholeTargets(t *testing.T) { + for _, tc := range []struct { + name string + target string + scope string + }{ + {"job", `"factory":"f","job":7.0`, "job f#7"}, + {"wave", `"wave":2e0`, "wave 2"}, + } { + d := &workstreamFakeDoor{plan: WorkstreamPlanState{Waves: []workstream.Wave{{N: 2}}}} + ctx, mark := withToolRejection(context.Background()) + args := `{"scope":"` + tc.name + `",` + tc.target + `,"cap_usd":12.50,"ends_on":"2026-11-04","why":"Review"}` + text, bad, err := workstreamToolForTest(t, d, "propose_goahead").Execute(ctx, json.RawMessage(args)) + if err != nil || bad || mark.RejectedBeforeRun() || len(d.proposals) != 1 { + t.Fatalf("%s: %q bad=%v rejected=%v err=%v", tc.name, text, bad, mark.RejectedBeforeRun(), err) + } + proposal := d.proposals[0] + want := WorkstreamGoAheadProposalPrefix + tc.scope + ", up to $12.50, until 2026-11-04. Review" + if proposal.GoAhead == nil || proposal.GoAhead.CapUSD != 12.5 || proposal.Text != want || proposal.Kind != "decision" { + t.Fatalf("fractional dollars changed: %+v", proposal) + } + } +} From 6f05c647816e6dc0e7f6352834f80bcae6a07e80 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 01:06:48 -0500 Subject: [PATCH 09/16] app: keep each workstream agent's brief current without reading the store The app remembers each workstream's name and why by its agent when it creates, reads, updates, marks done or reopens one, and when the agent's door resolves it, so the prompt reads that snapshot. A done refusal from the store reaches the agent as the workstream being done. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams.go | 103 +++++++++------ cmd/codeaf/appworkstreams_agent.go | 28 +++- cmd/codeaf/appworkstreams_agent_brief_test.go | 122 ++++++++++++++++++ cmd/codeaf/appworkstreams_agent_done_test.go | 59 +++++++++ cmd/codeaf/appworkstreams_agent_test.go | 11 +- 5 files changed, 274 insertions(+), 49 deletions(-) create mode 100644 cmd/codeaf/appworkstreams_agent_brief_test.go create mode 100644 cmd/codeaf/appworkstreams_agent_done_test.go diff --git a/cmd/codeaf/appworkstreams.go b/cmd/codeaf/appworkstreams.go index 75b1fb9d66..b504b922fe 100644 --- a/cmd/codeaf/appworkstreams.go +++ b/cmd/codeaf/appworkstreams.go @@ -44,6 +44,8 @@ type appWorkstreams struct { cacheMu sync.Mutex cache map[string]workstreamQueueRead flights map[string]chan struct{} + briefMu sync.Mutex + briefs map[string]session.WorkstreamBrief } type workstreamQueueRead struct { @@ -177,6 +179,7 @@ func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.Wor } jobs := []workstream.Job{} for _, w := range rows { + b.workstreamRememberBrief(w) jobs = append(jobs, w.Jobs()...) } facts := b.workstreamFacts(jobs) @@ -187,6 +190,7 @@ func (b *appBackend) WorkstreamList(p appwire.WorkstreamListParams) (appwire.Wor if err != nil { return appwire.WorkstreamListResult{}, err } + b.workstreamRememberBrief(w) v := appWorkstream(w, facts, b.workstreamSpend(w.AgentID)) out.Workstreams = append(out.Workstreams, appwire.WorkstreamRow{ID: v.ID, Name: v.Name, Stage: v.Stage, Total: v.Total, Merged: v.Merged, NeedsYou: v.NeedsYou, SpendUSD: v.SpendUSD, BudgetUSD: v.BudgetUSD, UpdatedAt: v.UpdatedAt}) } @@ -199,6 +203,7 @@ func (b *appBackend) WorkstreamGet(p appwire.WorkstreamGetParams) (appwire.Works if err != nil { return appwire.WorkstreamGetResult{}, workstream.Change{}, err } + b.workstreamRememberBrief(w) facts := b.workstreamFacts(w.Jobs()) w, c, err := b.workstreamObserved(s, w, facts, workstream.Change{}) return appwire.WorkstreamGetResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err @@ -289,32 +294,16 @@ func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire if existing != nil && workstreamValue(p.ClientID) != "" { w, err := existing.ByClientID(context.Background(), *p.ClientID) if err == nil { + b.workstreamRememberBrief(w) return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil } if !errors.Is(err, workstream.ErrNotFound) { return zero, appWorkstreamError(err) } } - if p.OriginChatID != nil { - if _, err := b.find(*p.OriginChatID); err != nil { - return zero, err - } - } - store, err := b.collectionStore(false) - if err != nil { + if err := b.workstreamCreatePreflight(p); err != nil { return zero, err } - if store != nil { - if err := workstreamPreflight(store, p); err != nil { - return zero, appWorkstreamError(err) - } - } else { - for _, ref := range p.Refs { - if ref.Kind == appwire.RefCollection { - return zero, appCollectionError(workspace.ErrNotFound) - } - } - } create := b.workstreams.createAgent if create == nil { create = b.CreateAgent @@ -323,43 +312,74 @@ func (b *appBackend) WorkstreamCreate(p appwire.WorkstreamCreateParams) (appwire if err != nil { return zero, err } + w, change, err := b.workstreamCreateWithAgent(p, agent) + if err != nil { + return zero, appWorkstreamError(err) + } + b.workstreamRememberBrief(w) + b.workstreamChanged(change) + if change.ID != "" { + b.collectionChanged(w.ID, false) + } + return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil +} + +func (b *appBackend) workstreamCreatePreflight(p appwire.WorkstreamCreateParams) error { + if p.OriginChatID != nil { + if _, err := b.find(*p.OriginChatID); err != nil { + return err + } + } + store, err := b.collectionStore(false) + if err != nil { + return err + } + if store != nil { + return appWorkstreamError(workstreamPreflight(store, p)) + } + for _, ref := range p.Refs { + if ref.Kind == appwire.RefCollection { + return appCollectionError(workspace.ErrNotFound) + } + } + return nil +} + +func (b *appBackend) workstreamCreateWithAgent(p appwire.WorkstreamCreateParams, agent appwire.Agent) (workstream.Workstream, workstream.Change, error) { refs := make([]workspace.Ref, 0, len(p.Refs)) for _, r := range p.Refs { refs = append(refs, workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: workstreamValue(r.SessionID), Factory: workstreamValue(r.Factory)}) } var w workstream.Workstream var change workstream.Change - store, err = b.collectionStore(true) + store, err := b.collectionStore(true) if err == nil { s := workstream.New(store, workstream.Options{Now: b.now}) w, change, err = s.Create(context.Background(), workstream.Create{Name: strings.TrimSpace(p.Name), Why: workstreamValue(p.Why), OriginChatID: workstreamValue(p.OriginChatID), Refs: refs, AgentID: agent.ID, ClientID: workstreamValue(p.ClientID)}) } if err != nil || w.AgentID != agent.ID { - closeAgent := b.workstreams.closeAgent - if closeAgent == nil { - closeAgent = func(id string) error { return b.MoveToClosed([]string{id}) } - } - if cleanup := closeAgent(agent.ID); cleanup != nil { - if err != nil { - mapped := appWorkstreamError(err) - var wire *appwire.Error - if errors.As(mapped, &wire) { - copy := *wire - copy.Message += "; the unused workstream agent could not be closed" - return zero, © - } - } - return zero, appwire.Errorf(appwire.CodeEngine, "the unused workstream agent could not be closed: %s", appUnaskedReason("the conversation", cleanup)) + if cleanup := b.workstreamCloseUnusedAgent(agent.ID, err); cleanup != nil { + return workstream.Workstream{}, workstream.Change{}, cleanup } } - if err != nil { - return zero, appWorkstreamError(err) + return w, change, err +} + +func (b *appBackend) workstreamCloseUnusedAgent(id string, cause error) error { + closeAgent := b.workstreams.closeAgent + if closeAgent == nil { + closeAgent = func(id string) error { return b.MoveToClosed([]string{id}) } } - b.workstreamChanged(change) - if change.ID != "" { - b.collectionChanged(w.ID, false) + if cleanup := closeAgent(id); cleanup != nil { + var wire *appwire.Error + if cause != nil && errors.As(appWorkstreamError(cause), &wire) { + copy := *wire + copy.Message += "; the unused workstream agent could not be closed" + return © + } + return appwire.Errorf(appwire.CodeEngine, "the unused workstream agent could not be closed: %s", appUnaskedReason("the conversation", cleanup)) } - return appwire.WorkstreamCreateResult{Workstream: appWorkstream(w, nil, b.workstreamSpend(w.AgentID))}, nil + return nil } func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire.WorkstreamUpdateResult, error) { @@ -372,6 +392,7 @@ func (b *appBackend) WorkstreamUpdate(p appwire.WorkstreamUpdateParams) (appwire if err != nil { return appwire.WorkstreamUpdateResult{}, c, err } + b.workstreamRememberBrief(w) if c.ID != "" && p.Patch.Name != nil { b.collectionChanged(w.ID, false) } @@ -387,6 +408,7 @@ func (b *appBackend) WorkstreamMarkDone(p appwire.WorkstreamMarkDoneParams) (app if err != nil { return appwire.WorkstreamMarkDoneResult{}, c, err } + b.workstreamRememberBrief(w) facts := b.workstreamFacts(w.Jobs()) w, c, err = b.workstreamObserved(s, w, facts, c) // The workstream is already durable even if archiving a chat fails. Publish @@ -424,6 +446,7 @@ func (b *appBackend) WorkstreamReopen(p appwire.WorkstreamReopenParams) (appwire if err != nil { return appwire.WorkstreamReopenResult{}, c, err } + b.workstreamRememberBrief(w) facts := b.workstreamFacts(w.Jobs()) w, c, err = b.workstreamObserved(s, w, facts, c) return appwire.WorkstreamReopenResult{Workstream: appWorkstream(w, facts, b.workstreamSpend(w.AgentID))}, c, err diff --git a/cmd/codeaf/appworkstreams_agent.go b/cmd/codeaf/appworkstreams_agent.go index 4f03ad78af..9fa75788a6 100644 --- a/cmd/codeaf/appworkstreams_agent.go +++ b/cmd/codeaf/appworkstreams_agent.go @@ -50,6 +50,9 @@ func (d *workstreamAgentDoor) workstreamOwner(ctx context.Context, writing bool) return nil, workstream.Workstream{}, session.ErrWorkstreamMissing } w, err := s.ByAgent(ctx, d.agentID) + if err == nil { + d.backend.workstreamRememberBrief(w) + } if errors.Is(err, workstream.ErrNotFound) { err = session.ErrWorkstreamMissing } @@ -59,9 +62,25 @@ func (d *workstreamAgentDoor) workstreamOwner(ctx context.Context, writing bool) return s, w, err } -func (d *workstreamAgentDoor) Brief(ctx context.Context) (session.WorkstreamBrief, error) { - _, w, err := d.workstreamOwner(ctx, false) - return session.WorkstreamBrief{Name: w.Name, Why: w.Why}, err +func (b *appBackend) workstreamRememberBrief(w workstream.Workstream) { + state := &b.workstreams + state.briefMu.Lock() + defer state.briefMu.Unlock() + if state.briefs == nil { + state.briefs = make(map[string]session.WorkstreamBrief) + } + state.briefs[w.AgentID] = session.WorkstreamBrief{Name: w.Name, Why: w.Why} +} + +func (d *workstreamAgentDoor) Brief() (session.WorkstreamBrief, bool) { + state := &d.backend.workstreams + // Prompt rendering holds the session lock, so even cache contention falls back. + if !state.briefMu.TryLock() { + return session.WorkstreamBrief{}, false + } + defer state.briefMu.Unlock() + brief, ok := state.briefs[d.agentID] + return brief, ok } func (d *workstreamAgentDoor) Read(ctx context.Context, ask session.WorkstreamReadAsk) (json.RawMessage, error) { @@ -314,6 +333,8 @@ func workstreamAgentError(err error) error { return session.ErrWorkstreamNotAgentPage case errors.Is(err, workstream.ErrTooLarge): return session.ErrWorkstreamTooLarge + case errors.Is(err, workstream.ErrDone): + return session.ErrWorkstreamDone case errors.Is(err, workstream.ErrConflict): return session.ErrWorkstreamDone } @@ -330,6 +351,7 @@ func (b *appBackend) workstreamAgentTurnSettled(id string) { } w, err := s.ByAgent(context.Background(), id) if err == nil { + b.workstreamRememberBrief(w) b.workstreamChanged(workstream.Change{ID: w.ID, Parts: []workstream.ChangedPart{workstream.PartWorkstream}}) } } diff --git a/cmd/codeaf/appworkstreams_agent_brief_test.go b/cmd/codeaf/appworkstreams_agent_brief_test.go new file mode 100644 index 0000000000..929411bdbf --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_brief_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func TestWorkstreamBriefNeverWaitsForTheStore(t *testing.T) { + b := &appBackend{} + door := &workstreamAgentDoor{backend: b, agentID: "unheld"} + b.collectionsMu.Lock() + defer b.collectionsMu.Unlock() + finished := make(chan bool, 1) + guard.Go("app/workstream-brief-test", func() { + brief, ok := door.Brief() + finished <- !ok && brief == (session.WorkstreamBrief{}) + }) + select { + case fallback := <-finished: + if !fallback { + t.Fatal("a missing snapshot must use the prompt fallback") + } + case <-time.After(3 * time.Second): + t.Fatal("the brief waited for the store") + } +} + +func TestWorkstreamBriefNeverWaitsForItsCache(t *testing.T) { + b := &appBackend{} + door := &workstreamAgentDoor{backend: b, agentID: "held"} + b.workstreamRememberBrief(workstream.Workstream{AgentID: "held", Name: "Cached", Why: "A reason"}) + b.workstreams.briefMu.Lock() + defer b.workstreams.briefMu.Unlock() + finished := make(chan bool, 1) + guard.Go("app/workstream-brief-cache-test", func() { + _, ok := door.Brief() + finished <- ok + }) + select { + case ok := <-finished: + if ok { + t.Fatal("a busy snapshot must use the fallback") + } + case <-time.After(3 * time.Second): + t.Fatal("the brief waited for its cache") + } +} + +func TestWorkstreamBriefTracksCreateAndRename(t *testing.T) { + b, _, _, _ := workstreamBackend(t) + created, err := b.WorkstreamCreate(appwire.WorkstreamCreateParams{Name: "Original", Why: workstreamPtr("Explain usage")}) + if err != nil { + t.Fatal(err) + } + door := &workstreamAgentDoor{backend: b, agentID: created.Workstream.AgentID} + workstreamCheckBrief(t, door, "Original", "Explain usage") + _, err = b.WorkstreamUpdate(appwire.WorkstreamUpdateParams{ID: created.Workstream.ID, Patch: appwire.WorkstreamPatch{Name: workstreamPtr("Renamed"), Why: workstreamPtr("Review usage")}}) + if err != nil { + t.Fatal(err) + } + workstreamCheckBrief(t, door, "Renamed", "Review usage") +} + +func TestWorkstreamBriefRefreshesWhereTheAppResolvesItsOwner(t *testing.T) { + cases := []struct { + name string + read func(*appBackend, string, *workstreamAgentDoor) error + }{ + {"get", func(b *appBackend, id string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamGet(appwire.WorkstreamGetParams{ID: id}) + return err + }}, + {"list", func(b *appBackend, _ string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamList(appwire.WorkstreamListParams{}) + return err + }}, + {"done", func(b *appBackend, id string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamMarkDone(appwire.WorkstreamMarkDoneParams{ID: id}) + return err + }}, + {"reopen", func(b *appBackend, id string, _ *workstreamAgentDoor) error { + _, err := b.WorkstreamReopen(appwire.WorkstreamReopenParams{ID: id}) + return err + }}, + {"door", func(_ *appBackend, _ string, d *workstreamAgentDoor) error { + _, err := d.Plan(context.Background()) + return err + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b, _, w, _ := workstreamAgentFixture(t) + s, err := b.workstreamStore(false) + if err != nil { + t.Fatal(err) + } + _, _, err = s.Update(context.Background(), w.ID, workstream.Patch{Name: workstreamPtr("Refreshed"), Why: workstreamPtr("Fresh reason")}) + if err != nil { + t.Fatal(err) + } + door := &workstreamAgentDoor{backend: b, agentID: w.AgentID} + if err := tc.read(b, w.ID, door); err != nil { + t.Fatal(err) + } + workstreamCheckBrief(t, door, "Refreshed", "Fresh reason") + }) + } +} + +func workstreamCheckBrief(t *testing.T, door *workstreamAgentDoor, name, why string) { + t.Helper() + brief, ok := door.Brief() + if !ok || brief.Name != name || brief.Why != why { + t.Fatalf("brief: %+v %v; want %q / %q", brief, ok, name, why) + } +} diff --git a/cmd/codeaf/appworkstreams_agent_done_test.go b/cmd/codeaf/appworkstreams_agent_done_test.go new file mode 100644 index 0000000000..51512fbe34 --- /dev/null +++ b/cmd/codeaf/appworkstreams_agent_done_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workstream" +) + +func TestWorkstreamDoneCommittedAfterDoorResolutionRefusesTheWrite(t *testing.T) { + for _, operation := range []string{"record", "create", "save"} { + t.Run(operation, func(t *testing.T) { + b, rec, _, door := workstreamAgentFixture(t) + ctx := context.Background() + s, w, err := door.(*workstreamAgentDoor).workstreamOwner(ctx, true) + if err != nil { + t.Fatal(err) + } + p, _, err := s.CreatePage(ctx, workstream.PageCreate{WorkstreamID: w.ID, Title: "Existing", Owner: "agent"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.MarkDone(ctx, w.ID, "Finished after resolution"); err != nil { + t.Fatal(err) + } + before, err := s.Get(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + events := len(workstreamEvents(rec)) + change, err := workstreamWriteAfterResolution(ctx, s, w.ID, p.ID, operation) + b.workstreamChanged(change) + if !errors.Is(workstreamAgentError(err), session.ErrWorkstreamDone) || len(workstreamEvents(rec)) != events { + t.Fatalf("done after resolution: %+v %v", change, err) + } + after, err := s.Get(ctx, w.ID) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("refused write changed the workstream: %+v %v", after, err) + } + }) + } +} + +func workstreamWriteAfterResolution(ctx context.Context, s *workstream.Store, id, pageID, operation string) (workstream.Change, error) { + if operation == "record" { + _, c, err := s.ProposeRecord(ctx, workstream.RecordPropose{WorkstreamID: id, Kind: "decision", Text: "Late", By: "agent"}) + return c, err + } + ask := session.WorkstreamPageWrite{Title: "Late"} + if operation == "save" { + ask.PageID = pageID + } + _, c, err := workstreamAgentWritePage(ctx, s, id, ask, json.RawMessage(`{"type":"doc","content":[]}`), workstream.Attribution{By: "agent"}) + return c, err +} diff --git a/cmd/codeaf/appworkstreams_agent_test.go b/cmd/codeaf/appworkstreams_agent_test.go index 7febcd928b..4f0f1393cb 100644 --- a/cmd/codeaf/appworkstreams_agent_test.go +++ b/cmd/codeaf/appworkstreams_agent_test.go @@ -43,9 +43,8 @@ func TestWorkstreamAgentDoorIsScopedAndResolvesLazily(t *testing.T) { t.Fatal("a workstream conversation needs a door before the row exists") } ctx := context.Background() - _, err := door.Brief(ctx) - if !errors.Is(err, session.ErrWorkstreamMissing) { - t.Fatalf("early brief: %v", err) + if brief, ok := door.Brief(); ok || brief != (session.WorkstreamBrief{}) { + t.Fatalf("early brief: %+v %v", brief, ok) } _, readErr := door.Read(ctx, session.WorkstreamReadAsk{What: "workstream"}) _, proposeErr := door.Propose(ctx, session.WorkstreamProposal{Kind: "decision", Text: "Words"}) @@ -58,9 +57,9 @@ func TestWorkstreamAgentDoorIsScopedAndResolvesLazily(t *testing.T) { } } w := workstreamCreateForTest(t, b, "Lazy") - brief, err := door.Brief(ctx) - if err != nil || brief.Name != w.Name { - t.Fatalf("late resolution: %+v %v", brief, err) + brief, ok := door.Brief() + if !ok || brief.Name != w.Name { + t.Fatalf("late resolution: %+v %v", brief, ok) } previous := appWorkstreamAgents.Swap(nil) t.Cleanup(func() { appWorkstreamAgents.Store(previous) }) From 41b87cb4c72914c522bb6486c95301de59aaf2e3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 01:06:48 -0500 Subject: [PATCH 10/16] docs: workstream agent tools refuse repeated keys; done is checked in the write Co-Authored-By: Claude Opus 5.5 --- .../unreleased/2289-app-workstream-agent-tools.md | 9 +++++++++ internal/manual/chat/collections.md | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/changes/unreleased/2289-app-workstream-agent-tools.md b/docs/changes/unreleased/2289-app-workstream-agent-tools.md index d1483b30d8..365cd05003 100644 --- a/docs/changes/unreleased/2289-app-workstream-agent-tools.md +++ b/docs/changes/unreleased/2289-app-workstream-agent-tools.md @@ -14,3 +14,12 @@ nothing. Page writes enforce agent ownership in the store transaction and preserve the agent mark, source and time in page history. Reads use recorded facts, and settled agent turns notify the app to refresh workstream spend. Ordinary conversations and child workers receive no workstream capability. + +Prompt briefs use a non-blocking cached snapshot and fall back when unavailable. +Agent record and page writes check done inside the write transaction, including +when the person marks done after the agent's read. Markdown list items always +start with a paragraph, and malformed inline delimiters take linear work. +Tool arguments refuse repeated keys at every object depth before calling a tool. +Workstream tools use the shared argument decoder and mark input refusals before +running. Whole-valued decimal job and wave numbers are accepted; proposed caps +retain fractional dollars. A plan version conflict gets one fresh read and save. diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 2d567ebc64..a5cb330354 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -187,7 +187,8 @@ once against a fresh read, then refused if it changes again. `propose_goahead` makes a proposed decision beginning `Proposed go-ahead (not granted): `. Its scope, cap and end date are proposals. It grants nothing; the person chooses the real permission. The proposed cap is greater than zero and -at most $10,000; its end date is after today in UTC and within 90 days. +at most $10,000, including fractional dollars such as $12.50; its end date is +after today in UTC and within 90 days. `write_agent_page` creates or updates only the agent's own pages. Every write carries an AI mark, its written-at time and a named source; history keeps that @@ -196,3 +197,7 @@ pages with “That page is the person's. You write only your own pages; suggesting edits on theirs is not available yet.” Page documents are limited to 1 MiB. A page that changed must be read again before writing. Done workstreams refuse all these writes. + +Tool arguments refuse unknown fields and repeated object keys, including inside +sources and draft jobs, before reading or writing the workstream. Whole job and +wave numbers accept forms such as `2.0`; fractions and numeric strings are refused. From bd497d98b50e3774335996be514eef814d08efb7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 01:13:03 -0500 Subject: [PATCH 11/16] session: import the workstream types in the validation test Co-Authored-By: Claude Opus 5.5 --- internal/session/workstream_validation_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/session/workstream_validation_test.go b/internal/session/workstream_validation_test.go index 72bedd710a..46317e6ce2 100644 --- a/internal/session/workstream_validation_test.go +++ b/internal/session/workstream_validation_test.go @@ -6,6 +6,8 @@ import ( "errors" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/workstream" ) // Contract (g) validates byte bounds and JSON shapes before any read or write. From 32a2f869509f55e9c93687f11ece4153230a5a87 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 01:13:31 -0500 Subject: [PATCH 12/16] workstream: only an explicitly attributed agent proposal is refused when done The done check in ProposeRecord read the defaulted actor, so the wire's unattributed record.propose on a done workstream, which has always landed as a proposal, was refused. It now reads the attribution the caller gave, and the agent's door keeps passing its own. Co-Authored-By: Claude Opus 5.5 --- internal/workstream/records.go | 5 ++++- .../workstream/workstream_agent_done_test.go | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/workstream/records.go b/internal/workstream/records.go index 7d8e80dbda..40262f3e96 100644 --- a/internal/workstream/records.go +++ b/internal/workstream/records.go @@ -80,6 +80,9 @@ func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Cha if err := recordText(p.Kind, p.Text); err != nil { return Record{}, Change{}, err } + // Only an explicitly attributed agent write is refused on a done workstream; + // the wire's unattributed proposal keeps the behaviour it always had. + asked := p.By if p.By == "" { if p.Kept { p.By = "you" @@ -98,7 +101,7 @@ func (s *Store) ProposeRecord(ctx context.Context, p RecordPropose) (Record, Cha var out Record var c Change err = s.workspace.Write(ctx, func(tx *sql.Tx) error { - if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, by); err != nil { + if err := workstreamWriteAllowed(ctx, tx, p.WorkstreamID, asked); err != nil { return err } id, err := s.newID() diff --git a/internal/workstream/workstream_agent_done_test.go b/internal/workstream/workstream_agent_done_test.go index c6a9a859fd..c7e3f44300 100644 --- a/internal/workstream/workstream_agent_done_test.go +++ b/internal/workstream/workstream_agent_done_test.go @@ -134,3 +134,22 @@ func TestWorkstreamDoneStillAllowsThePersonsRecords(t *testing.T) { t.Fatalf("person write timestamp: %+v %v", after, err) } } + +// The wire's unattributed proposal still lands on a done workstream as it always +// has; only an explicitly attributed agent write is refused there. +func TestWorkstreamDoneStillAllowsTheUnattributedProposal(t *testing.T) { + s, _, now := testStore(t) + ctx := context.Background() + w := createStream(t, s, "Unattributed records") + if _, _, err := s.MarkDone(ctx, w.ID, "Finished"); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Hour) + r, c, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Follow-up"}) + if err != nil || c.ID != w.ID || r.By != "agent" || r.State != "proposed" { + t.Fatalf("unattributed proposal: %+v %+v %v", r, c, err) + } + if _, _, err := s.ProposeRecord(ctx, RecordPropose{WorkstreamID: w.ID, Kind: "question", Text: "Agent", By: "agent"}); !errors.Is(err, ErrDone) { + t.Fatalf("explicit agent proposal on a done workstream: %v", err) + } +} From ec4d2a4fa96010b470b2bb70687016ca3c059e1b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 02:04:56 -0500 Subject: [PATCH 13/16] session: the workstream agent keeps its "Where it stands" page current The app shows the agent's summary on the Overview from the agent page titled exactly "Where it stands". The prompt and write_agent_page now name that page and ask the agent to keep it current with what it was updated from. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/collections.md | 4 ++-- internal/session/workstream_contract.go | 4 ++++ internal/session/workstream_tools.go | 3 ++- internal/session/workstream_tools_test.go | 21 +++++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index a5cb330354..ae3ef989f3 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -199,5 +199,5 @@ to 1 MiB. A page that changed must be read again before writing. Done workstreams refuse all these writes. Tool arguments refuse unknown fields and repeated object keys, including inside -sources and draft jobs, before reading or writing the workstream. Whole job and -wave numbers accept forms such as `2.0`; fractions and numeric strings are refused. +sources and draft jobs, before reading or writing the workstream. The agent keeps +its page titled “Where it stands” current; the Overview shows it with the AI mark. diff --git a/internal/session/workstream_contract.go b/internal/session/workstream_contract.go index 8ae48426c9..a7d5d61a73 100644 --- a/internal/session/workstream_contract.go +++ b/internal/session/workstream_contract.go @@ -31,6 +31,10 @@ var ErrWorkstreamTooLarge = errors.New("That page is too large; the encoded docu const WorkstreamGoAheadProposalPrefix = "Proposed go-ahead (not granted): " +// WorkstreamWhereItStandsTitle is the agent page the app shows on the Overview as +// the agent's summary, so the agent keeps the page with exactly this title current. +const WorkstreamWhereItStandsTitle = "Where it stands" + type WorkstreamBrief struct { Name string Why string diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go index ec79eb9717..fb38cad0d1 100644 --- a/internal/session/workstream_tools.go +++ b/internal/session/workstream_tools.go @@ -22,6 +22,7 @@ const workstreamPlanChanged = "The plan changed while you were proposing; nothin const workstreamPromptRules = "\n- You propose; the person keeps. Decisions, questions, jobs and go-aheads you raise with propose_decision, propose_question, propose_jobs and propose_goahead are proposals until the person keeps them.\n" + "- You write only your own pages, with write_agent_page. The person's pages are theirs; you do not change them.\n" + + "- Keep your page titled \"" + WorkstreamWhereItStandsTitle + "\" current with write_agent_page: a short summary of where the work stands, with updated_from saying what it was updated from. Create it if it is missing.\n" + "- You never invent numbers. Counts, costs, states and dates come from workstream_read; on a page, use a live block such as [live: jobs] instead of writing a number.\n" + "- You start nothing. Jobs start only when the person launches them or inside a go-ahead they granted." @@ -81,7 +82,7 @@ func (a *Agent) workstreamToolsAt(now func() time.Time) []bare.Tool { workstreamTool("propose_goahead", "Propose a go-ahead as a decision. The cap and end date are proposals; this grants nothing.", workstreamGoAheadSchema, func(ctx context.Context, ask WorkstreamGoAheadAsk) (string, error) { return workstreamProposeGoAhead(ctx, door, ask, now()) }), - workstreamTool("write_agent_page", "Write only your own pages, marked as agent-written with their source and time. The person's pages are refused.", workstreamPageSchema, func(ctx context.Context, ask WorkstreamPageWrite) (string, error) { + workstreamTool("write_agent_page", "Write only your own pages, marked as agent-written with their source and time. The person's pages are refused. Keep the page titled \""+WorkstreamWhereItStandsTitle+"\" current; the app shows it on the Overview.", workstreamPageSchema, func(ctx context.Context, ask WorkstreamPageWrite) (string, error) { return workstreamWritePage(ctx, door, ask) }), } diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go index 6ab5ae47c8..98de92f2e4 100644 --- a/internal/session/workstream_tools_test.go +++ b/internal/session/workstream_tools_test.go @@ -410,3 +410,24 @@ func TestWorkstreamToolActionCategories(t *testing.T) { } } } + +// The app draws the agent's summary from the agent page titled exactly +// "Where it stands", so the prompt and write_agent_page both name that title. +func TestWorkstreamAgentKeepsWhereItStandsCurrent(t *testing.T) { + if WorkstreamWhereItStandsTitle != "Where it stands" { + t.Fatalf("the Overview summary page title moved: %q", WorkstreamWhereItStandsTitle) + } + cfg := Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Workstream: &workstreamFakeDoor{}} + if !strings.Contains(workstreamPage(cfg), `titled "Where it stands" current with write_agent_page`) { + t.Fatal("the workstream prompt does not ask the agent to keep Where it stands current") + } + described := false + for _, tool := range (&Agent{config: cfg}).workstreamTools() { + if tool.Name == "write_agent_page" { + described = strings.Contains(tool.Description, `"Where it stands"`) + } + } + if !described { + t.Fatal("write_agent_page does not name the Where it stands page") + } +} From d8b24ab58e6919f53a01f4379189e159e172c6be Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 02:14:33 -0500 Subject: [PATCH 14/16] session: a shorter Where it stands rule, and room for it in the prompt budget The rule now fits in one short line, and the workstream section's own budget test allows 1000 bytes for the worst-case name and why. Co-Authored-By: Claude Opus 5.5 --- internal/session/workstream_tools.go | 2 +- internal/session/workstream_tools_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/session/workstream_tools.go b/internal/session/workstream_tools.go index fb38cad0d1..2a7308c3e4 100644 --- a/internal/session/workstream_tools.go +++ b/internal/session/workstream_tools.go @@ -22,7 +22,7 @@ const workstreamPlanChanged = "The plan changed while you were proposing; nothin const workstreamPromptRules = "\n- You propose; the person keeps. Decisions, questions, jobs and go-aheads you raise with propose_decision, propose_question, propose_jobs and propose_goahead are proposals until the person keeps them.\n" + "- You write only your own pages, with write_agent_page. The person's pages are theirs; you do not change them.\n" + - "- Keep your page titled \"" + WorkstreamWhereItStandsTitle + "\" current with write_agent_page: a short summary of where the work stands, with updated_from saying what it was updated from. Create it if it is missing.\n" + + "- Keep your page titled \"" + WorkstreamWhereItStandsTitle + "\" current with write_agent_page, saying what it was updated from.\n" + "- You never invent numbers. Counts, costs, states and dates come from workstream_read; on a page, use a live block such as [live: jobs] instead of writing a number.\n" + "- You start nothing. Jobs start only when the person launches them or inside a go-ahead they granted." diff --git a/internal/session/workstream_tools_test.go b/internal/session/workstream_tools_test.go index 98de92f2e4..6859db2b51 100644 --- a/internal/session/workstream_tools_test.go +++ b/internal/session/workstream_tools_test.go @@ -155,7 +155,7 @@ func TestWorkstreamPromptUsesSnapshotOrFallback(t *testing.T) { } d.briefReady = true d.brief = WorkstreamBrief{Name: strings.Repeat(`"`, 256), Why: strings.Repeat("why ", 1000)} - if len(workstreamPage(cfg)) > 900 { + if len(workstreamPage(cfg)) > 1000 { t.Fatal("the workstream prompt section exceeded its budget") } } From 9a767080bd931120345c76e17bc2de0276b5658a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 02:32:55 -0500 Subject: [PATCH 15/16] tests: the conformance seed lends the agent the Overview for its one save The store now refuses an agent write to a page the person owns, so the seed that reproduces the contract's agent-saved Overview hands the page to the agent for that save and back again, keeping the agent's attribution and version. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams_conformance_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/cmd/codeaf/appworkstreams_conformance_test.go b/cmd/codeaf/appworkstreams_conformance_test.go index b022e5e27f..e3cb621e43 100644 --- a/cmd/codeaf/appworkstreams_conformance_test.go +++ b/cmd/codeaf/appworkstreams_conformance_test.go @@ -649,9 +649,19 @@ func (fixture *workstreamWireFixture) saveByAgent(t *testing.T) { if err != nil || store == nil { t.Fatalf("seed page store: %v", err) } - _, _, err = store.SavePage(context.Background(), fixture.IDs["pg_31d0"], 1, - json.RawMessage(`{"type":"doc","content":[]}`), workstream.Attribution{By: "agent", From: workstreamSource(fixture.source())}) - if err != nil { + // The agent writes only its own pages, so the seed hands it the Overview + // for the one save the contract's examples show and then hands it back, + // still attributed to the agent, without a new version. + ctx, id := context.Background(), fixture.IDs["pg_31d0"] + agent := workstream.Attribution{By: "agent", From: workstreamSource(fixture.source())} + lent, kept := "agent", "you" + if _, _, err := store.UpdatePage(ctx, id, workstream.PagePatch{Owner: &lent, Attribution: agent}); err != nil { + t.Fatal(err) + } + if _, _, err := store.SavePage(ctx, id, 1, json.RawMessage(`{"type":"doc","content":[]}`), agent); err != nil { + t.Fatal(err) + } + if _, _, err := store.UpdatePage(ctx, id, workstream.PagePatch{Owner: &kept, Attribution: agent}); err != nil { t.Fatal(err) } } From f4ac14ad42406125381be86181e414efec530d6e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 02:53:17 -0500 Subject: [PATCH 16/16] tests: page version and restore fixtures write as the agent only on its pages The store now refuses an agent write to a page the person owns, so the version-read and deleted-tree fixtures that save or create as the agent make those pages the agent's first; what they assert is unchanged. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appworkstreams_pages_test.go | 2 +- internal/workstream/pages_test.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/appworkstreams_pages_test.go b/cmd/codeaf/appworkstreams_pages_test.go index 6d89ba1d6f..1a521d06f6 100644 --- a/cmd/codeaf/appworkstreams_pages_test.go +++ b/cmd/codeaf/appworkstreams_pages_test.go @@ -55,7 +55,7 @@ func TestAppWorkstreamPageGetVersion(t *testing.T) { t.Fatalf("store: %v", err) } doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Earlier"}]}]}`) - first, _, err := store.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Before", Doc: doc, + first, _, err := store.CreatePage(context.Background(), workstream.PageCreate{WorkstreamID: w.ID, Title: "Before", Owner: "agent", Doc: doc, Attribution: workstream.Attribution{By: "agent", From: &workstream.Source{Kind: "chat", ID: "old-source"}}}) if err != nil { t.Fatal(err) diff --git a/internal/workstream/pages_test.go b/internal/workstream/pages_test.go index 4b9b00e51d..3b5b84c257 100644 --- a/internal/workstream/pages_test.go +++ b/internal/workstream/pages_test.go @@ -228,7 +228,7 @@ func TestWorkstreamPageGetSavedVersion(t *testing.T) { ctx := context.Background() w := createStream(t, s, "Version reads") firstDoc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]}`) - first, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "First title", Doc: firstDoc, + first, _, err := s.CreatePage(ctx, PageCreate{WorkstreamID: w.ID, Title: "First title", Owner: "agent", Doc: firstDoc, Attribution: Attribution{By: "agent", From: &Source{Kind: "chat", ID: "first-source"}}}) if err != nil { t.Fatal(err) @@ -319,6 +319,11 @@ func TestWorkstreamDeletedPageTreeRestore(t *testing.T) { second := createWorkstreamPage(t, s, w.ID, root.ID, "Second") sibling := createWorkstreamPage(t, s, w.ID, parent.ID, "Sibling") doc := json.RawMessage(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Keep me"}]}]}`) + // The agent writes only its own pages, so the page is the agent's for this save. + agentOwner := "agent" + if _, _, err := s.UpdatePage(ctx, root.ID, PagePatch{Owner: &agentOwner}); err != nil { + t.Fatal(err) + } if _, _, err := s.SavePage(ctx, root.ID, 1, doc, Attribution{By: "agent", From: &Source{Kind: "chat", ID: "source"}}); err != nil { t.Fatal(err) }