From 6c8ea373409eee84e7713d7cfb5b15580dedc840 Mon Sep 17 00:00:00 2001 From: "liuxinyang.lxy" Date: Wed, 8 Jul 2026 11:48:37 +0800 Subject: [PATCH] feat: add example provider, StaticCatalog scaffolding and conformance harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StaticCatalog (internal/agent/catalog.go): a framework helper carrying the catalog-provider boilerplate — enumeration, per-agent card lookup and typed unknown-id errors — so catalog providers do not reinvent it. - agenttest.RunConformance: a one-call conformance suite pinning the SPI's implicit contracts (registration metadata, zero-Deps factory, card single source, enumeration stability). - example provider (internal/agent/example): an offline in-memory reference provider (echo / reporter, deliberately different capability matrices) that doubles as the provider-onboarding template and the command tree's zero-network demo backend. --- internal/agent/agenttest/agenttest.go | 156 ++++++++++ internal/agent/catalog.go | 91 ++++++ internal/agent/catalog_test.go | 117 ++++++++ internal/agent/example/example.go | 391 +++++++++++++++++++++++++ internal/agent/example/example_test.go | 321 ++++++++++++++++++++ internal/agent/example/state.go | 324 ++++++++++++++++++++ 6 files changed, 1400 insertions(+) create mode 100644 internal/agent/agenttest/agenttest.go create mode 100644 internal/agent/catalog.go create mode 100644 internal/agent/catalog_test.go create mode 100644 internal/agent/example/example.go create mode 100644 internal/agent/example/example_test.go create mode 100644 internal/agent/example/state.go diff --git a/internal/agent/agenttest/agenttest.go b/internal/agent/agenttest/agenttest.go new file mode 100644 index 000000000..4195954d8 --- /dev/null +++ b/internal/agent/agenttest/agenttest.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package agenttest provides provider conformance tests: a new integrator calls +// RunConformance in its own test to lock down registration metadata, the +// zero-value Deps contract, Card single-sourcing, and other implicit contracts. +// All assertions run offline (zero-value Deps, no API calls). +package agenttest + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/agent" +) + +// RunConformance runs the full set of conformance assertions against a +// registered scheme. sampleAgentID must be a valid agent id for which the +// provider can produce a Card (catalog-type: an id from the catalog; +// instance-type: any non-empty id). +func RunConformance(t *testing.T, scheme, sampleAgentID string) { + t.Helper() + info, ok := agent.Info(scheme) + if !ok { + t.Fatalf("conformance: scheme %q not registered (the provider package must be imported to trigger init registration)", scheme) + } + + t.Run("metadata", func(t *testing.T) { + if info.Label == "" { + t.Error("conformance: ProviderInfo.Label must not be empty") + } + if info.AgentIDSource == "" { + t.Error("conformance: ProviderInfo.AgentIDSource must not be empty") + } + if info.Kind != agent.KindCatalog && info.Kind != agent.KindInstance { + t.Errorf("conformance: Kind should be %q|%q, got %q", agent.KindCatalog, agent.KindInstance, info.Kind) + } + if !strings.HasPrefix(info.AgentRefFormat, scheme+":") { + t.Errorf("conformance: AgentRefFormat should start with %q, got %q", scheme+":", info.AgentRefFormat) + } + if len(info.Identities) == 0 { + t.Error("conformance: Identities must not be empty") + } + for i, id := range info.Identities { + if id.Type != agent.IdentityUser && id.Type != agent.IdentityBot { + t.Errorf("conformance: Identities[%d].Type should be user|bot, got %q", i, id.Type) + } + } + seen := make(map[string]bool, len(info.RequiredScopes)) + for _, s := range info.RequiredScopes { + if seen[s] { + t.Errorf("conformance: RequiredScopes contains duplicate %q", s) + } + seen[s] = true + } + }) + + t.Run("factory", func(t *testing.T) { + p, err := info.Factory(agent.Deps{}, sampleAgentID) + if err != nil { + t.Fatalf("conformance: Factory must accept zero-value Deps (expected nil error), got %v", err) + } + if p == nil { + t.Fatal("conformance: Factory must not return a nil provider") + } + }) + + t.Run("card", func(t *testing.T) { + newCard := func() *agent.AgentCard { + t.Helper() + p, err := info.Factory(agent.Deps{}, sampleAgentID) + if err != nil { + t.Fatalf("conformance: Factory(zero-value Deps) returned error: %v", err) + } + card, err := p.Card(context.Background()) + if err != nil { + t.Fatalf("conformance: Card should be available offline (expected nil error), got %v", err) + } + if card == nil { + t.Fatal("conformance: Card must not return nil") + } + return card + } + card := newCard() + if card.Provider != scheme { + t.Errorf("conformance: Card.Provider should be %q, got %q", scheme, card.Provider) + } + if card.AgentID != sampleAgentID { + t.Errorf("conformance: Card.AgentID should echo the constructor input %q, got %q", sampleAgentID, card.AgentID) + } + if card.ProviderLabel != info.Label { + t.Errorf("conformance: Card.ProviderLabel should equal the registered Label %q, got %q", info.Label, card.ProviderLabel) + } + if !reflect.DeepEqual(card.Identity, info.Identities) { + t.Errorf("conformance: Card.Identity should match the registered Identities (single source), expected %+v got %+v", info.Identities, card.Identity) + } + if card.AgentIDSource != info.AgentIDSource { + t.Errorf("conformance: Card.AgentIDSource should equal the registered value %q, got %q", info.AgentIDSource, card.AgentIDSource) + } + if card.Parameters == nil { + t.Error("conformance: Card.Parameters must not be nil (always emitted, empty is [])") + } + // Single-sourcing: two independent instances each produce a Card, and the + // results must DeepEqual (no hidden instance state). + if card2 := newCard(); !reflect.DeepEqual(card, card2) { + t.Errorf("conformance: Cards from two instances should DeepEqual (single source), got\n%+v\nvs\n%+v", card, card2) + } + }) + + if info.Kind == agent.KindCatalog { + t.Run("discovery", func(t *testing.T) { + p, err := info.Factory(agent.Deps{}, sampleAgentID) + if err != nil { + t.Fatalf("conformance: Factory(zero-value Deps) returned error: %v", err) + } + d, ok := p.(agent.Discoverer) + if !ok { + t.Fatal("conformance: catalog-type provider instances must implement Discoverer") + } + list, err := d.ListAgents(context.Background()) + if err != nil { + t.Fatalf("conformance: catalog-type ListAgents should be available offline (expected nil error), got %v", err) + } + wantRef := scheme + ":" + sampleAgentID + found := false + for i, a := range list { + r, err := agent.ParseRef(a.AgentRef) + if err != nil { + t.Errorf("conformance: ListAgents[%d].AgentRef %q should be parseable by agent.ParseRef: %v", i, a.AgentRef, err) + continue + } + if r.Scheme != scheme { + t.Errorf("conformance: ListAgents[%d].AgentRef %q scheme should be %q, got %q", i, a.AgentRef, scheme, r.Scheme) + } + if a.Name == "" { + t.Errorf("conformance: ListAgents[%d] (%s) Name must not be empty", i, a.AgentRef) + } + if a.AgentRef == wantRef { + found = true + } + } + if !found { + t.Errorf("conformance: sampleAgentID should appear in the enumeration (expected to contain %q), got %+v", wantRef, list) + } + list2, err := d.ListAgents(context.Background()) + if err != nil { + t.Fatalf("conformance: second ListAgents returned error: %v", err) + } + if !reflect.DeepEqual(list, list2) { + t.Errorf("conformance: two consecutive ListAgents results should DeepEqual (stable enumeration), got\n%+v\nvs\n%+v", list, list2) + } + }) + } +} diff --git a/internal/agent/catalog.go b/internal/agent/catalog.go new file mode 100644 index 000000000..0eff6954a --- /dev/null +++ b/internal/agent/catalog.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package agent + +import ( + "context" + "sort" + + "github.com/larksuite/cli/errs" +) + +// CatalogEntry is the provider-neutral description of one predefined agent in a +// catalog-type provider. It holds only fields the framework can consume +// (enumeration / Card synthesis); a provider's private business fields (such as +// the execution backend it points to) are maintained alongside in the +// integrator's own package and do not enter framework types. +type CatalogEntry struct { + ID string + Name string + Description string + Capabilities Capabilities +} + +// StaticCatalog carries the common boilerplate of a catalog-type provider: +// catalog enumeration (Discoverer semantics), per-agent rich Card (NewCard fills +// in registration-time fields, the entry adds Name/Description/Capabilities), and +// a typed validation error for unknown ids. Business differences (such as the +// execution backend) are composed by the provider itself on the outer layer. +// It is read-only after construction and safe for concurrent use. +type StaticCatalog struct { + scheme string + entries map[string]CatalogEntry +} + +// NewStaticCatalog constructs a static catalog. A duplicate entry ID is an +// integrator coding error and panics fail-fast (aligned with the Register convention). +func NewStaticCatalog(scheme string, entries []CatalogEntry) *StaticCatalog { + m := make(map[string]CatalogEntry, len(entries)) + for _, e := range entries { + if _, dup := m[e.ID]; dup { + panic("agent: StaticCatalog duplicate entry ID for scheme " + scheme + ": " + e.ID) + } + m[e.ID] = e + } + return &StaticCatalog{scheme: scheme, entries: m} +} + +// ListAgents enumerates the catalog (Discoverer semantics), sorted by AgentRef +// to guarantee stable output. +func (c *StaticCatalog) ListAgents(ctx context.Context) ([]AgentSummary, error) { + out := make([]AgentSummary, 0, len(c.entries)) + for _, e := range c.entries { + out = append(out, AgentSummary{ + AgentRef: c.scheme + ":" + e.ID, + Name: e.Name, + Description: e.Description, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].AgentRef < out[j].AgentRef }) + return out, nil +} + +// Card synthesizes the agent's rich card: NewCard fills in registration-time +// fields (Provider/Label/Identity/AgentIDSource/empty Parameters), and the entry +// adds the per-agent Name/Description/Capabilities. An unknown id returns the +// typed error from Lookup. +func (c *StaticCatalog) Card(agentID string) (*AgentCard, error) { + e, err := c.Lookup(agentID) + if err != nil { + return nil, err + } + card := NewCard(c.scheme, agentID) + card.Name = e.Name + card.Description = e.Description + card.Capabilities = e.Capabilities + return card, nil +} + +// Lookup fetches a catalog entry by id. An unknown id returns a typed +// validation/invalid_argument error (exit 2) whose hint points to +// `agent list `, so a provider need not define its own unknown-agent error. +func (c *StaticCatalog) Lookup(agentID string) (CatalogEntry, error) { + e, ok := c.entries[agentID] + if !ok { + return CatalogEntry{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "未知的 %s agent '%s'", c.scheme, agentID). + WithHint("运行 lark-cli agent list %s 查看可用 agent", c.scheme) + } + return e, nil +} diff --git a/internal/agent/catalog_test.go b/internal/agent/catalog_test.go new file mode 100644 index 000000000..9677b8e2b --- /dev/null +++ b/internal/agent/catalog_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package agent + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/larksuite/cli/errs" +) + +// testCatalogEntries is declared out of order to verify ListAgents sorting stability. +func testCatalogEntries() []CatalogEntry { + return []CatalogEntry{ + {ID: "zeta", Name: "Zeta 助手", Description: "z desc", + Capabilities: Capabilities{TaskGet: true}}, + {ID: "alpha", Name: "Alpha 助手", Description: "a desc", + Capabilities: Capabilities{TaskGet: true, MultiTurn: true}}, + } +} + +// TestStaticCatalogListAgentsSorted asserts the enumeration is sorted by AgentRef +// and that two consecutive results are DeepEqual (stable sort, the same contract +// asserted by agenttest discovery). +func TestStaticCatalogListAgentsSorted(t *testing.T) { + c := NewStaticCatalog("cattest", testCatalogEntries()) + got, err := c.ListAgents(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []AgentSummary{ + {AgentRef: "cattest:alpha", Name: "Alpha 助手", Description: "a desc"}, + {AgentRef: "cattest:zeta", Name: "Zeta 助手", Description: "z desc"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ListAgents should be sorted by AgentRef, want %+v got %+v", want, got) + } + got2, err := c.ListAgents(context.Background()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, got2) { + t.Fatalf("two consecutive ListAgents calls should be DeepEqual, got\n%+v\nvs\n%+v", got, got2) + } +} + +// TestStaticCatalogCard asserts Card = NewCard pre-filling registration-time fields +// plus the entry filling in Name/Description/Capabilities. +func TestStaticCatalogCard(t *testing.T) { + swapRegistry(t, map[string]ProviderInfo{}) + Register("cattest", testInfo("cattest", + func(Deps, string) (Provider, error) { return &stubProvider{}, nil })) + info, _ := Info("cattest") + + c := NewStaticCatalog("cattest", testCatalogEntries()) + card, err := c.Card("alpha") + if err != nil { + t.Fatal(err) + } + if card.Provider != "cattest" || card.AgentID != "alpha" { + t.Fatalf("provider/agent_id: %+v", card) + } + if card.ProviderLabel != info.Label || card.AgentIDSource != info.AgentIDSource { + t.Fatalf("registration-time fields should be pre-filled by NewCard: %+v", card) + } + if !reflect.DeepEqual(card.Identity, info.Identities) { + t.Fatalf("Identity should match the registered value: %+v", card.Identity) + } + if card.Parameters == nil { + t.Fatal("Parameters must not be nil (always emitted, empty is [])") + } + if card.Name != "Alpha 助手" || card.Description != "a desc" { + t.Fatalf("entry should fill in Name/Description: %+v", card) + } + wantCaps := Capabilities{TaskGet: true, MultiTurn: true} + if card.Capabilities != wantCaps { + t.Fatalf("entry should fill in Capabilities, want %+v got %+v", wantCaps, card.Capabilities) + } +} + +// TestStaticCatalogUnknownID asserts Lookup / Card return a typed +// validation/invalid_argument error for an unknown id (exit 2 rather than +// internal/exit 5), with the hint pointing to `agent list `. +func TestStaticCatalogUnknownID(t *testing.T) { + c := NewStaticCatalog("cattest", testCatalogEntries()) + _, err := c.Lookup("nonexistent") + if err == nil { + t.Fatal("unknown id should return an error") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("unknown id should be an *errs.ValidationError, got %T: %v", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype should be invalid_argument, got %q", ve.Subtype) + } + if want := "未知的 cattest agent 'nonexistent'"; ve.Message != want { + t.Fatalf("message should be %q, got %q", want, ve.Message) + } + if want := "运行 lark-cli agent list cattest 查看可用 agent"; ve.Hint != want { + t.Fatalf("hint should be %q, got %q", want, ve.Hint) + } + // Card goes through the same Lookup path. + if _, err := c.Card("nonexistent"); !errors.As(err, &ve) { + t.Fatalf("Card with an unknown id should return the same typed error, got %v", err) + } +} + +// TestStaticCatalogDuplicateIDPanic asserts a duplicate entry ID triggers a +// fail-fast panic (aligned with the Register convention). +func TestStaticCatalogDuplicateIDPanic(t *testing.T) { + entries := []CatalogEntry{{ID: "a"}, {ID: "a"}} + mustPanic(t, "duplicate entry ID", func() { NewStaticCatalog("cattest", entries) }) +} diff --git a/internal/agent/example/example.go b/internal/agent/example/example.go new file mode 100644 index 000000000..2fa171493 --- /dev/null +++ b/internal/agent/example/example.go @@ -0,0 +1,391 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package example is the in-repo agent provider onboarding template and offline +// demo backend: a hypothetical example business domain whose data / calls are +// entirely in-memory mocks, with zero network. It has three roles: +// +// 1. A copy-start point for new integrators — copy the whole package and rename +// it; every key decision point carries a teaching comment from the +// "integrator's perspective" (how to fill registration fields, why typed +// errors are required, how to make capability-matrix trade-offs); +// 2. The command tree's offline demo backend — the full agent +// list/card/send/task/context chain runs for real without any platform +// configuration; +// 3. A stable mock scheme for cmd-layer tests. +// +// Minimal checklist for onboarding a new provider (each item is demonstrated in +// this package): +// - register metadata via agent.Register in init() (see the per-field comments below); +// - implement the 9 methods of agent.Provider (internal/agent/provider.go); +// - a catalog type (KindCatalog) must implement agent.Discoverer (asserted at registration time); +// - add a blank import in cmd/agent/agent.go to trigger init registration; +// - run agenttest.RunConformance in tests to lock down implicit contracts. +package example + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/agent" +) + +// scheme is this provider's ref prefix (example:). It is globally +// unique; duplicate registration panics during init (aligned with the +// sql.Register convention, fail-fast to expose onboarding errors). +const scheme = "example" + +// catalog is the full agent set known at registration time. The catalog +// boilerplate (enumeration / per-agent rich Card / typed error for unknown ids) +// is entirely handled by the framework's StaticCatalog; the integrator only +// declares the data. +// +// Teaching focus — the capability matrix must be "declared honestly": +// - The two agents' matrices differ on purpose: echo is the minimal set (no +// artifact, no file input, not cancelable), reporter has everything on. The +// AI decides its next command from the card, so whatever is declared must be +// delivered. +// - The asymmetry is directional: declaring something you "can't do" as true +// leads the AI into a dead end (e.g. declaring input_required true while the +// task never stops in that state, so the AI waits forever for a state that +// never appears); conversely, declaring an "unused" capability as true when +// the state never appears (like reporter's input_required) merely means the +// AI never uses it and does not go wrong. Only the former warrants being +// conservatively false. +// - task_cancel being true for one and false for the other is not arbitrary: +// echo=false demonstrates the command layer's card gating +// (cmd/agent/task.go intercepts and reports unsupported_capability before +// sending any request), while reporter=true demonstrates actually reaching +// the provider's CancelTask. +var catalog = agent.NewStaticCatalog(scheme, []agent.CatalogEntry{ + { + ID: "echo", + Name: "复读机", + Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。", + Capabilities: agent.Capabilities{ + TaskGet: true, + TaskList: true, + // echo is not cancelable: once the command layer reads false it + // rejects task cancel outright, so the provider is never even called + // (gate-before-network). + TaskCancel: false, + // The mock task completes instantly and never stops in + // input_required; echo honestly declares false per the minimal set. + InputRequired: false, + // echo takes no files and produces no artifact — Send returns + // agent.ErrUnsupported for --file, consistent with the false here + // (declaration and behavior single-sourced). + FileInput: false, + ArtifactDownload: false, + MultiTurn: true, + }, + }, + { + ID: "reporter", + Name: "报表生成器", + Description: "对任意请求产出一份内联 CSV 报表 artifact,示范 artifact 下载与任务取消链路。", + Capabilities: agent.Capabilities{ + TaskGet: true, + TaskList: true, + TaskCancel: true, + InputRequired: true, + FileInput: true, + ArtifactDownload: true, + MultiTurn: true, + }, + }, +}) + +func init() { + // Registration contract (internal/agent/registry.go): everything except + // RequiredScopes is required; missing / invalid values panic. At + // registration time it also constructs a Provider once via a zero-value Deps + // probe — so Factory must accept zero-value Deps and have no side effects + // during construction (no network, no disk). + agent.Register(scheme, agent.ProviderInfo{ + Factory: newExampleProvider, + // Label: the user-facing provider name (the LABEL column in agent list). + Label: "Example 演示 agent(内存 mock,零网络)", + // AgentRefFormat: the written format of agent_ref, must start with ":" (validated at registration). + AgentRefFormat: "example:", + // AgentIDSource: tells the user / AI where to get the agent_id — key + // information for AI-guided onboarding, referenced by the unknown-id hint + // and the not-discoverable list hint. + AgentIDSource: "运行 lark-cli agent list example 查看内置演示 agent 及其 agent_ref(无需任何平台配置)", + // Kind: catalog type. Registration asserts the instance implements + // Discoverer, so `agent list example` can enumerate (an instance-type + // provider that does not support enumeration returns unsupported_capability). + Kind: agent.KindCatalog, + // RequiredScopes: the full set of scopes this provider's real API calls + // need. example has zero network and calls no OAPI, so it is empty — + // scope preflight (cmd/agent/preflight.go) always passes for the empty + // set. A real provider must list every scope used by any verb (preflight + // is all-or-nothing). + RequiredScopes: nil, + // Identities: supported calling identities and their preconditions. The + // mock treats user/bot alike; if a real provider has a precondition for + // some identity (e.g. a bot needs channel whitelisting), put it in + // Precondition and the card passes it through to the AI verbatim. + Identities: []agent.IdentitySpec{ + {Type: agent.IdentityUser}, + {Type: agent.IdentityBot}, + }, + }) +} + +// exampleProvider addresses one agent in the catalog. agentID may be empty — the +// Discoverer path (agent list example) constructs an instance without an id only +// to enumerate the catalog. +type exampleProvider struct { + deps agent.Deps + agentID string +} + +// newExampleProvider is the registered Factory. Teaching point: it does pure +// assignment only. +// - It does not validate agentID: an unknown id is rejected by catalog.Lookup +// inside "the verbs that actually use the id" (the empty-id enumeration +// instance must construct successfully); +// - It does not touch deps: the mock has no use for Client/As; a real provider +// uses deps.Client to send requests and deps.As to pick the identity in its +// methods, but construction must still have no side effects (the zero-value +// Deps probe contract). +func newExampleProvider(deps agent.Deps, agentID string) (agent.Provider, error) { + return &exampleProvider{deps: deps, agentID: agentID}, nil +} + +// Card returns the agent's capability card. Synthesis is fully offline: NewCard +// fills in registration-time fields (Provider/Label/Identity/AgentIDSource/empty +// Parameters), and the catalog entry adds Name/Description/Capabilities. An +// unknown id returns a typed validation error from StaticCatalog.Lookup +// (invalid_argument/exit 2, hint pointing to agent list example) — the +// integrator need not build its own unknown-agent error. +func (p *exampleProvider) Card(ctx context.Context) (*agent.AgentCard, error) { + return catalog.Card(p.agentID) +} + +// ListAgents enumerates the catalog (Discoverer): `agent list example` goes here. +func (p *exampleProvider) ListAgents(ctx context.Context) ([]agent.AgentSummary, error) { + return catalog.ListAgents(ctx) +} + +// Send sends one message: the first turn generates a context_id to start a new +// conversation, and --context-id continues within the same conversation. The +// mock task has no async execution body, so Send immediately returns in the +// completed terminal state — the command layer's meta.next therefore directly +// gives the terminal-state suggestion "view task detail and artifacts" rather +// than a polling command. +// +// Teaching point (IsTerminal): IsTerminal is filled in here for convenience, but +// leaving it out would be fine — the command layer's normalizeTask always +// re-derives this field from State (single source), so a provider filling it in +// wrong does not affect the watch exit code. The integrator need not worry about it. +func (p *exampleProvider) Send(ctx context.Context, in agent.SendInput) (*agent.AgentTask, error) { + entry, err := catalog.Lookup(p.agentID) + if err != nil { + return nil, err + } + // Capability declaration and behavior are single-sourced: if the card says it + // takes no files, the behavior must reject them. The command layer does not + // card-gate --file (only cancel is gated), so the provider returns the + // agent.ErrUnsupported sentinel, and the command layer's convertUnsupported + // turns it into a typed unsupported_capability (exit 2) — the full + // sentinel→typed chain. + if len(in.Files) > 0 && !entry.Capabilities.FileInput { + return nil, fmt.Errorf("example:%s 不接收文件输入: %w", p.agentID, agent.ErrUnsupported) + } + // The mock task is instantly terminal, so there is no "feed input to a running + // task" scenario. Continuing via --task-id returns failed_precondition: the + // request itself is valid but the target resource's state does not satisfy it + // — reading this subtype, the AI knows to "try a different way" (start a new + // task) rather than retry as-is. + if in.TaskID != "" { + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "example 的任务发出即完成(终态),无法向已有任务续发"). + WithParam("--task-id"). + WithHint("去掉 --task-id,用 --context-id 在同一会话起新一轮任务") + } + + ctxID := in.ContextID + if ctxID == "" { + // First turn: generate a context_id (the anchor for the multi-turn + // context; later sends use it to continue the conversation). + ctxID, err = store.createContext(p.agentID, truncateTitle(in.Text)) + if err != nil { + return nil, err + } + } + + // createTask validates context ownership while holding the lock (an unknown / + // cross-agent context id is rejected inside with a typed validation error), + // computes the round, and inserts atomically; the build callback only + // assembles the task body according to the round. + task, err := store.createTask(p.agentID, ctxID, func(round int) agent.AgentTask { + var reply string + switch entry.ID { + case "echo": + // Echo the input; from round 2 on, add a round marker to prove + // across commands that context memory really works. + reply = in.Text + if round > 1 { + reply = fmt.Sprintf("%s(第 %d 轮)", in.Text, round) + } + default: // reporter + reply = "报表已生成:quarterly_report.csv(见 artifacts,用 task get --artifact -o 下载)" + if n := len(in.Files); n > 0 { + reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply) + } + } + t := agent.AgentTask{ + TaskID: newID("task"), + ContextID: ctxID, + State: agent.StateCompleted, + IsTerminal: true, + Messages: []agent.Message{ + {Role: "user", Parts: []agent.Part{{Type: "text", Text: in.Text}}}, + {Role: "agent", Parts: []agent.Part{{Type: "text", Text: reply}}}, + }, + } + if entry.ID == "reporter" { + // The artifact exposes only fields the provider can truly deliver + // (the contract.go rule: do not create empty shell fields that cannot + // be filled): the GetTask stage gives ID + Kind (a coarse-grained type + // hint), while the file name / mime are exposed at the + // DownloadArtifact stage as suggested_name. + t.Artifacts = []agent.Artifact{{ID: newID("art"), Kind: "text"}} + } + return t + }) + if err != nil { + return nil, err + } + return &task, nil +} + +// GetTask queries a single task's state and artifacts (reads the in-memory state machine). +func (p *exampleProvider) GetTask(ctx context.Context, taskID string) (*agent.AgentTask, error) { + if _, err := catalog.Lookup(p.agentID); err != nil { + return nil, err + } + task, err := store.getTask(p.agentID, taskID) + if err != nil { + return nil, err + } + return &task, nil +} + +// ListTasks lists tasks, optionally filtered by contextID (empty string means no filter). +func (p *exampleProvider) ListTasks(ctx context.Context, contextID string) ([]agent.TaskSummary, error) { + if _, err := catalog.Lookup(p.agentID); err != nil { + return nil, err + } + return store.listTasks(p.agentID, contextID), nil +} + +// CancelTask cancels a task. Both paths are demonstrated here: +// - echo (task_cancel=false): returns the agent.ErrUnsupported sentinel. Via +// the CLI this is unreachable (cmd/agent/task.go's card gating comes first), +// but tests calling the SPI directly / future callers that bypass the gate +// still get the correct semantics — belt-and-braces. +// - reporter (task_cancel=true): actually dispatched. But the mock task is +// completed the moment it is sent, so canceling a terminal task returns a +// failed_precondition typed error (state not satisfied, exit 2) rather than +// pretending success — honest error semantics matter as much as honest +// capability declaration. +func (p *exampleProvider) CancelTask(ctx context.Context, taskID string) error { + entry, err := catalog.Lookup(p.agentID) + if err != nil { + return err + } + if !entry.Capabilities.TaskCancel { + return agent.ErrUnsupported + } + task, err := store.getTask(p.agentID, taskID) + if err != nil { + return err + } + if task.State.IsTerminal() { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "任务 '%s' 已处于终态 %s,无法取消", taskID, task.State). + WithHint("终态任务不可取消;用 lark-cli agent task get example:%s %s 查看结果", p.agentID, taskID) + } + return store.setTaskState(taskID, agent.StateCanceled) +} + +// ListContexts lists multi-turn contexts. +func (p *exampleProvider) ListContexts(ctx context.Context) ([]agent.ContextSummary, error) { + if _, err := catalog.Lookup(p.agentID); err != nil { + return nil, err + } + return store.listContexts(p.agentID), nil +} + +// GetContext returns a single context's detail (including its task list). +func (p *exampleProvider) GetContext(ctx context.Context, ctxID string) (*agent.ContextDetail, error) { + if _, err := catalog.Lookup(p.agentID); err != nil { + return nil, err + } + return store.getContext(p.agentID, ctxID) +} + +// DeleteContext deletes a context (a destructive operation; the --yes gate is in the command layer). +func (p *exampleProvider) DeleteContext(ctx context.Context, ctxID string) error { + if _, err := catalog.Lookup(p.agentID); err != nil { + return err + } + return store.deleteContext(p.agentID, ctxID) +} + +// reportCSV is the fixed content of the reporter artifact (inline text, demonstrating a Bytes-type artifact). +const reportCSV = "quarter,revenue,cost,margin\n" + + "2026Q1,1250,830,0.336\n" + + "2026Q2,1410,905,0.358\n" + +// DownloadArtifact fetches artifact data. example uses the inline Bytes type +// (the command layer writes it to disk directly); the URL type (a real +// provider's signed URL) fills the URL field, and SSRF validation plus the +// download are handled uniformly by the command layer. +// +// Teaching point (suggested_name): ArtifactData.Name is the "server-suggested +// file name", echoed back only as a suggested_name for the caller to reference +// when choosing -o — it is untrusted input and must never participate in +// constructing the local save path (the contract.go rule; the save path is +// always determined by -o/SafeOutputPath). +func (p *exampleProvider) DownloadArtifact(ctx context.Context, taskID, artifactID string) (*agent.ArtifactData, error) { + entry, err := catalog.Lookup(p.agentID) + if err != nil { + return nil, err + } + if !entry.Capabilities.ArtifactDownload { + // Keep behavior consistent with the card's artifact_download=false (the sentinel→typed chain). + return nil, fmt.Errorf("example:%s 不产出可下载产物: %w", p.agentID, agent.ErrUnsupported) + } + task, err := store.getTask(p.agentID, taskID) + if err != nil { + return nil, err + } + for _, a := range task.Artifacts { + if a.ID == artifactID { + return &agent.ArtifactData{ + Name: "quarterly_report.csv", + Mime: "text/csv", + Bytes: []byte(reportCSV), + }, nil + } + } + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "任务 '%s' 名下没有产物 '%s'", taskID, artifactID). + WithHint("运行 lark-cli agent task get example:%s %s 查看该任务的 artifacts", p.agentID, taskID) +} + +// truncateTitle takes the first few characters of the message as the +// conversation title (truncated by rune to avoid cutting a character in half). +func truncateTitle(s string) string { + const max = 20 + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} diff --git a/internal/agent/example/example_test.go b/internal/agent/example/example_test.go new file mode 100644 index 000000000..8954290d3 --- /dev/null +++ b/internal/agent/example/example_test.go @@ -0,0 +1,321 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package example + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/agent" + "github.com/larksuite/cli/internal/agent/agenttest" +) + +// swapStore replaces the package-level store with an isolated instance pointing at +// t.TempDir, so tests do not pollute each other or the local demo snapshot. +func swapStore(t *testing.T) { + t.Helper() + old := store + store = newMemoryStore(filepath.Join(t.TempDir(), "state.json")) + t.Cleanup(func() { store = old }) +} + +// newProvider builds an example provider with zero-value Deps (the mock never needs a Client). +func newProvider(t *testing.T, agentID string) agent.Provider { + t.Helper() + p, err := newExampleProvider(agent.Deps{}, agentID) + if err != nil { + t.Fatalf("newExampleProvider: %v", err) + } + return p +} + +// TestConformance runs the shared conformance suite: locking registration metadata, +// the zero-value Deps contract, the single-source Card, and catalog enumeration (the +// discovery group automatically verifies ListAgents contains example:echo and enumerates stably). +func TestConformance(t *testing.T) { + agenttest.RunConformance(t, scheme, "echo") +} + +// TestConformanceReporter runs it again with reporter, so both catalog entries are locked by the contract. +func TestConformanceReporter(t *testing.T) { + agenttest.RunConformance(t, scheme, "reporter") +} + +// TestCapabilityMatrixDiverges pins the deliberate difference between the two agents' +// capability matrices (the core of the teaching demo: honest capability declaration +// plus task_cancel true for one and false for the other). +func TestCapabilityMatrixDiverges(t *testing.T) { + echoCard, err := newProvider(t, "echo").Card(context.Background()) + if err != nil { + t.Fatal(err) + } + reporterCard, err := newProvider(t, "reporter").Card(context.Background()) + if err != nil { + t.Fatal(err) + } + ec, rc := echoCard.Capabilities, reporterCard.Capabilities + if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel { + t.Errorf("echo should be the minimal capability set (no artifact/file/cancel), got %+v", ec) + } + if !ec.MultiTurn || !ec.TaskGet || !ec.TaskList { + t.Errorf("echo should support multi_turn/task_get/task_list, got %+v", ec) + } + if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.MultiTurn && rc.TaskGet && rc.TaskList) { + t.Errorf("reporter should have everything enabled, got %+v", rc) + } +} + +// TestEchoMultiTurn verifies multi-turn context memory: the first turn echoes the +// original text and generates a context_id, and a follow-up in the same context +// echoes with a turn marker. +func TestEchoMultiTurn(t *testing.T) { + swapStore(t) + p := newProvider(t, "echo") + ctx := context.Background() + + t1, err := p.Send(ctx, agent.SendInput{Text: "hello"}) + if err != nil { + t.Fatalf("first-turn Send: %v", err) + } + if t1.State != agent.StateCompleted { + t.Fatalf("send should be immediately completed, got %s", t1.State) + } + if t1.ContextID == "" || t1.TaskID == "" { + t.Fatalf("first turn should generate context_id/task_id: %+v", t1) + } + if got := agentReply(t, t1); got != "hello" { + t.Fatalf("first-turn echo should be the original text, got %q", got) + } + + t2, err := p.Send(ctx, agent.SendInput{Text: "再来", ContextID: t1.ContextID}) + if err != nil { + t.Fatalf("follow-up Send: %v", err) + } + if t2.ContextID != t1.ContextID { + t.Fatalf("follow-up should stay in the same context: %q vs %q", t2.ContextID, t1.ContextID) + } + if got := agentReply(t, t2); got != "再来(第 2 轮)" { + t.Fatalf("second-turn echo should carry a turn marker, got %q", got) + } + + // GetTask / ListTasks / ListContexts / GetContext read the same state machine. + got, err := p.GetTask(ctx, t2.TaskID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if agentReply(t, got) != "再来(第 2 轮)" { + t.Fatalf("GetTask should replay the stored messages, got %+v", got.Messages) + } + tasks, err := p.ListTasks(ctx, t1.ContextID) + if err != nil { + t.Fatal(err) + } + if len(tasks) != 2 { + t.Fatalf("the same context should have 2 tasks, got %d", len(tasks)) + } + ctxs, err := p.ListContexts(ctx) + if err != nil { + t.Fatal(err) + } + if len(ctxs) != 1 || ctxs[0].ContextID != t1.ContextID { + t.Fatalf("should have exactly 1 context with a matching id, got %+v", ctxs) + } + detail, err := p.GetContext(ctx, t1.ContextID) + if err != nil { + t.Fatal(err) + } + if len(detail.Tasks) != 2 { + t.Fatalf("context detail should contain 2 tasks, got %+v", detail) + } +} + +// TestStateSurvivesReload pins the cross-process semantics: swapping in a new store +// instance pointing at the same snapshot file (simulating a new CLI process), the task +// is still queryable -- the offline demo chain depends on this. +func TestStateSurvivesReload(t *testing.T) { + swapStore(t) + p := newProvider(t, "echo") + task, err := p.Send(context.Background(), agent.SendInput{Text: "persist"}) + if err != nil { + t.Fatal(err) + } + // A new store instance = a new process view; only the snapshot file is shared memory. + store = newMemoryStore(store.path) + got, err := p.GetTask(context.Background(), task.TaskID) + if err != nil { + t.Fatalf("GetTask after reload: %v", err) + } + if got.ContextID != task.ContextID { + t.Fatalf("task should replay fully after reload: %+v", got) + } +} + +// TestReporterArtifactFlow verifies the full artifact chain: send produces {ID, Kind:text}, +// and DownloadArtifact returns inline Bytes + suggested_name. +func TestReporterArtifactFlow(t *testing.T) { + swapStore(t) + p := newProvider(t, "reporter") + ctx := context.Background() + + task, err := p.Send(ctx, agent.SendInput{Text: "本季度报表"}) + if err != nil { + t.Fatal(err) + } + if len(task.Artifacts) != 1 { + t.Fatalf("reporter should produce 1 artifact, got %+v", task.Artifacts) + } + art := task.Artifacts[0] + if art.ID == "" || art.Kind != "text" { + t.Fatalf("artifact should carry ID + Kind=text, got %+v", art) + } + + data, err := p.DownloadArtifact(ctx, task.TaskID, art.ID) + if err != nil { + t.Fatalf("DownloadArtifact: %v", err) + } + if data.Name != "quarterly_report.csv" { + t.Errorf("suggested_name should be quarterly_report.csv, got %q", data.Name) + } + if data.Mime != "text/csv" { + t.Errorf("mime should be text/csv, got %q", data.Mime) + } + if !strings.HasPrefix(string(data.Bytes), "quarter,revenue") { + t.Errorf("should return inline CSV bytes, got %q", string(data.Bytes)) + } + + // Unknown artifact id -> typed validation error. + if _, err := p.DownloadArtifact(ctx, task.TaskID, "art_nope"); err == nil { + t.Fatal("unknown artifact id should return an error") + } else if _, ok := errs.ProblemOf(err); !ok { + t.Fatalf("unknown artifact id should be a typed error, got %T: %v", err, err) + } +} + +// TestEchoCancelSentinel verifies the sentinel chain: echo (task_cancel=false) +// returns agent.ErrUnsupported from CancelTask, which the command layer's +// convertUnsupported recognizes via errors.Is. The same holds for echo's +// DownloadArtifact and a Send with files. +func TestEchoCancelSentinel(t *testing.T) { + swapStore(t) + p := newProvider(t, "echo") + ctx := context.Background() + if err := p.CancelTask(ctx, "task_whatever"); !errors.Is(err, agent.ErrUnsupported) { + t.Fatalf("echo CancelTask should return the ErrUnsupported sentinel, got %v", err) + } + if _, err := p.DownloadArtifact(ctx, "task_x", "art_x"); !errors.Is(err, agent.ErrUnsupported) { + t.Fatalf("echo DownloadArtifact should return the ErrUnsupported sentinel, got %v", err) + } + if _, err := p.Send(ctx, agent.SendInput{Text: "hi", Files: []string{"a.txt"}}); !errors.Is(err, agent.ErrUnsupported) { + t.Fatalf("echo Send with --file should return the ErrUnsupported sentinel, got %v", err) + } +} + +// TestReporterCancelTerminal verifies reporter supports cancel but returns a +// failed_precondition typed error for a terminal task (the mock task is completed +// as soon as it is sent). +func TestReporterCancelTerminal(t *testing.T) { + swapStore(t) + p := newProvider(t, "reporter") + ctx := context.Background() + task, err := p.Send(ctx, agent.SendInput{Text: "报表"}) + if err != nil { + t.Fatal(err) + } + err = p.CancelTask(ctx, task.TaskID) + if err == nil { + t.Fatal("canceling a terminal task should return an error") + } + if errors.Is(err, agent.ErrUnsupported) { + t.Fatal("reporter supports cancel and should not return ErrUnsupported") + } + prob, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("terminal cancel should be a typed error, got %T: %v", err, err) + } + if prob.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("terminal cancel subtype should be failed_precondition, got %s", prob.Subtype) + } +} + +// TestUnknownCatalogID verifies an unknown catalog id goes through StaticCatalog.Lookup's +// typed error (invalid_argument, with a hint pointing to agent list example). +func TestUnknownCatalogID(t *testing.T) { + swapStore(t) + p := newProvider(t, "nonexistent") + ctx := context.Background() + if _, err := p.Card(ctx); err == nil { + t.Fatal("Card with an unknown catalog id should return an error") + } + _, err := p.Send(ctx, agent.SendInput{Text: "hi"}) + if err == nil { + t.Fatal("Send with an unknown catalog id should return an error") + } + prob, ok := errs.ProblemOf(err) + if !ok || prob.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("unknown catalog id should be an invalid_argument typed error, got %v", err) + } +} + +// TestSendGuards pins Send's two typed rejections: --task-id follow-up (terminal +// semantics) and an unknown context id. +func TestSendGuards(t *testing.T) { + swapStore(t) + p := newProvider(t, "echo") + ctx := context.Background() + + _, err := p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_x", TaskID: "task_x"}) + if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("--task-id follow-up should be failed_precondition, got %v", err) + } + + _, err = p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_missing"}) + if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("unknown context id should be invalid_argument, got %v", err) + } +} + +// TestDeleteContext verifies deleting a context also cleans up the tasks under it. +func TestDeleteContext(t *testing.T) { + swapStore(t) + p := newProvider(t, "echo") + ctx := context.Background() + task, err := p.Send(ctx, agent.SendInput{Text: "bye"}) + if err != nil { + t.Fatal(err) + } + if err := p.DeleteContext(ctx, task.ContextID); err != nil { + t.Fatal(err) + } + if _, err := p.GetTask(ctx, task.TaskID); err == nil { + t.Fatal("after deleting the context its tasks should be unqueryable") + } + ctxs, err := p.ListContexts(ctx) + if err != nil { + t.Fatal(err) + } + if len(ctxs) != 0 { + t.Fatalf("no contexts should remain after deletion, got %+v", ctxs) + } +} + +// agentReply returns the first text reply from the agent role in the task. +func agentReply(t *testing.T, task *agent.AgentTask) string { + t.Helper() + for _, m := range task.Messages { + if m.Role != "agent" { + continue + } + for _, part := range m.Parts { + if part.Type == "text" { + return part.Text + } + } + } + t.Fatalf("task is missing an agent text reply: %+v", task.Messages) + return "" +} diff --git a/internal/agent/example/state.go b/internal/agent/example/state.go new file mode 100644 index 000000000..79482c9f1 --- /dev/null +++ b/internal/agent/example/state.go @@ -0,0 +1,324 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package example + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/agent" + "github.com/larksuite/cli/internal/vfs" +) + +// ============================================================================ +// In-memory state machine (teaching focus: concurrency safety of package-level +// state + the CLI process boundary) +// +// A real provider's context/task state lives on the server, so the adapter is +// naturally stateless; example is a pure mock and must manage state itself. Two +// disciplines the integrator needs to know: +// +// 1. Concurrency safety: provider instances may be constructed / called +// concurrently (e.g. list's probe alongside the real call), so package-level +// mutable state must be locked. A single coarse-grained Mutex covers all +// reads and writes here — the mock does not chase throughput; correctness comes first. +// 2. CLI process boundary: every lark-cli command is a fresh process, so a pure +// in-memory map does not survive a single command — after `send`, a +// `task get` would find nothing. So a lazy JSON snapshot layer sits beneath +// the in-memory map (under os.TempDir, last-writer-wins) to make the offline +// demo chain work across commands. A real provider neither needs nor should +// have this layer — it is a mock-only demo device. +// +// Note that the snapshot is loaded lazily (only on the first real read/write of +// state): Register's zero-value Deps probe constructs a provider once at +// registration time, and construction must have no side effects (the registry.go +// contract), so Factory / Card / ListAgents must not touch store. +// ============================================================================ + +// taskRecord is a task's storage form: a full AgentTask snapshot + owning agent +// + creation sequence number (list output sorts by creation order to guarantee +// stable enumeration). +type taskRecord struct { + AgentID string `json:"agent_id"` + Seq int `json:"seq"` + Task agent.AgentTask `json:"task"` +} + +// contextRecord is a multi-turn context's storage form. TaskIDs is appended in +// creation order — len(TaskIDs)+1 is the next round number, which echo uses to +// demonstrate "context memory". +type contextRecord struct { + AgentID string `json:"agent_id"` + ContextID string `json:"context_id"` + CreatedAt string `json:"created_at"` + Title string `json:"title,omitempty"` + Seq int `json:"seq"` + TaskIDs []string `json:"task_ids"` +} + +// memoryStore is the package-level state machine itself: mu covers all fields; +// path is the JSON snapshot location; loaded ensures the snapshot is read only +// once, on first access. +type memoryStore struct { + mu sync.Mutex + path string + loaded bool + + Contexts map[string]*contextRecord `json:"contexts"` + Tasks map[string]*taskRecord `json:"tasks"` + NextSeq int `json:"next_seq"` +} + +// store is the package-level singleton. Tests use swapStoreForTest to replace it +// with an instance pointing at t.TempDir, avoiding cross-contamination between +// tests and between tests and the local demo state. +var store = newMemoryStore(filepath.Join(os.TempDir(), "lark-cli-example-agent.json")) + +func newMemoryStore(path string) *memoryStore { + return &memoryStore{ + path: path, + Contexts: map[string]*contextRecord{}, + Tasks: map[string]*taskRecord{}, + } +} + +// loadLocked lazily reads in the snapshot (the caller must already hold the +// lock). A missing / corrupt snapshot is uniformly treated as empty state — the +// mock's demo data is not worth erroring over, so it just starts fresh. +func (s *memoryStore) loadLocked() { + if s.loaded { + return + } + s.loaded = true + data, err := vfs.ReadFile(s.path) + if err != nil { + return + } + var snap memoryStore + if json.Unmarshal(data, &snap) != nil { + return + } + if snap.Contexts != nil { + s.Contexts = snap.Contexts + } + if snap.Tasks != nil { + s.Tasks = snap.Tasks + } + s.NextSeq = snap.NextSeq +} + +// saveLocked writes the current state back to the snapshot (the caller must +// already hold the lock). A write failure returns a typed internal error +// (storage subtype) — the mock does not swallow errors either: silently losing +// state would make the next command report "task not found", which is harder to +// diagnose than a clear error. +func (s *memoryStore) saveLocked() error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "序列化 example 状态失败: %v", err).WithCause(err) + } + if err := vfs.WriteFile(s.path, data, 0o600); err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "写 example 状态快照失败: %v", err).WithCause(err) + } + return nil +} + +// newID generates a random id that is safe for [A-Za-z0-9_-]. The character set +// deliberately aligns with the command layer's meta.next interpolation +// allowlist (cmd/agent/send.go safeNextID): the id is spliced into a command +// string "the AI copies and runs", and an id with shell metacharacters would +// cause the whole hint to be suppressed. +func newID(prefix string) string { + var b [6]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand being unavailable is an environment-level failure; the mock + // degrades to a timestamp that still satisfies the character set. + return prefix + "_" + time.Now().UTC().Format("20060102150405") + } + return prefix + "_" + hex.EncodeToString(b[:]) +} + +// createContext creates a new context and returns its id (the first-turn send goes here). +func (s *memoryStore) createContext(agentID, title string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + id := newID("ctx") + s.NextSeq++ + s.Contexts[id] = &contextRecord{ + AgentID: agentID, + ContextID: id, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Title: title, + Seq: s.NextSeq, + } + return id, s.saveLocked() +} + +// createTask appends a task under ctxID: validate context ownership → compute +// the round (which task number in this conversation) → call build under the lock +// to construct the task → insert and write the snapshot. build runs inside the +// lock to guarantee "compute the round" and "store the task" are atomic, so two +// concurrent sends never get the same round. +// An unknown / cross-agent context id returns a typed validation error (teaching +// point: every error a provider returns must be typed — a bare error would land +// as internal/exit 5, whereas this is clearly "the caller passed a wrong +// argument", semantically invalid_argument/exit 2, and the AI relies on this +// classification to decide between "fix the argument and retry" and "report an +// environment failure"). +func (s *memoryStore) createTask(agentID, ctxID string, build func(round int) agent.AgentTask) (agent.AgentTask, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + ctx, ok := s.Contexts[ctxID] + if !ok || ctx.AgentID != agentID { + return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID). + WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID) + } + task := build(len(ctx.TaskIDs) + 1) + s.NextSeq++ + s.Tasks[task.TaskID] = &taskRecord{AgentID: agentID, Seq: s.NextSeq, Task: task} + ctx.TaskIDs = append(ctx.TaskIDs, task.TaskID) + return task, s.saveLocked() +} + +// getTask fetches a task snapshot by id (returns a copy by value, so the command +// layer's in-place edits like normalizeTask do not write through to store). A +// cross-agent task is treated as "not found", without leaking another agent's state. +func (s *memoryStore) getTask(agentID, taskID string) (agent.AgentTask, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + rec, ok := s.Tasks[taskID] + if !ok || rec.AgentID != agentID { + return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "未知的 task id '%s'(example:%s 名下不存在)", taskID, agentID). + WithHint("运行 lark-cli agent task list example:%s 查看现有任务", agentID) + } + return rec.Task, nil +} + +// setTaskState updates a task's state (used by reporter's cancel). +func (s *memoryStore) setTaskState(taskID string, state agent.TaskState) error { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + rec, ok := s.Tasks[taskID] + if !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "未知的 task id '%s'", taskID) + } + rec.Task.State = state + rec.Task.IsTerminal = state.IsTerminal() + return s.saveLocked() +} + +// listTasks lists an agent's task summaries, optionally filtered by contextID +// (empty string means no filter), output in creation order. IsTerminal is +// carried along here for convenience, but the command layer re-derives it from +// State via normalizeTask* (single source), so the integrator need not worry +// about this field. +func (s *memoryStore) listTasks(agentID, contextID string) []agent.TaskSummary { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + recs := make([]*taskRecord, 0, len(s.Tasks)) + for _, rec := range s.Tasks { + if rec.AgentID != agentID { + continue + } + if contextID != "" && rec.Task.ContextID != contextID { + continue + } + recs = append(recs, rec) + } + sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq }) + out := make([]agent.TaskSummary, 0, len(recs)) + for _, rec := range recs { + out = append(out, agent.TaskSummary{ + TaskID: rec.Task.TaskID, + ContextID: rec.Task.ContextID, + State: rec.Task.State, + IsTerminal: rec.Task.IsTerminal, + }) + } + return out +} + +// listContexts lists an agent's context summaries, output in creation order. +func (s *memoryStore) listContexts(agentID string) []agent.ContextSummary { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + recs := make([]*contextRecord, 0, len(s.Contexts)) + for _, ctx := range s.Contexts { + if ctx.AgentID == agentID { + recs = append(recs, ctx) + } + } + sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq }) + out := make([]agent.ContextSummary, 0, len(recs)) + for _, ctx := range recs { + out = append(out, agent.ContextSummary{ + ContextID: ctx.ContextID, + CreatedAt: ctx.CreatedAt, + Title: ctx.Title, + }) + } + return out +} + +// getContext returns a context's detail (including its task summaries, in creation order). +func (s *memoryStore) getContext(agentID, ctxID string) (*agent.ContextDetail, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + ctx, ok := s.Contexts[ctxID] + if !ok || ctx.AgentID != agentID { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID). + WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID) + } + detail := &agent.ContextDetail{ + ContextID: ctx.ContextID, + CreatedAt: ctx.CreatedAt, + Title: ctx.Title, + } + for _, tid := range ctx.TaskIDs { + if rec, ok := s.Tasks[tid]; ok { + detail.Tasks = append(detail.Tasks, agent.TaskSummary{ + TaskID: rec.Task.TaskID, + ContextID: rec.Task.ContextID, + State: rec.Task.State, + IsTerminal: rec.Task.IsTerminal, + }) + } + } + return detail, nil +} + +// deleteContext deletes a context and its tasks (a destructive operation, already gated by --yes in the command layer). +func (s *memoryStore) deleteContext(agentID, ctxID string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.loadLocked() + ctx, ok := s.Contexts[ctxID] + if !ok || ctx.AgentID != agentID { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID). + WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID) + } + for _, tid := range ctx.TaskIDs { + delete(s.Tasks, tid) + } + delete(s.Contexts, ctxID) + return s.saveLocked() +}