diff --git a/cmd/codeaf/appscope.go b/cmd/codeaf/appscope.go new file mode 100644 index 0000000000..8818e1f71c --- /dev/null +++ b/cmd/codeaf/appscope.go @@ -0,0 +1,44 @@ +package main + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// SetAgentScope writes the conversation's stored scope without opening it. +// A held conversation reads the word beside its next task-index reading. +func (b *appBackend) SetAgentScope(id, scope string) (appwire.AgentScope, error) { + if scope != session.WorkScopeProject && scope != session.WorkScopeConversation { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeBadParams, "scope is project or conversation") + } + var a *appAgent + b.locked(func() { a = b.agents[id] }) + if a == nil { + var err error + a, err = b.seenTarget(id) + if err != nil { + return appwire.AgentScope{}, err + } + } + a.gate.Lock() + defer a.gate.Unlock() + if err := b.lentFence(a, "scope"); err != nil { + return appwire.AgentScope{}, err + } + var transcript string + b.locked(func() { transcript = a.transcript }) + if strings.TrimSpace(transcript) == "" { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeConflict, "this conversation has no local folder on this computer") + } + dir := session.DirOf(transcript) + meta, err := session.LoadMeta(dir) + if err != nil || meta.ID == "" { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeConflict, "this conversation has no local folder on this computer") + } + if _, err := session.SetConversationScope(dir, scope); err != nil { + return appwire.AgentScope{}, appEngineError(err) + } + return appwire.AgentScope{ID: id, Scope: session.ConversationScopeAt(dir)}, nil +} diff --git a/cmd/codeaf/appscope_test.go b/cmd/codeaf/appscope_test.go new file mode 100644 index 0000000000..2dd906ffc8 --- /dev/null +++ b/cmd/codeaf/appscope_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// L-FIX9-cross A7 +func TestAppScopeWritesAClosedConversationWithoutOpeningIt(t *testing.T) { + d := newUnreadDisk(t) + if err := d.backend.state.write(func(f *appStateFile) error { + f.Closed = map[string]time.Time{d.id: time.Now()} + return nil + }); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(d.dir, "transcript.jsonl")) + if err != nil { + t.Fatal(err) + } + w := newActionWire(t, d.backend) + if !slices.Contains(w.hello, appwire.FeatureAgentScope) { + t.Fatal("the backend does not announce agents.scope") + } + for _, word := range []string{session.WorkScopeConversation, session.WorkScopeProject} { + raw, failure := w.call("agents.set_scope", map[string]string{"id": d.id, "scope": word}) + if failure != nil { + t.Fatal(failure) + } + var result appwire.AgentScope + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + if result != (appwire.AgentScope{ID: d.id, Scope: word}) || session.ConversationScopeAt(d.dir) != word { + t.Fatalf("set %q = %+v, stored %q", word, result, session.ConversationScopeAt(d.dir)) + } + } + d.mu.Lock() + opens := d.opens + d.mu.Unlock() + if opens != 0 || d.backend.held(d.id) || !d.backend.tidied(d.id) { + t.Fatalf("scope opened or reopened the conversation: opens %d, held %v, closed %v", opens, d.backend.held(d.id), d.backend.tidied(d.id)) + } + after, err := os.ReadFile(filepath.Join(d.dir, "transcript.jsonl")) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("scope changed the transcript: %q, %v", after, err) + } + d.rec.mu.Lock() + defer d.rec.mu.Unlock() + if slices.Contains(d.rec.events, appwire.EventAgentChanged) { + t.Fatalf("scope emitted agent.changed: %v", d.rec.events) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRefusesAnUnknownAgent(t *testing.T) { + d := newUnreadDisk(t) + w := newActionWire(t, d.backend) + if _, err := w.call("agents.set_scope", map[string]string{"id": "no-such-agent", "scope": session.WorkScopeConversation}); err == nil || err.Code != appwire.CodeNotFound { + t.Fatalf("unknown agent = %v", err) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRefusesALentConversationBeforeWriting(t *testing.T) { + backend, _, agent, dir := liveUnread(t, &fakeConversation{}) + a, err := backend.seenTarget(agent.ID) + if err != nil { + t.Fatal(err) + } + backend.locked(func() { a.live.conn.Lent = true }) + path := filepath.Join(dir, "meta.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + _, err = backend.SetAgentScope(agent.ID, session.WorkScopeConversation) + failure, ok := err.(*appwire.Error) + want := errLentStage("scope").(*appwire.Error) + if !ok || !reflect.DeepEqual(failure, want) { + t.Fatalf("lent scope change = %v, want %v", err, want) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("refused scope changed metadata: %q, %v", after, err) + } +} + +// L-FIX9-cross A7 +func TestAppScopeLeavesAHeldConversationAndItsUseTimeAlone(t *testing.T) { + conv := &fakeConversation{} + backend, _, agent, dir := liveUnread(t, conv) + a, err := backend.seenTarget(agent.ID) + if err != nil { + t.Fatal(err) + } + var before *appLive + var used time.Time + var view appwire.Agent + backend.locked(func() { before, used, view = a.live, a.live.lastUse, a.view }) + result, err := backend.SetAgentScope(agent.ID, session.WorkScopeConversation) + if err != nil || result.Scope != session.WorkScopeConversation || session.ConversationScopeAt(dir) != session.WorkScopeConversation { + t.Fatalf("held scope = %+v, %v", result, err) + } + backend.locked(func() { + if a.live != before || !a.live.lastUse.Equal(used) || !reflect.DeepEqual(a.view, view) { + t.Errorf("scope changed the held conversation or warmed it") + } + }) + conv.mu.Lock() + defer conv.mu.Unlock() + if conv.submits != 0 || conv.interrupted != 0 { + t.Fatalf("scope drove the conversation: submits %d, interruptions %d", conv.submits, conv.interrupted) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRequiresALocalConversationFolder(t *testing.T) { + for _, where := range []string{"no transcript", "missing folder", "missing metadata"} { + t.Run(where, func(t *testing.T) { + d := newUnreadDisk(t) + a, err := d.backend.seenTarget(d.id) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + transcript := "" + if where == "missing folder" { + dir = filepath.Join(dir, "absent") + transcript = filepath.Join(dir, "transcript.jsonl") + } else if where == "missing metadata" { + transcript = filepath.Join(dir, "transcript.jsonl") + } + d.backend.locked(func() { a.transcript = transcript }) + _, err = d.backend.SetAgentScope(d.id, session.WorkScopeConversation) + failure, ok := err.(*appwire.Error) + if !ok || failure.Code != appwire.CodeConflict || failure.Message != "this conversation has no local folder on this computer" { + t.Fatalf("%s = %v", where, err) + } + if _, err := os.Stat(filepath.Join(dir, "meta.json")); !os.IsNotExist(err) { + t.Fatalf("scope created a conversation: %v", err) + } + }) + } +} diff --git a/docs/changes/unreleased/2462-agent-scope.md b/docs/changes/unreleased/2462-agent-scope.md new file mode 100644 index 0000000000..c05b340d01 --- /dev/null +++ b/docs/changes/unreleased/2462-agent-scope.md @@ -0,0 +1,18 @@ +--- +kind: added +title: A conversation can be told only about its own work (agents.set_scope) +pr: 2462 +surface: [app, engine] +invalidates: + - "Was true: every conversation in a folder was told about the other conversations' landed and running work there, and its tasks tool listed them. Is true now: a client can set a conversation's saved scope to conversation so its turns and tasks tool show only its own work; project remains the default and restores the earlier behavior." +--- + +`agents.set_scope {id, scope}` stores `project` or `conversation` and answers the +stored word. Engines serving it announce `agents.scope`. The choice survives a +restart and takes effect on the next turn or tasks call without opening, +warming or interrupting the conversation. Its folder, transcript, title, model +and tasks stay as they were. A lent conversation or one without a local folder +is refused. + +The person's task list, task nodes and the write-safety checks keep their +existing behavior. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index d7846082bb..2b746cbd82 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -75,11 +75,35 @@ decides which combinations are accepted. | `agents.set_effort` | `{effort, id}` | `agentResult` | | `agents.set_model` | `{automatic, id, model}` | `agentResult` | | `agents.set_models` | `{fallback, id, main}` | `agentResult` | +| `agents.set_scope` | `{id, scope}` | `AgentScope` | | `agents.since` | `{after, id}` | `Since` | | `agents.steer` | `in.sayTo` | `Empty` | | `agents.stop` | `in.IDParams` | `Empty` | | `agents.suggest_reply` | `in.ReplySuggestionParams` | `ReplySuggestion` | +## Conversation work scope + +`agents.set_scope {id, scope}` answers `{id, scope}` with the stored word. +`project` is the default: the conversation is told about other conversations' +landed and running work in its folder, as today. `conversation` means it is told +only about its own work: its turns receive no other-conversation `` +block, and its `tasks` tool lists and addresses only its own tasks. Neither the +other-window running section nor the wider `scope: "everywhere"` section is +added. Task nodes keep their existing scope. + +The choice is stored with the conversation and survives restarts. It takes +effect from the conversation's next turn; a live tasks call also reads the +stored choice. Setting it never opens, warms or interrupts the conversation, +never moves its folder, and never changes its transcript, title, model or tasks. +The write-safety checks still warn before its own work writes over files other +conversations are writing. The person's task list is unchanged. + +Engines that serve this method announce `agents.scope` in `hello.features`. +A missing id or a scope other than `project` or `conversation` is `bad_params`; +an engine without the door answers `unknown_method`. An unknown conversation is +`not_found`; a lent conversation or one without a local folder is `conflict`. +No `agent.changed` event is emitted, because the Agent view carries no scope. + ## Client tools The optional features `agents.client_tools` and `agents.client_tools.source` diff --git a/internal/appwire/agent_scope.go b/internal/appwire/agent_scope.go new file mode 100644 index 0000000000..03c95abad6 --- /dev/null +++ b/internal/appwire/agent_scope.go @@ -0,0 +1,43 @@ +package appwire + +// FeatureAgentScope names an engine that keeps a conversation's work scope. +const FeatureAgentScope = "agents.scope" + +type AgentScope struct { + ID string `json:"id"` + Scope string `json:"scope" enum:"project,conversation"` +} + +type AgentScoper interface { + SetAgentScope(id, scope string) (AgentScope, error) +} + +func withAgentScopeFeature(h Hello, e Engine) Hello { + if _, ok := e.(AgentScoper); ok { + return withFeature(h, FeatureAgentScope) + } + return h +} + +func init() { + methods["agents.set_scope"] = typed(func(e Engine, p struct { + ID string `json:"id"` + Scope string `json:"scope" enum:"project,conversation"` + }) (*AgentScope, error) { + if p.ID == "" { + return nil, Errorf(CodeBadParams, "agents.set_scope needs an id") + } + if p.Scope != "project" && p.Scope != "conversation" { + return nil, Errorf(CodeBadParams, "scope is project or conversation") + } + scoper, ok := e.(AgentScoper) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.set_scope") + } + out, err := scoper.SetAgentScope(p.ID, p.Scope) + if err != nil { + return nil, err + } + return &out, nil + }) +} diff --git a/internal/appwire/agent_scope_test.go b/internal/appwire/agent_scope_test.go new file mode 100644 index 0000000000..d5e71b2a8d --- /dev/null +++ b/internal/appwire/agent_scope_test.go @@ -0,0 +1,92 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "slices" + "testing" +) + +type scopeEngine struct { + fakeEngine +} + +func (e *scopeEngine) SetAgentScope(id, scope string) (AgentScope, error) { + if id == "missing" { + return AgentScope{}, Errorf(CodeNotFound, "there is no conversation with id missing") + } + return AgentScope{ID: id, Scope: scope}, nil +} + +// L-FIX9-cross A7 +func TestAgentScopeIsAnnouncedAndServedOnlyWithItsDoor(t *testing.T) { + engine := &scopeEngine{} + w := startWireOver(t, engine, &engine.fakeEngine) + if !slices.Contains(features(w.next()), FeatureAgentScope) { + t.Fatal("hello lacks agents.scope") + } + for i, word := range []string{"conversation", "project"} { + request, err := json.Marshal(map[string]any{ + "id": i + 1, "method": "agents.set_scope", "params": map[string]string{"id": "a1", "scope": word}, + }) + if err != nil { + t.Fatal(err) + } + w.send(string(request)) + got := w.next() + want := map[string]any{"id": "a1", "scope": word} + if got["error"] != nil || !reflect.DeepEqual(got["result"], want) { + t.Fatalf("scope %q = %v", word, got) + } + } + plain := startWire(t, &fakeEngine{}) + if slices.Contains(features(plain.next()), FeatureAgentScope) { + t.Fatal("an engine without the door announced agents.scope") + } + plain.send(`{"id":3,"method":"agents.set_scope","params":{"id":"a1","scope":"conversation"}}`) + got := plain.next() + if failure, ok := got["error"].(map[string]any); !ok || failure["code"] != CodeUnknownMethod || failure["message"] != "this engine does not serve agents.set_scope" { + t.Fatalf("an unsupported engine = %v", got) + } +} + +// L-FIX9-cross A7 +func TestAgentScopeValidatesParametersAndCarriesEngineErrors(t *testing.T) { + for _, tc := range []struct { + params, code, message string + }{ + {`{"scope":"conversation"}`, CodeBadParams, "agents.set_scope needs an id"}, + {`{"id":"","scope":"conversation"}`, CodeBadParams, "agents.set_scope needs an id"}, + {`{"id":"a1"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"a1","scope":"everywhere"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"a1","scope":"Conversation"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"missing","scope":"conversation"}`, CodeNotFound, "there is no conversation with id missing"}, + } { + _, err := methods["agents.set_scope"].call(&scopeEngine{}, json.RawMessage(tc.params)) + failure, ok := err.(*Error) + if !ok || failure.Code != tc.code || failure.Message != tc.message { + t.Fatalf("params %s = %v, want %s: %s", tc.params, err, tc.code, tc.message) + } + } +} + +// L-FIX9-cross A7 +func TestAgentScopeSchemaNamesBothWordsAndTheResult(t *testing.T) { + doc := Schema() + method := doc["methods"].(map[string]any)["agents.set_scope"].(map[string]any) + params := method["params"].(map[string]any)["properties"].(map[string]any) + result := doc["$defs"].(map[string]any)["AgentScope"].(map[string]any) + for name, properties := range map[string]map[string]any{ + "params": params, "result": result["properties"].(map[string]any), + } { + if properties["id"].(map[string]any)["type"] != "string" { + t.Fatalf("%s lacks a string id: %v", name, properties) + } + if got := properties["scope"].(map[string]any)["enum"]; !reflect.DeepEqual(got, []string{"project", "conversation"}) { + t.Fatalf("%s scope enum = %v", name, got) + } + } + if got := method["result"].(map[string]any)["$ref"]; got != "#/$defs/AgentScope" { + t.Fatalf("result schema = %v", method["result"]) + } +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 063b5af1b5..0e20f284df 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -214,6 +214,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello = withCrewsOrganizeFeature(hello, engine) hello = withReplySuggestionFeature(hello, engine) hello = withUnreadFeature(hello, engine) + hello = withAgentScopeFeature(hello, engine) hello = withErrandFeature(hello, engine) hello = withClientToolsFeature(hello, engine) hello = withAsksDrawnFeature(hello, engine) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index d13b744d98..59c0b97f87 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2863,8 +2863,9 @@ it had started. ## Does it know what my other windows are doing — will it notice work from another terminal -Yes, and it is told rather than having to go and ask. When another codeaf window on this -same directory lands a task, or has one running, codeaf puts a small block into the chat's +With the default `project` conversation scope, yes: it is told rather than having to +go and ask. When another codeaf window on this same directory lands a task, or has +one running, codeaf puts a small block into the chat's own context — never on your screen — that reads: ``` @@ -2901,10 +2902,30 @@ running in another window now: told. - A **task** is never given this block. A task's brief is its whole world. +## Conversation scope — tell this chat only about its own work + +A client can set a saved conversation's scope with `agents.set_scope {id, scope}`. +`project` is the default: the chat is told about other conversations' landed and +running work in the same folder. `conversation` means it is told only about its +own work. No `` block is added to its turns, and the model's `tasks` +tool lists only its own tasks. Another conversation's tasks cannot be read by +name or id, steered or resolved through that tool. The running-in-other-windows +section and the `scope: "everywhere"` section are absent too. + +The choice survives a restart and takes effect from the next turn; a tasks call +also reads the saved choice. Setting it does not open or interrupt the chat, +move its folder, or change its transcript, title, model or tasks. Set `project` +to restore the ordinary reading. This setting has no terminal command or UI +control. The person's `@` task list and task nodes keep their existing behavior. + +The checks that warn before this chat's own work writes over files other +conversations are writing still apply. + ## Asking the chat what else is running on this project right now -Ask it in words. It reads the other windows itself rather than guessing from the project's -record — which cannot answer, because an ordinary task writes no row there until it lands. +With `project` conversation scope, ask it in words. It reads the other windows itself +rather than guessing from the project's record — which cannot answer, because an +ordinary task writes no row there until it lands. What it gets back looks like this: ``` diff --git a/internal/session/place.go b/internal/session/place.go index d0ff1ca9be..14bd1e3221 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -296,6 +296,9 @@ type Meta struct { // a gate a person opened from inside a conversation must be open when they // come back to it. Approval string `json:"approval,omitempty"` + // Scope is conversation when the model is told only about its own work. + // Project or absence keeps the project's reading of other conversations' work. + Scope string `json:"scope,omitempty"` // Created is when the session was minted. Created time.Time `json:"created"` // LastUserAt is when the PERSON last said something. Resume order is on @@ -513,6 +516,9 @@ func saveMetaLocked(dir string, meta Meta) error { } meta.ChatURI = current.ChatURI } + if meta.Scope == "" { + meta.Scope = current.Scope + } meta.AgentIdentity = keptAgentIdentity(current, meta) // K3: schema paths use slashes even when their source machine is Windows. for _, field := range []*string{&meta.Workspace, &meta.Origin, &meta.FromWorkspace} { diff --git a/internal/session/scope.go b/internal/session/scope.go new file mode 100644 index 0000000000..883d77834c --- /dev/null +++ b/internal/session/scope.go @@ -0,0 +1,61 @@ +package session + +import ( + "fmt" + "strings" +) + +const ( + WorkScopeProject = "project" + WorkScopeConversation = "conversation" +) + +// ConversationScopeAt reads the stored scope. An absent or unreadable identity +// keeps the project's ordinary reading of other conversations' work. +func ConversationScopeAt(dir string) string { + if strings.TrimSpace(dir) == "" { + return WorkScopeProject + } + meta, err := LoadMeta(dir) + if err == nil && meta.Scope == WorkScopeConversation { + return WorkScopeConversation + } + return WorkScopeProject +} + +// SetConversationScope changes only an existing conversation's scope. A word +// already kept, or project when no scope is stored, owes no write. +func SetConversationScope(dir, scope string) (changed bool, err error) { + if scope != WorkScopeProject && scope != WorkScopeConversation { + return false, fmt.Errorf("scope is project or conversation") + } + dir = strings.TrimSpace(dir) + if dir == "" { + return false, fmt.Errorf("set conversation scope: no conversation directory") + } + err = withMetaLock(dir, func() error { + meta, err := LoadMeta(dir) + if err != nil { + return err + } + if meta.ID == "" { + return fmt.Errorf("no conversation at %s", dir) + } + if meta.Scope == scope || (meta.Scope == "" && scope == WorkScopeProject) { + return nil + } + meta.Scope = scope + if err := saveMetaLocked(dir, meta); err != nil { + return err + } + changed = true + return nil + }) + return changed, err +} + +// ownWorkOnly is read beside the task index, so a stored choice reaches a live +// conversation without another connection or a read on the request path. +func (a *Agent) ownWorkOnly() bool { + return ConversationScopeAt(a.config.Place.Dir) == WorkScopeConversation +} diff --git a/internal/session/scope_test.go b/internal/session/scope_test.go new file mode 100644 index 0000000000..6d7750fe31 --- /dev/null +++ b/internal/session/scope_test.go @@ -0,0 +1,502 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" +) + +// scopeRig uses the same index and presence records as the turn-context and +// tasks-tool fixtures, with an own row and another project to catch wider reads. +func scopeRig(t *testing.T) (*Agent, string) { + t.Helper() + root := t.TempDir() + bucket := filepath.Join(root, "here") + dir := filepath.Join(bucket, "0123456789abcdef") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = Place{Dir: dir, Workspace: c.Workspace} + c.SessionFile = c.Place.Transcript() + }) + if err := SaveMeta(dir, Meta{ID: "0123456789abcdef", Workspace: agent.config.Workspace}); err != nil { + t.Fatal(err) + } + writeWindow(t, bucket, "theirs", "Other conversation", time.Second, + PresenceTask{ID: "4", Title: "Sweep the call sites", State: string(TaskRunning), + Files: []string{"internal/session/agent.go"}}) + appendTaskIndex(filepath.Join(bucket, taskIndexName), + deltaLandedRow("99", "theirs", "Fix the nil-map crash", time.Now().Add(-time.Minute))) + agent.mu.Lock() + id := agent.sessionID() + agent.mu.Unlock() + appendTaskIndex(filepath.Join(bucket, taskIndexName), + deltaLandedRow("7", id, "Repair this parser", time.Now().Add(-2*time.Minute))) + machineSession(t, root, "away", "another", "Separate project", t.TempDir(), 4242, time.Second, + PresenceTask{ID: "8", Title: "Separate project work", State: string(TaskRunning)}) + return agent, dir +} + +// L-FIX9-cross A1 +func TestConversationScopeLeavesOtherWorkOutOfTurnContext(t *testing.T) { + agent, dir := scopeRig(t) + if changed, err := SetConversationScope(dir, WorkScopeConversation); err != nil || !changed { + t.Fatalf("set scope = %v, %v", changed, err) + } + agent.refreshElsewhere(context.Background()) + if got := volatileNote(agent); strings.Contains(got, "") { + t.Fatalf("other work reached the turn: %s", got) + } + if agent.elsewhereText != "" || len(agent.elsewhereTold) != 0 { + t.Fatalf("other work was retained: %q, %+v", agent.elsewhereText, agent.elsewhereTold) + } + agent.SettleWrites() + if !LastTold(dir).IsZero() { + t.Fatal("a conversation told no other work advanced its told stamp") + } +} + +// L-FIX9-cross A2 +func TestConversationScopeTasksReachOnlyItsOwnRows(t *testing.T) { + agent, dir := scopeRig(t) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + for _, args := range []string{`{}`, `{"scope":"everywhere"}`, `{"query":"nil-map"}`} { + text, isError := runTool(t, agent, "tasks", args) + if isError { + t.Fatalf("search %s = %s", args, text) + } + for _, absent := range []string{"Fix the nil-map crash", "Sweep the call sites", "Separate project work", "running in other codeaf windows", "running in other projects"} { + if strings.Contains(text, absent) { + t.Fatalf("search %s reached %q: %s", args, absent, text) + } + } + if args != `{"query":"nil-map"}` && !strings.Contains(text, "Repair this parser") { + t.Fatalf("own work is missing: %s", text) + } + } + for _, args := range []string{ + `{"id":99}`, `{"id":"fix-the-nil-map-crash"}`, + `{"id":99,"say":"change the guard"}`, `{"id":99,"resolve":"accept"}`, + } { + text, isError := runTool(t, agent, "tasks", args) + if !isError || !strings.Contains(text, "No task") || strings.Contains(text, "Fix the nil-map crash") { + t.Fatalf("other task %s = %s (error %v)", args, text, isError) + } + } + text, isError := runTool(t, agent, "tasks", `{"id":7}`) + if isError || !strings.Contains(text, "Repair this parser") { + t.Fatalf("own task read = %s (error %v)", text, isError) + } + if rows := agent.TaskIndex(); len(rows) != 2 { + t.Fatalf("the person's task index was narrowed: %+v", rows) + } +} + +// L-FIX9-cross A3 +func TestProjectScopeKeepsOtherWorkVisible(t *testing.T) { + for _, reset := range []bool{false, true} { + t.Run(map[bool]string{false: "absent", true: "restored"}[reset], func(t *testing.T) { + agent, dir := scopeRig(t) + if reset { + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + agent.refreshElsewhere(context.Background()) + if _, err := SetConversationScope(dir, WorkScopeProject); err != nil { + t.Fatal(err) + } + } + agent.refreshElsewhere(context.Background()) + for _, want := range []string{"", "Fix the nil-map crash", "Sweep the call sites"} { + if got := volatileNote(agent); !strings.Contains(got, want) { + t.Fatalf("project context lacks %q: %s", want, got) + } + } + text, isError := runTool(t, agent, "tasks", `{}`) + if isError || !strings.Contains(text, "Fix the nil-map crash") || !strings.Contains(text, "running in other codeaf windows") { + t.Fatalf("project tasks = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"scope":"everywhere"}`) + if isError || !strings.Contains(text, "Separate project work") { + t.Fatalf("project wide tasks = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"id":99}`) + if isError || !strings.Contains(text, "Fix the nil-map crash") { + t.Fatalf("project task read = %s (error %v)", text, isError) + } + }) + } +} + +// L-FIX9-cross A5 +func TestConversationScopeClearsAnEarlierBlockOnTheNextReading(t *testing.T) { + agent, dir := scopeRig(t) + agent.refreshElsewhere(context.Background()) + agent.SettleWrites() + if !strings.Contains(volatileNote(agent), "") || len(agent.elsewhereTold) == 0 { + t.Fatal("the fixture never delivered other work") + } + was := LastTold(dir) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + agent.refreshElsewhere(context.Background()) + agent.SettleWrites() + agent.mu.Lock() + current := agent.volatileBlockLocked() + agent.mu.Unlock() + if agent.elsewhereText != "" || agent.elsewhereTold != nil || strings.Contains(current, "") { + t.Fatalf("earlier work survived the next reading: %s", current) + } + if !LastTold(dir).Equal(was) { + t.Fatal("clearing the block advanced the told stamp") + } +} + +// L-FIX9-cross A6 +// LoadMeta reports the same facts except scope and the build stamped on every +// metadata write; the write applies the usual metadata normalisation. +func TestConversationScopeChangesOnlyItsStoredWord(t *testing.T) { + dir := t.TempDir() + meta := Meta{ + ID: "0123456789abcdef", Workspace: t.TempDir(), Title: "Parser repairs", + Model: "test/model", Effort: "high", Approval: PostureAsk, + SpentUSD: .25, Tokens: 140, Created: time.Now().Add(-time.Hour).UTC(), + LastUserAt: time.Now().Add(-time.Minute).UTC(), Archived: true, + Places: []PlaceRef{{Path: t.TempDir()}}, + } + if err := SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + transcript := (Place{Dir: dir}).Transcript() + journal := []byte("{\"type\":\"session\",\"id\":\"0123456789abcdef\"}\n") + if err := os.WriteFile(transcript, journal, 0o600); err != nil { + t.Fatal(err) + } + before := scopeMetaFields(t, dir) + for _, word := range []string{WorkScopeConversation, WorkScopeProject} { + changed, err := SetConversationScope(dir, word) + if err != nil || !changed || ConversationScopeAt(dir) != word { + t.Fatalf("set %q = %v, %v; stored %q", word, changed, err, ConversationScopeAt(dir)) + } + after := scopeMetaFields(t, dir) + if string(after["scope"]) != strconv.Quote(word) { + t.Fatalf("scope = %s, want %q", after["scope"], word) + } + delete(after, "scope") + if !reflect.DeepEqual(before, after) { + t.Fatalf("scope changed other metadata: before %v, after %v", before, after) + } + got, err := os.ReadFile(transcript) + if err != nil || !bytes.Equal(got, journal) { + t.Fatalf("scope changed the transcript: %q, %v", got, err) + } + + path := filepath.Join(dir, placeMeta) + old := time.Unix(1234567890, 0) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + was, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + changed, err = SetConversationScope(dir, word) + if err != nil || changed { + t.Fatalf("repeat %q = %v, %v", word, changed, err) + } + now, err := os.Stat(path) + if err != nil || !os.SameFile(was, now) || !was.ModTime().Equal(now.ModTime()) { + t.Fatalf("repeat %q rewrote meta.json: %v", word, err) + } + } +} + +// L-FIX9-cross A6 +func TestProjectScopeLeavesAbsentScopeUnwritten(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, placeMeta) + old := time.Unix(1234567890, 0) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if changed, err := SetConversationScope(dir, WorkScopeProject); err != nil || changed { + t.Fatalf("set absent project = %v, %v", changed, err) + } + after, err := os.Stat(path) + if err != nil || !os.SameFile(before, after) || !before.ModTime().Equal(after.ModTime()) { + t.Fatalf("default project rewrote meta.json: %v", err) + } + if _, present := scopeMetaFields(t, dir)["scope"]; present { + t.Fatal("default project wrote a scope") + } +} + +func scopeMetaFields(t *testing.T, dir string) map[string]json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, placeMeta)) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + return fields +} + +// L-FIX9-cross A8 +func TestConversationScopeRefusesUnknownWordsAndMissingConversations(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine", Workspace: t.TempDir()}); err != nil { + t.Fatal(err) + } + before := scopeMetaFields(t, dir) + for _, word := range []string{"", "everywhere", "Conversation", " conversation "} { + if changed, err := SetConversationScope(dir, word); err == nil || changed { + t.Fatalf("unknown %q = %v, %v", word, changed, err) + } + } + if !reflect.DeepEqual(before, scopeMetaFields(t, dir)) { + t.Fatal("an unknown word changed metadata") + } + for _, dir := range []string{"", " ", t.TempDir(), filepath.Join(t.TempDir(), "absent")} { + if changed, err := SetConversationScope(dir, WorkScopeConversation); err == nil || changed { + t.Fatalf("no conversation at %q = %v, %v", dir, changed, err) + } + if dir != "" && dir != " " { + if _, err := os.Stat(filepath.Join(dir, placeMeta)); !os.IsNotExist(err) { + t.Fatalf("a missing conversation acquired meta.json: %v", err) + } + } + } +} + +// L-FIX9-cross A3 +func TestConversationScopeDefaultsQuietlyToProject(t *testing.T) { + for _, stored := range []string{"", "project", "Conversation", "elsewhere", WorkScopeConversation} { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine", Scope: stored}); err != nil { + t.Fatal(err) + } + want := WorkScopeProject + if stored == WorkScopeConversation { + want = WorkScopeConversation + } + if got := ConversationScopeAt(dir); got != want { + t.Fatalf("stored %q reads %q, want %q", stored, got, want) + } + } + for _, raw := range []string{"", "{broken", `{"scope":"conversation"}`} { + dir := t.TempDir() + if raw != "" { + if err := os.WriteFile(filepath.Join(dir, placeMeta), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + } + if got := ConversationScopeAt(dir); got != WorkScopeProject { + t.Fatalf("unreadable identity %q reads %q", raw, got) + } + } + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, placeMeta), 0o700); err != nil { + t.Fatal(err) + } + if got := ConversationScopeAt(dir); got != WorkScopeProject { + t.Fatalf("failed metadata read = %q", got) + } + if got := ConversationScopeAt(""); got != WorkScopeProject { + t.Fatalf("no folder = %q", got) + } +} + +// L-FIX9-cross A4 +func TestConversationScopeSurvivesReloadAndLaterMetadataStamps(t *testing.T) { + agent, dir := scopeRig(t) + stale := agent.metaSnapshot() + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + place := t.TempDir() + for _, stamp := range []struct { + name string + write func() + }{ + {"effort", func() { + if !agent.SetConversationEffort("high") { + t.Fatal("effort was refused") + } + }}, + {"title", func() { agent.stampTitle("Parser repairs") }}, + {"spend", func() { agent.writeSpendSnapshot(dir, stale, .25, 140) }}, + {"places", func() { + agent.mu.Lock() + agent.places = []PlaceRef{{Path: place, Arrival: PlaceSaid}} + agent.mu.Unlock() + agent.stampPlaces() + }}, + {"trees", agent.stampTrees}, + {"model", agent.stampMeta}, + {"approval", func() { + agent.config.ApprovalGate = &fakeApprovalGate{standing: PostureAsk} + if err := agent.SetApprovalPosture(PostureAllow); err != nil { + t.Fatal(err) + } + }}, + {"user", func() { + agent.mu.Lock() + agent.stampUserLocked("Keep the parser repair") + agent.mu.Unlock() + }}, + } { + stamp.write() + agent.SettleWrites() + meta, err := LoadMeta(dir) + if err != nil || meta.Scope != WorkScopeConversation || ConversationScopeAt(dir) != WorkScopeConversation { + t.Fatalf("%s lost scope: %+v, %v", stamp.name, meta, err) + } + } + meta, err := LoadMeta(dir) + if err != nil || meta.Effort != "high" || meta.Title != "Parser repairs" || meta.SpentUSD != .25 || meta.Approval != PostureAllow || len(meta.Places) != 1 || meta.Places[0].Path != place { + t.Fatalf("the later stamps did not land: %+v, %v", meta, err) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + reopened, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = agent.config.Workspace + c.Place = agent.config.Place + c.SessionFile = c.Place.Transcript() + }) + reopened.refreshElsewhere(context.Background()) + if strings.Contains(volatileNote(reopened), "") || ConversationScopeAt(dir) != WorkScopeConversation { + t.Fatal("a reopened conversation forgot its scope") + } +} + +// L-FIX9-cross A4 +func TestConversationScopeSurvivesStaleWholeMetadataSaves(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + stale, err := LoadMeta(dir) + if err != nil || stale.Scope != "" { + t.Fatalf("initial metadata = %+v, %v", stale, err) + } + stale.Title = "Parser repairs" + for _, word := range []string{WorkScopeConversation, WorkScopeProject} { + if changed, err := SetConversationScope(dir, word); err != nil || !changed { + t.Fatalf("set %q = %v, %v", word, changed, err) + } + if err := SaveMeta(dir, stale); err != nil { + t.Fatal(err) + } + meta, err := LoadMeta(dir) + if err != nil || meta.Scope != word || ConversationScopeAt(dir) != word || meta.Title != stale.Title { + t.Fatalf("stale save after %q = %+v, %v", word, meta, err) + } + } +} + +// L-FIX9-cross A2 +func TestConversationScopeKeepsItsLiveGraphWhileFilteringTheMergedIndex(t *testing.T) { + agent, _, id := runningStubbedNode(t, "Keep my live task") + dir := filepath.Join(t.TempDir(), "mine") + agent.mu.Lock() + agent.config.Place = Place{Dir: dir, Workspace: agent.config.Workspace} + agent.mu.Unlock() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + number := strconv.FormatUint(id, 10) + appendTaskIndex(agent.config.taskIndexFile(), + deltaLandedRow(number, "theirs", "An unrelated newer task", time.Now())) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + text, isError := runTool(t, agent, "tasks", `{}`) + if isError || !strings.Contains(text, "Keep my live task") || strings.Contains(text, "An unrelated newer task") { + t.Fatalf("merged listing = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"id":`+number+`}`) + if isError || !strings.Contains(text, "Keep my live task") || strings.Contains(text, "An unrelated newer task") { + t.Fatalf("merged read = %s (error %v)", text, isError) + } + if rows := agent.TaskIndex(); len(rows) != 2 { + t.Fatalf("the person's merged index was narrowed: %+v", rows) + } +} + +// L-FIX9-cross A2 +func TestConversationScopeLeavesATaskNodesChildrenVisible(t *testing.T) { + owner, dir := scopeRig(t) + graph := stubbedGraph(owner, func(*TaskNode) {}) + parent := graph.reserve() + graph.admit(parent, taskSpec{title: "Parent task", named: true, brief: "b", acceptance: "a"}) + child := graph.reserve() + graph.admit(child, taskSpec{title: "Its own piece", named: true, brief: "b", acceptance: "a", parent: parent}) + worker, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = owner.config.Place + c.InTask, c.taskID, c.tasker = true, parent, graph + }) + for _, scope := range []string{WorkScopeProject, WorkScopeConversation} { + if _, err := SetConversationScope(dir, scope); err != nil { + t.Fatal(err) + } + text, isError := runTool(t, worker, "tasks", `{"scope":"everywhere"}`) + if isError || !strings.Contains(text, "Its own piece") || strings.Contains(text, "Parent task") || strings.Contains(text, "Fix the nil-map crash") || strings.Contains(text, "Separate project work") { + t.Fatalf("node under %q = %s (error %v)", scope, text, isError) + } + worker.refreshElsewhere(context.Background()) + if worker.elsewhereText != "" { + t.Fatalf("node under %q received other work: %s", scope, worker.elsewhereText) + } + } +} + +// L-FIX9-cross A6 +func TestConversationScopeStaysInMetaBesideSealedConversationSettings(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, cellStateDir), 0o700); err != nil { + t.Fatal(err) + } + if err := SaveMeta(dir, Meta{ID: "mine", Model: "test/model", Effort: "high", Approval: PostureAsk}); err != nil { + t.Fatal(err) + } + path := layoutOf(dir).metaTruth(dir) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, scope := range []string{WorkScopeConversation, WorkScopeProject} { + if _, err := SetConversationScope(dir, scope); err != nil { + t.Fatal(err) + } + if got := ConversationScopeAt(dir); got != scope { + t.Fatalf("stored scope = %q, want %q", got, scope) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("scope changed sealed settings: %q, %v", after, err) + } + meta, err := LoadMeta(dir) + if err != nil || meta.Model != "test/model" || meta.Effort != "high" || meta.Approval != PostureAsk { + t.Fatalf("scope changed conversation settings: %+v, %v", meta, err) + } + } +} diff --git a/internal/session/taskdelta.go b/internal/session/taskdelta.go index 3e9463177e..4944b166b7 100644 --- a/internal/session/taskdelta.go +++ b/internal/session/taskdelta.go @@ -591,6 +591,18 @@ func (a *Agent) refreshElsewhere(ctx context.Context) { mine := a.sessionID() a.mu.Unlock() + // A conversation scoped to its own work drops an earlier block too, so the + // next reading follows the stored choice without advancing the told stamp. + if a.ownWorkOnly() { + a.mu.Lock() + if !a.closed { + a.elsewhereTold = nil + a.elsewhereText = "" + } + a.mu.Unlock() + return + } + dir := strings.TrimSpace(a.config.Place.Dir) now := time.Now() since := LastTold(dir) diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index fb0820c213..34baf90ea5 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -375,7 +375,7 @@ func (a *Agent) taskSearchText(query string, limit int, scope string) string { // THIS CONVERSATION'S OWN RUNS ARE LEFT TO THEIR STORE'S LISTING, which the // tool puts above this one ([Agent.withoutOwnRunRows]). out := taskRowsTextLimit(a.withoutOwnRunRows(a.taskRows()), query, limit) - if !a.tellsElsewhere() { + if !a.tellsElsewhere() || a.ownWorkOnly() { return a.taskConversationHint(out) } now := time.Now() @@ -1079,8 +1079,8 @@ func taskWhereWord(entry TaskIndexEntry) string { return taskWhereNowhere } -// taskRows is what this tool answers from: the project's whole index in a -// conversation, and in a node ONLY THE CHILDREN IT HANDED OUT ITSELF. +// taskRows is what this tool answers from: the project's index or only this +// conversation's own rows, and in a node ONLY THE CHILDREN IT HANDED OUT ITSELF. // // The scope is applied here, at the one door, rather than in each of the three // ops: a search, a read, a steer and a resolve are four things to do with a @@ -1088,7 +1088,20 @@ func taskWhereWord(entry TaskIndexEntry) string { func (a *Agent) taskRows() []TaskIndexEntry { parent := a.config.taskID if parent == 0 { - return a.TaskIndex() + rows := a.TaskIndex() + if a.config.InTask || !a.ownWorkOnly() { + return rows + } + a.mu.Lock() + session := a.sessionID() + a.mu.Unlock() + own := make([]TaskIndexEntry, 0, len(rows)) + for _, row := range rows { + if row.SessionID == session { + own = append(own, row) + } + } + return own } a.mu.Lock() session := a.sessionID() @@ -1118,9 +1131,9 @@ func (a *Agent) taskRows() []TaskIndexEntry { // left to settle it in" and pointed at a stranger's worktree — over a node this // session was holding all along. // -// A NAME STILL MEANS THE NEWEST. A slug is derived from a title and carries no -// conversation in it, so "the nil-map task" said out loud means the last one -// whoever ran it, exactly as it always did. +// A NAME STILL MEANS THE NEWEST VISIBLE ROW. A slug is derived from a title +// and carries no conversation in it, so the scope of the rows decides whose +// "nil-map task" a name can reach. // // AND A NODE ASKS FOR NOTHING WIDER THAN ITS OWN FAMILY. Inside a node // [Agent.taskRows] is already only the pieces it handed out, and reaching into