Skip to content

Commit 780008f

Browse files
Copilotalexec
andauthored
Add user_prompt as positional argument for dynamic task augmentation (#160)
* Initial plan * Add user_prompt parameter support with slash command expansion Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * All tests and linting pass Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Make user_prompt a first-class positional argument with WithUserPrompt option Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Simplify user_prompt by appending to task content before parsing Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Add --- delimiter between task and user_prompt, assume taskContent length > 0 Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Remove empty task validation check and test per @alexec feedback Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
1 parent d577ca7 commit 780008f

3 files changed

Lines changed: 262 additions & 15 deletions

File tree

main.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ func main() {
4444

4545
flag.Usage = func() {
4646
logger.Info("Usage:")
47-
logger.Info(" coding-context [options] <task-name>")
47+
logger.Info(" coding-context [options] <task-name> [user-prompt]")
4848
logger.Info("")
4949
logger.Info("The task-name is the name of a task file to look up in task search paths (.agents/tasks).")
50+
logger.Info("The user-prompt is optional text to append to the task. It can contain slash commands")
51+
logger.Info("(e.g., '/command-name') which will be expanded, and parameter substitution (${param}).")
52+
logger.Info("")
5053
logger.Info("Task content can contain slash commands (e.g., '/command-name arg') which reference")
5154
logger.Info("command files in command search paths (.cursor/commands, .agents/commands, etc.).")
5255
logger.Info("")
@@ -56,13 +59,17 @@ func main() {
5659
flag.Parse()
5760

5861
args := flag.Args()
59-
if len(args) != 1 {
60-
logger.Error("Error", "error", fmt.Errorf("invalid usage: expected exactly one task name argument"))
62+
if len(args) < 1 || len(args) > 2 {
63+
logger.Error("Error", "error", fmt.Errorf("invalid usage: expected one task name argument and optional user-prompt"))
6164
flag.Usage()
6265
os.Exit(1)
6366
}
6467

6568
taskName := args[0]
69+
var userPrompt string
70+
if len(args) == 2 {
71+
userPrompt = args[1]
72+
}
6673

6774
homeDir, err := os.UserHomeDir()
6875
if err != nil {
@@ -81,6 +88,7 @@ func main() {
8188
codingcontext.WithResume(resume),
8289
codingcontext.WithAgent(agent),
8390
codingcontext.WithManifestURL(manifestURL),
91+
codingcontext.WithUserPrompt(userPrompt),
8492
)
8593

8694
result, err := cc.Run(ctx, taskName)

pkg/codingcontext/context.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Context struct {
2828
cmdRunner func(cmd *exec.Cmd) error
2929
resume bool
3030
agent Agent
31+
userPrompt string // User-provided prompt to append to task
3132
}
3233

3334
// Option is a functional option for configuring a Context
@@ -82,6 +83,13 @@ func WithAgent(agent Agent) Option {
8283
}
8384
}
8485

86+
// WithUserPrompt sets the user prompt to append to the task
87+
func WithUserPrompt(userPrompt string) Option {
88+
return func(c *Context) {
89+
c.userPrompt = userPrompt
90+
}
91+
}
92+
8593
// New creates a new Context with the given options
8694
func New(opts ...Option) *Context {
8795
c := &Context{
@@ -155,13 +163,15 @@ func (cc *Context) findTask(taskName string) error {
155163
// Add task name to includes so rules can be filtered
156164
cc.includes.SetValue("task_name", taskName)
157165

166+
taskFound := false
158167
err := cc.visitMarkdownFiles(taskSearchPaths, func(path string) error {
159168
baseName := filepath.Base(path)
160169
ext := filepath.Ext(baseName)
161170
if strings.TrimSuffix(baseName, ext) != taskName {
162171
return nil
163172
}
164173

174+
taskFound = true
165175
var frontMatter TaskFrontMatter
166176
md, err := ParseMarkdownFile(path, &frontMatter)
167177
if err != nil {
@@ -193,8 +203,20 @@ func (cc *Context) findTask(taskName string) error {
193203
cc.agent = agent
194204
}
195205

196-
// Parse the task content first to separate text blocks from slash commands
197-
task, err := ParseTask(md.Content)
206+
// Append user_prompt to task content before parsing
207+
// This allows user_prompt to be processed uniformly with task content
208+
taskContent := md.Content
209+
if cc.userPrompt != "" {
210+
// Add delimiter to separate task from user_prompt
211+
if !strings.HasSuffix(taskContent, "\n") {
212+
taskContent += "\n"
213+
}
214+
taskContent += "---\n" + cc.userPrompt
215+
cc.logger.Info("Appended user_prompt to task", "user_prompt_length", len(cc.userPrompt))
216+
}
217+
218+
// Parse the task content (including user_prompt) to separate text blocks from slash commands
219+
task, err := ParseTask(taskContent)
198220
if err != nil {
199221
return err
200222
}
@@ -235,7 +257,7 @@ func (cc *Context) findTask(taskName string) error {
235257
if err != nil {
236258
return fmt.Errorf("failed to find task: %w", err)
237259
}
238-
if cc.task.Content == "" {
260+
if !taskFound {
239261
return fmt.Errorf("task not found: %s", taskName)
240262
}
241263
return nil
@@ -248,6 +270,12 @@ func (cc *Context) findTask(taskName string) error {
248270
func (cc *Context) findCommand(commandName string, params map[string]string) (string, error) {
249271
var content *string
250272
err := cc.visitMarkdownFiles(commandSearchPaths, func(path string) error {
273+
baseName := filepath.Base(path)
274+
ext := filepath.Ext(baseName)
275+
if strings.TrimSuffix(baseName, ext) != commandName {
276+
return nil
277+
}
278+
251279
var frontMatter CommandFrontMatter
252280
md, err := ParseMarkdownFile(path, &frontMatter)
253281
if err != nil {

pkg/codingcontext/context_test.go

Lines changed: 220 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -352,15 +352,6 @@ func TestContext_Run_Basic(t *testing.T) {
352352
}
353353
},
354354
},
355-
{
356-
name: "empty task content returns error",
357-
setup: func(t *testing.T, dir string) {
358-
createTask(t, dir, "empty", "", "")
359-
},
360-
taskName: "empty",
361-
wantErr: true,
362-
errContains: "task not found",
363-
},
364355
}
365356

366357
for _, tt := range tests {
@@ -1417,3 +1408,223 @@ func TestContext_Run_ExpandParams(t *testing.T) {
14171408
})
14181409
}
14191410
}
1411+
1412+
// TestUserPrompt tests the user_prompt parameter functionality
1413+
func TestUserPrompt(t *testing.T) {
1414+
tests := []struct {
1415+
name string
1416+
setup func(t *testing.T, dir string)
1417+
opts []Option
1418+
taskName string
1419+
wantErr bool
1420+
errContains string
1421+
check func(t *testing.T, result *Result)
1422+
}{
1423+
{
1424+
name: "simple user_prompt appended to task",
1425+
setup: func(t *testing.T, dir string) {
1426+
createTask(t, dir, "simple", "", "Task content\n")
1427+
},
1428+
opts: []Option{
1429+
WithUserPrompt("User prompt content"),
1430+
},
1431+
taskName: "simple",
1432+
wantErr: false,
1433+
check: func(t *testing.T, result *Result) {
1434+
if !strings.Contains(result.Task.Content, "Task content") {
1435+
t.Error("expected task content to contain 'Task content'")
1436+
}
1437+
if !strings.Contains(result.Task.Content, "User prompt content") {
1438+
t.Error("expected task content to contain 'User prompt content'")
1439+
}
1440+
// Check that user_prompt comes after task content
1441+
taskIdx := strings.Index(result.Task.Content, "Task content")
1442+
userIdx := strings.Index(result.Task.Content, "User prompt content")
1443+
if taskIdx >= userIdx {
1444+
t.Error("expected user_prompt to come after task content")
1445+
}
1446+
},
1447+
},
1448+
{
1449+
name: "user_prompt with slash command",
1450+
setup: func(t *testing.T, dir string) {
1451+
createTask(t, dir, "with-command", "", "Task content\n")
1452+
createCommand(t, dir, "greet", "", "Hello from command!")
1453+
},
1454+
opts: []Option{
1455+
WithUserPrompt("User says:\n/greet\n"),
1456+
},
1457+
taskName: "with-command",
1458+
wantErr: false,
1459+
check: func(t *testing.T, result *Result) {
1460+
if !strings.Contains(result.Task.Content, "Task content") {
1461+
t.Error("expected task content to contain 'Task content'")
1462+
}
1463+
if !strings.Contains(result.Task.Content, "User says:") {
1464+
t.Error("expected task content to contain 'User says: '")
1465+
}
1466+
if !strings.Contains(result.Task.Content, "Hello from command!") {
1467+
t.Error("expected slash command in user_prompt to be expanded")
1468+
}
1469+
},
1470+
},
1471+
{
1472+
name: "user_prompt with parameter substitution",
1473+
setup: func(t *testing.T, dir string) {
1474+
createTask(t, dir, "with-params", "", "Task content\n")
1475+
},
1476+
opts: []Option{
1477+
WithUserPrompt("Issue: ${issue_number}"),
1478+
WithParams(Params{
1479+
"issue_number": "123",
1480+
}),
1481+
},
1482+
taskName: "with-params",
1483+
wantErr: false,
1484+
check: func(t *testing.T, result *Result) {
1485+
if !strings.Contains(result.Task.Content, "Issue: 123") {
1486+
t.Error("expected parameter substitution in user_prompt")
1487+
}
1488+
},
1489+
},
1490+
{
1491+
name: "user_prompt with slash command and parameters",
1492+
setup: func(t *testing.T, dir string) {
1493+
createTask(t, dir, "complex", "", "Task content\n")
1494+
createCommand(t, dir, "issue-info", "", "Issue ${issue_number}: ${issue_title}")
1495+
},
1496+
opts: []Option{
1497+
WithUserPrompt("Please fix:\n/issue-info\n"),
1498+
WithParams(Params{
1499+
"issue_number": "456",
1500+
"issue_title": "Fix bug",
1501+
}),
1502+
},
1503+
taskName: "complex",
1504+
wantErr: false,
1505+
check: func(t *testing.T, result *Result) {
1506+
if !strings.Contains(result.Task.Content, "Please fix:") {
1507+
t.Error("expected task content to contain 'Please fix: '")
1508+
}
1509+
if !strings.Contains(result.Task.Content, "Issue 456: Fix bug") {
1510+
t.Error("expected slash command to be expanded with parameter substitution")
1511+
}
1512+
},
1513+
},
1514+
{
1515+
name: "empty user_prompt should not affect task",
1516+
setup: func(t *testing.T, dir string) {
1517+
createTask(t, dir, "empty", "", "Task content\n")
1518+
},
1519+
opts: []Option{
1520+
WithUserPrompt(""),
1521+
},
1522+
taskName: "empty",
1523+
wantErr: false,
1524+
check: func(t *testing.T, result *Result) {
1525+
if result.Task.Content != "Task content\n" {
1526+
t.Errorf("expected task content to be unchanged, got %q", result.Task.Content)
1527+
}
1528+
},
1529+
},
1530+
{
1531+
name: "no user_prompt parameter should not affect task",
1532+
setup: func(t *testing.T, dir string) {
1533+
createTask(t, dir, "no-prompt", "", "Task content\n")
1534+
},
1535+
opts: []Option{},
1536+
taskName: "no-prompt",
1537+
wantErr: false,
1538+
check: func(t *testing.T, result *Result) {
1539+
if result.Task.Content != "Task content\n" {
1540+
t.Errorf("expected task content to be unchanged, got %q", result.Task.Content)
1541+
}
1542+
},
1543+
},
1544+
{
1545+
name: "user_prompt with multiple slash commands",
1546+
setup: func(t *testing.T, dir string) {
1547+
createTask(t, dir, "multi", "", "Task content\n")
1548+
createCommand(t, dir, "cmd1", "", "Command 1")
1549+
createCommand(t, dir, "cmd2", "", "Command 2")
1550+
},
1551+
opts: []Option{
1552+
WithUserPrompt("/cmd1\n/cmd2\n"),
1553+
},
1554+
taskName: "multi",
1555+
wantErr: false,
1556+
check: func(t *testing.T, result *Result) {
1557+
if !strings.Contains(result.Task.Content, "Command 1") {
1558+
t.Error("expected first slash command to be expanded")
1559+
}
1560+
if !strings.Contains(result.Task.Content, "Command 2") {
1561+
t.Error("expected second slash command to be expanded")
1562+
}
1563+
},
1564+
},
1565+
{
1566+
name: "user_prompt respects task expand setting",
1567+
setup: func(t *testing.T, dir string) {
1568+
createTask(t, dir, "no-expand", "expand: false", "Task content\n")
1569+
},
1570+
opts: []Option{
1571+
WithUserPrompt("Issue ${issue_number}"),
1572+
WithParams(Params{
1573+
"issue_number": "789",
1574+
}),
1575+
},
1576+
taskName: "no-expand",
1577+
wantErr: false,
1578+
check: func(t *testing.T, result *Result) {
1579+
if !strings.Contains(result.Task.Content, "${issue_number}") {
1580+
t.Error("expected parameter to NOT be expanded when expand: false")
1581+
}
1582+
},
1583+
},
1584+
{
1585+
name: "user_prompt with invalid slash command",
1586+
setup: func(t *testing.T, dir string) {
1587+
createTask(t, dir, "invalid", "", "Task content\n")
1588+
},
1589+
opts: []Option{
1590+
WithUserPrompt("/nonexistent-command\n"),
1591+
},
1592+
taskName: "invalid",
1593+
wantErr: true,
1594+
errContains: "command not found",
1595+
},
1596+
}
1597+
1598+
for _, tt := range tests {
1599+
t.Run(tt.name, func(t *testing.T) {
1600+
tmpDir := t.TempDir()
1601+
1602+
tt.setup(t, tmpDir)
1603+
1604+
allOpts := append([]Option{
1605+
WithLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))),
1606+
WithSearchPaths("file://" + tmpDir),
1607+
}, tt.opts...)
1608+
1609+
c := New(allOpts...)
1610+
1611+
result, err := c.Run(context.Background(), tt.taskName)
1612+
1613+
if (err != nil) != tt.wantErr {
1614+
t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr)
1615+
return
1616+
}
1617+
1618+
if tt.wantErr && tt.errContains != "" {
1619+
if !strings.Contains(err.Error(), tt.errContains) {
1620+
t.Errorf("expected error to contain %q, got %v", tt.errContains, err)
1621+
}
1622+
return
1623+
}
1624+
1625+
if !tt.wantErr && tt.check != nil {
1626+
tt.check(t, result)
1627+
}
1628+
})
1629+
}
1630+
}

0 commit comments

Comments
 (0)