feat(v0.6): AgentSpec field on TaskMeta with backward-compat unmarshal

Replace TaskMeta.Agent (string) with TaskMeta.Agent (AgentSpec) carrying
type/command/args/env. Custom UnmarshalYAML preserves the legacy scalar
form: a built-in name (claude, opencode) maps to that type; any other
scalar maps to type=custom with the scalar as command. A missing agent
field leaves Type empty so the resolver fills in default_agent at launch.

ValidateAgentSpec enforces: known type (claude|opencode|custom),
type=custom requires command, command must be an executable name or
path with no whitespace or shell metacharacters.

Launch-path wiring (Task 3) and the --agent flag rework (Task 4) are
intentionally not part of this commit; cmd/* call sites are patched to
the minimum needed for the build to compile.
This commit is contained in:
2026-05-15 10:58:06 -04:00
parent 6c4c3e8df2
commit 8120c399df
31 changed files with 427 additions and 189 deletions
+127
View File
@@ -0,0 +1,127 @@
package workspace
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestAgentSpecRoundTripMappingForm(t *testing.T) {
src := []byte(`
agent:
type: opencode
command: /usr/local/bin/opencode
args:
- --model
- deepseek-coder
env:
OPENAI_API_KEY: ollama
`)
var doc struct {
Agent AgentSpec `yaml:"agent"`
}
if err := yaml.Unmarshal(src, &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if doc.Agent.Type != "opencode" {
t.Errorf("Type = %q, want opencode", doc.Agent.Type)
}
if doc.Agent.Command != "/usr/local/bin/opencode" {
t.Errorf("Command = %q, want /usr/local/bin/opencode", doc.Agent.Command)
}
if got := doc.Agent.Args; len(got) != 2 || got[0] != "--model" || got[1] != "deepseek-coder" {
t.Errorf("Args = %v, want [--model deepseek-coder]", got)
}
if doc.Agent.Env["OPENAI_API_KEY"] != "ollama" {
t.Errorf("Env[OPENAI_API_KEY] = %q, want ollama", doc.Agent.Env["OPENAI_API_KEY"])
}
}
func TestAgentSpecLegacyScalarForm(t *testing.T) {
cases := []struct {
name string
src string
wantType string
wantCommand string
}{
{"builtin claude", "agent: claude", "claude", ""},
{"builtin opencode", "agent: opencode", "opencode", ""},
{"arbitrary command", "agent: aider", "custom", "aider"},
{"absolute path", "agent: /opt/agent", "custom", "/opt/agent"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var doc struct {
Agent AgentSpec `yaml:"agent"`
}
if err := yaml.Unmarshal([]byte(tc.src), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if doc.Agent.Type != tc.wantType {
t.Errorf("Type = %q, want %q", doc.Agent.Type, tc.wantType)
}
if doc.Agent.Command != tc.wantCommand {
t.Errorf("Command = %q, want %q", doc.Agent.Command, tc.wantCommand)
}
})
}
}
func TestAgentSpecMissingFieldStaysEmpty(t *testing.T) {
// No `agent:` key at all — Type stays empty so resolution falls
// through to the user-level default_agent.
var doc struct {
Agent AgentSpec `yaml:"agent"`
}
if err := yaml.Unmarshal([]byte("title: x\n"), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if doc.Agent.Type != "" || doc.Agent.Command != "" {
t.Errorf("got %+v, want zero AgentSpec", doc.Agent)
}
}
func TestIsBuiltinAgentType(t *testing.T) {
for _, name := range []string{"claude", "opencode"} {
if !IsBuiltinAgentType(name) {
t.Errorf("IsBuiltinAgentType(%q) = false, want true", name)
}
}
for _, name := range []string{"custom", "gemini", "", "aider"} {
if IsBuiltinAgentType(name) {
t.Errorf("IsBuiltinAgentType(%q) = true, want false", name)
}
}
}
func TestValidateAgentSpec(t *testing.T) {
cases := []struct {
name string
spec AgentSpec
wantErr string // substring; "" means no error
}{
{"empty ok (resolved later)", AgentSpec{}, ""},
{"claude ok", AgentSpec{Type: "claude"}, ""},
{"opencode ok", AgentSpec{Type: "opencode"}, ""},
{"custom needs command", AgentSpec{Type: "custom"}, "type \"custom\" requires command"},
{"custom with command ok", AgentSpec{Type: "custom", Command: "my-agent"}, ""},
{"unknown type rejected", AgentSpec{Type: "gemini"}, "unknown agent type"},
{"command with space rejected", AgentSpec{Type: "claude", Command: "claude --debug"}, "must be an executable name"},
{"command with shell metachar rejected", AgentSpec{Type: "claude", Command: "claude|grep"}, "must be an executable name"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateAgentSpec("ws", &TaskMeta{Agent: tc.spec})
if tc.wantErr == "" {
if err != nil {
t.Fatalf("got %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("got %v, want substring %q", err, tc.wantErr)
}
})
}
}
+6 -6
View File
@@ -12,11 +12,11 @@ import (
// CreateOpts holds parameters for workspace creation.
type CreateOpts struct {
Root string
Title string
Category string
Mode string
Agent string
Root string
Title string
Category string
Mode string
AgentSpec AgentSpec
// IsProject switches the built-in defaults to project-oriented templates,
// records type=project in task.yaml, and triggers project seed overlay.
@@ -141,7 +141,7 @@ func Create(opts CreateOpts) (*CreateResult, error) {
Category: opts.Category,
Type: taskType,
Mode: opts.Mode,
Agent: opts.Agent,
Agent: opts.AgentSpec,
ArchivedAt: nil,
}
if opts.IsProject {
+42 -42
View File
@@ -12,11 +12,11 @@ func TestCreateWorkspace(t *testing.T) {
root := t.TempDir()
opts := CreateOpts{
Root: root,
Title: "test task",
Category: "general",
Mode: "local",
Agent: "claude",
Root: root,
Title: "test task",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
}
ws, err := Create(opts)
@@ -68,11 +68,11 @@ func TestCreateWorkspaceNoTitle(t *testing.T) {
root := t.TempDir()
opts := CreateOpts{
Root: root,
Title: "",
Category: "general",
Mode: "local",
Agent: "claude",
Root: root,
Title: "",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
}
ws, err := Create(opts)
@@ -96,7 +96,7 @@ func TestCreateProjectWritesProjectClaudeMD(t *testing.T) {
Title: "billing",
Category: "projects",
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
}
ws, err := Create(opts)
@@ -119,11 +119,11 @@ func TestCreateProjectWritesProjectClaudeMD(t *testing.T) {
func TestCreateTaskWritesTaskClaudeMD(t *testing.T) {
root := t.TempDir()
opts := CreateOpts{
Root: root,
Title: "fix bug",
Category: "general",
Mode: "local",
Agent: "claude",
Root: root,
Title: "fix bug",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
}
ws, err := Create(opts)
if err != nil {
@@ -150,12 +150,12 @@ func TestCreateAppliesGeneralSeed(t *testing.T) {
}
opts := CreateOpts{
Root: root,
Title: "seeded",
Category: "general",
Mode: "local",
Agent: "claude",
SeedDir: seedDir,
Root: root,
Title: "seeded",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
SeedDir: seedDir,
}
ws, err := Create(opts)
if err != nil {
@@ -193,7 +193,7 @@ func TestCreateProjectAppliesGeneralThenProjectSeed(t *testing.T) {
Title: "billing",
Category: "projects",
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
SeedDir: general,
ProjectSeedDir: project,
@@ -222,12 +222,12 @@ func TestCreateSeedDoesNotReplaceTaskYAML(t *testing.T) {
t.Fatalf("write seed task.yaml: %v", err)
}
opts := CreateOpts{
Root: root,
Title: "no clobber",
Category: "general",
Mode: "local",
Agent: "claude",
SeedDir: seedDir,
Root: root,
Title: "no clobber",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
SeedDir: seedDir,
}
ws, err := Create(opts)
if err != nil {
@@ -249,7 +249,7 @@ func TestCreateSkipCategoryDirPlacesUnderRoot(t *testing.T) {
Title: "billing service",
Category: "projects", // recorded in metadata
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
SkipCategoryDir: true,
}
@@ -273,7 +273,7 @@ func TestCreateExplicitCategoryAppendsSubdir(t *testing.T) {
Title: "billing service",
Category: "backend",
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
// SkipCategoryDir: false (default)
}
@@ -291,11 +291,11 @@ func TestCreateCollisionSuffix(t *testing.T) {
root := t.TempDir()
opts := CreateOpts{
Root: root,
Title: "same name",
Category: "general",
Mode: "local",
Agent: "claude",
Root: root,
Title: "same name",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
}
ws1, _ := Create(opts)
@@ -318,7 +318,7 @@ func TestCreateProjectScaffoldsSubdirAndSetsLaunchDir(t *testing.T) {
Title: "litlink-v2",
Category: "projects",
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
})
if err != nil {
@@ -345,7 +345,7 @@ func TestCreateProjectSubdirMatchesSuffixedSlug(t *testing.T) {
root := t.TempDir()
first, err := Create(CreateOpts{
Root: root, Title: "dup", Category: "projects",
Mode: "local", Agent: "claude", IsProject: true,
Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: true,
})
if err != nil {
t.Fatalf("first Create: %v", err)
@@ -356,7 +356,7 @@ func TestCreateProjectSubdirMatchesSuffixedSlug(t *testing.T) {
second, err := Create(CreateOpts{
Root: root, Title: "dup", Category: "projects",
Mode: "local", Agent: "claude", IsProject: true,
Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: true,
})
if err != nil {
t.Fatalf("second Create: %v", err)
@@ -380,7 +380,7 @@ func TestCreateDirectoryPrefixUsesLocalDate(t *testing.T) {
expected := time.Now().Format("2006-01-02") // LOCAL — no .UTC()
res, err := Create(CreateOpts{
Root: root, Title: "tz-check", Category: "general",
Mode: "local", Agent: "claude",
Mode: "local", AgentSpec: AgentSpec{Type: "claude"},
})
if err != nil {
t.Fatalf("Create: %v", err)
@@ -397,7 +397,7 @@ func TestCreateStoresTimestampsInUTC(t *testing.T) {
root := t.TempDir()
res, err := Create(CreateOpts{
Root: root, Title: "tz-check-stored", Category: "general",
Mode: "local", Agent: "claude",
Mode: "local", AgentSpec: AgentSpec{Type: "claude"},
})
if err != nil {
t.Fatalf("Create: %v", err)
@@ -414,7 +414,7 @@ func TestCreateTaskDoesNotScaffoldSubdir(t *testing.T) {
root := t.TempDir()
res, err := Create(CreateOpts{
Root: root, Title: "hello", Category: "general",
Mode: "local", Agent: "claude", IsProject: false,
Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: false,
})
if err != nil {
t.Fatalf("Create: %v", err)
+3 -3
View File
@@ -59,7 +59,7 @@ func TestCreateProjectPreservesGeneralSeedGitignore(t *testing.T) {
Title: "gen gi proj",
Category: "projects",
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
SeedDir: general,
})
@@ -91,7 +91,7 @@ func TestCreateProjectPreservesProjectSeedGitignore(t *testing.T) {
Title: "proj gi proj",
Category: "projects",
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
ProjectSeedDir: project,
})
@@ -116,7 +116,7 @@ func TestCreateProjectCreatesMinimalGitignoreWhenNoSeed(t *testing.T) {
Title: "no seed gi",
Category: "projects",
Mode: "local",
Agent: "claude",
AgentSpec: AgentSpec{Type: "claude"},
IsProject: true,
})
if err != nil {
+112 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/warrenronsiek/ctask/internal/lockfile"
@@ -45,11 +46,118 @@ type TaskMeta struct {
Category string `yaml:"category"`
Type string `yaml:"type"`
Mode string `yaml:"mode"`
Agent string `yaml:"agent"`
Agent AgentSpec `yaml:"agent,omitempty"`
ArchivedAt *time.Time `yaml:"archived_at"`
LaunchDir string `yaml:"launch_dir,omitempty"`
}
// AgentSpec is the v0.6 agent profile carried in task.yaml. All fields are
// optional; a missing Type falls through to the user-level default_agent at
// resolution time (see internal/agent.Resolve).
type AgentSpec struct {
Type string `yaml:"type,omitempty"`
Command string `yaml:"command,omitempty"`
Args []string `yaml:"args,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
}
// IsBuiltinAgentType reports whether name is a built-in agent type with a
// known default command (claude, opencode). "custom" is NOT a built-in —
// it is the escape hatch. internal/agent.BuiltinProfiles must mirror this
// exact set; when a new built-in lands, update both together.
func IsBuiltinAgentType(name string) bool {
switch name {
case "claude", "opencode":
return true
default:
return false
}
}
// UnmarshalYAML accepts either:
// - a mapping: the v0.6+ structured form, decoded field-by-field.
// - a scalar string: the legacy v0.1-v0.5 form. The scalar is PRESERVED,
// not dropped. A scalar matching a built-in name (claude, opencode)
// maps to AgentSpec{Type: <scalar>}. Any other scalar — a path or an
// arbitrary command such as `aider` or `/opt/agent` — maps to
// AgentSpec{Type: "custom", Command: <scalar>}, so a legacy workspace
// keeps launching the agent it was created with.
// - a null/empty node: Type stays empty so resolution falls through to
// the user-level default_agent (the agent field is effectively
// missing).
func (a *AgentSpec) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var s string
if err := node.Decode(&s); err != nil {
return fmt.Errorf("agent: %w", err)
}
switch {
case s == "":
// Empty/null — leave Type empty; resolver fills in the default.
case IsBuiltinAgentType(s):
*a = AgentSpec{Type: s}
default:
*a = AgentSpec{Type: "custom", Command: s}
}
return nil
case yaml.MappingNode:
type rawAgentSpec AgentSpec // alias to avoid recursion
var v rawAgentSpec
if err := node.Decode(&v); err != nil {
return fmt.Errorf("agent: %w", err)
}
*a = AgentSpec(v)
return nil
default:
return fmt.Errorf("agent: expected string or mapping, got node kind %d", node.Kind)
}
}
// knownAgentTypes is the set of agent types accepted in task.yaml. "custom"
// is the escape hatch — any executable can be used, but agent.command is
// required for it. Built-in profiles (claude, opencode) live in
// internal/agent.BuiltinProfiles; this set must stay in sync with that map
// and with IsBuiltinAgentType.
var knownAgentTypes = map[string]struct{}{
"claude": {},
"opencode": {},
"custom": {},
}
// agentCommandForbidden is the set of characters disallowed in agent.command.
// command must be an executable name or absolute/relative path — arguments
// belong in agent.args. Whitespace and shell metacharacters indicate the
// user is trying to embed args; we reject them with a hint.
const agentCommandForbidden = " \t|&;<>()$`"
// ValidateAgentSpec enforces the v0.6 invariants on the agent block. An
// empty Type is always allowed (resolution fills in default_agent at
// launch time). Any non-empty Type must be in knownAgentTypes. type:custom
// requires a non-empty Command. Command (when set) must look like a single
// executable: no whitespace, no shell metacharacters.
func ValidateAgentSpec(slug string, m *TaskMeta) error {
if m == nil {
return nil
}
a := m.Agent
if a.Type != "" {
if _, ok := knownAgentTypes[a.Type]; !ok {
return fmt.Errorf("workspace %q: unknown agent type %q (must be claude, opencode, or custom)",
slug, a.Type)
}
}
if a.Type == "custom" && a.Command == "" {
return fmt.Errorf("workspace %q: agent type \"custom\" requires command field",
slug)
}
if a.Command != "" && strings.ContainsAny(a.Command, agentCommandForbidden) {
return fmt.Errorf("workspace %q: agent.command %q must be an executable name or path with no whitespace or shell metacharacters; put arguments in agent.args",
slug, a.Command)
}
return nil
}
// EffectiveSchemaVersion returns the schema version a meta should be
// treated as. A stored value of 0 means the field was missing in
// task.yaml (legacy pre-v0.6 workspace); per spec, these are treated
@@ -146,6 +254,9 @@ func ReadMeta(path string) (*TaskMeta, error) {
if err := ValidateWorkspaceMode(slug, &meta); err != nil {
return nil, err
}
if err := ValidateAgentSpec(slug, &meta); err != nil {
return nil, err
}
return &meta, nil
}
+42 -42
View File
@@ -17,7 +17,7 @@ func TestWriteMetaLockedHappyPath(t *testing.T) {
ID: "1", Slug: "s", Title: "t",
CreatedAt: now, UpdatedAt: now,
Status: "active", Category: "general", Type: "task",
Mode: "local", Agent: "claude",
Mode: "local", Agent: AgentSpec{Type: "claude"},
}
if err := WriteMetaLocked(metaPath, meta); err != nil {
@@ -68,16 +68,16 @@ func TestWriteAndReadMeta(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
meta := &TaskMeta{
ID: "20260405-143022",
Slug: "arch-notes",
Title: "arch notes",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "general",
Mode: "local",
Agent: "claude",
ArchivedAt: nil,
ID: "20260405-143022",
Slug: "arch-notes",
Title: "arch notes",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "general",
Mode: "local",
Agent: AgentSpec{Type: "claude"},
ArchivedAt: nil,
}
if err := WriteMeta(path, meta); err != nil {
@@ -112,15 +112,15 @@ func TestMetaYAMLFieldsPresent(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
meta := &TaskMeta{
ID: "20260405-143022",
Slug: "test",
Title: "test",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "general",
Mode: "local",
Agent: "claude",
ID: "20260405-143022",
Slug: "test",
Title: "test",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "general",
Mode: "local",
Agent: AgentSpec{Type: "claude"},
}
WriteMeta(path, meta)
@@ -141,16 +141,16 @@ func TestMetaTypeRoundTrip(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
meta := &TaskMeta{
ID: "20260410-120000",
Slug: "billing",
Title: "billing",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "projects",
Type: "project",
Mode: "local",
Agent: "claude",
ID: "20260410-120000",
Slug: "billing",
Title: "billing",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "projects",
Type: "project",
Mode: "local",
Agent: AgentSpec{Type: "claude"},
}
if err := WriteMeta(path, meta); err != nil {
t.Fatalf("WriteMeta: %v", err)
@@ -221,15 +221,15 @@ func TestMetaArchive(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
meta := &TaskMeta{
ID: "20260405-143022",
Slug: "test",
Title: "test",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "general",
Mode: "local",
Agent: "claude",
ID: "20260405-143022",
Slug: "test",
Title: "test",
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Category: "general",
Mode: "local",
Agent: AgentSpec{Type: "claude"},
}
WriteMeta(path, meta)
@@ -265,7 +265,7 @@ func TestMetaLaunchDirRoundTrip(t *testing.T) {
Category: "projects",
Type: "project",
Mode: "local",
Agent: "claude",
Agent: AgentSpec{Type: "claude"},
LaunchDir: "demo",
}
if err := WriteMeta(path, meta); err != nil {
@@ -289,8 +289,8 @@ func TestMetaLaunchDirOmittedWhenEmpty(t *testing.T) {
ID: "t", Slug: "t", Title: "t",
CreatedAt: time.Now().UTC().Truncate(time.Second),
UpdatedAt: time.Now().UTC().Truncate(time.Second),
Status: "active", Category: "general", Type: "task",
Mode: "local", Agent: "claude",
Status: "active", Category: "general", Type: "task",
Mode: "local", Agent: AgentSpec{Type: "claude"},
}
if err := WriteMeta(path, meta); err != nil {
t.Fatalf("WriteMeta: %v", err)
+20 -20
View File
@@ -30,16 +30,16 @@ func createTestWorkspaceFull(t *testing.T, root, category, dirName, status, task
// Extract slug from dirName (skip "YYYY-MM-DD_")
slug := dirName[11:]
meta := &TaskMeta{
ID: "test",
Slug: slug,
Title: slug,
CreatedAt: updatedAt,
UpdatedAt: updatedAt,
Status: status,
Category: category,
Type: taskType,
Mode: "local",
Agent: "claude",
ID: "test",
Slug: slug,
Title: slug,
CreatedAt: updatedAt,
UpdatedAt: updatedAt,
Status: status,
Category: category,
Type: taskType,
Mode: "local",
Agent: AgentSpec{Type: "claude"},
}
WriteMeta(filepath.Join(dir, "task.yaml"), meta)
}
@@ -175,16 +175,16 @@ func createFlatProjectWorkspaceFull(t *testing.T, root, dirName string, updatedA
os.MkdirAll(dir, 0755)
slug := dirName[11:]
meta := &TaskMeta{
ID: "test",
Slug: slug,
Title: slug,
CreatedAt: updatedAt,
UpdatedAt: updatedAt,
Status: "active",
Category: "projects",
Type: "project",
Mode: "local",
Agent: "claude",
ID: "test",
Slug: slug,
Title: slug,
CreatedAt: updatedAt,
UpdatedAt: updatedAt,
Status: "active",
Category: "projects",
Type: "project",
Mode: "local",
Agent: AgentSpec{Type: "claude"},
}
WriteMeta(filepath.Join(dir, "task.yaml"), meta)
}
+15 -15
View File
@@ -13,11 +13,11 @@ import (
func TestNewMetaIncludesSchemaVersion(t *testing.T) {
root := t.TempDir()
result, err := Create(CreateOpts{
Root: root,
Title: "Test schema",
Category: "general",
Mode: "local",
Agent: "claude",
Root: root,
Title: "Test schema",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
})
if err != nil {
t.Fatalf("Create: %v", err)
@@ -37,11 +37,11 @@ func TestNewMetaIncludesSchemaVersion(t *testing.T) {
func TestNewMetaIncludesWorkspaceMode(t *testing.T) {
root := t.TempDir()
result, err := Create(CreateOpts{
Root: root,
Title: "Test mode",
Category: "general",
Mode: "local",
Agent: "claude",
Root: root,
Title: "Test mode",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
})
if err != nil {
t.Fatalf("Create: %v", err)
@@ -65,11 +65,11 @@ func TestNewMetaIncludesWorkspaceMode(t *testing.T) {
func TestNewMetaRoundTripWithSchemaFields(t *testing.T) {
root := t.TempDir()
result, err := Create(CreateOpts{
Root: root,
Title: "Round trip",
Category: "general",
Mode: "local",
Agent: "claude",
Root: root,
Title: "Round trip",
Category: "general",
Mode: "local",
AgentSpec: AgentSpec{Type: "claude"},
})
if err != nil {
t.Fatalf("Create: %v", err)