Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/core/src/messaging/do-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,46 @@ describe('makeDoCommandHandler — happy paths', () => {
expect(conv?.closedAt).toBeNull();
t.dispose();
});

it('free-form args + project snapshot → uses snapshot project + args as goal', async () => {
const t = harness();
seedActiveConversationWith(t, { sessionId: 'sess-1', projectId: PROJECT_A.id });
const reply = await t.handler(
{ name: 'do', scope: SCOPE, userId: 'u1', args: 'fix the panic in the login flow' },
'trace-1',
);
expect(reply).toMatch(/Promoting to task/);
const cmd = t.orch.created[0];
expect(cmd?.projectSlugOrPath).toBe(PROJECT_A.slug);
expect(cmd?.prompt).toBe('fix the panic in the login flow');
expect(cmd?.branchRef).toBeUndefined();
t.dispose();
});

it('free-form args with trailing branch tail → snapshot project + branchRef extracted', async () => {
const t = harness();
seedActiveConversationWith(t, { sessionId: 'sess-1', projectId: PROJECT_A.id });
const reply = await t.handler(
{ name: 'do', scope: SCOPE, userId: 'u1', args: 'rewrite the readme, branch off feat/x' },
'trace-1',
);
expect(reply).toMatch(/Promoting to task/);
const cmd = t.orch.created[0];
expect(cmd?.projectSlugOrPath).toBe(PROJECT_A.slug);
expect(cmd?.prompt).toBe('rewrite the readme');
expect(cmd?.branchRef).toBe('feat/x');
t.dispose();
});

it('free-form args + no project snapshot → REPLY_DO_NO_PROJECT', async () => {
const t = harness();
seedActiveConversationWith(t, { sessionId: 'sess-1' });
const reply = await t.handler(
{ name: 'do', scope: SCOPE, userId: 'u1', args: 'fix the panic' },
'trace-1',
);
expect(reply).toBe(REPLY_DO_NO_PROJECT);
expect(t.orch.created).toHaveLength(0);
t.dispose();
});
});
43 changes: 29 additions & 14 deletions packages/core/src/messaging/do-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
* 1. No active conversation in scope → "send a message first" (E2 sibling)
* 2. Conversation has no `last_session_id` yet → "send a message first" (E2)
* 3. RunCoordinator says a chat or task is in flight → "wait or /new" (E1, E4)
* 4. Args supplied (`/do work on X in Y`) but slug bad → "unknown project: <slug>" hard-reject (E9)
* 4. Args supplied as `work on <goal> in <slug>` but slug bad → "unknown project: <slug>" hard-reject (E9)
* 5. Args omitted: snapshot project_id; missing snapshot → "no project for this conversation" hint
* 6. Otherwise: build CreateTaskCommand whose orchestrator path resumes
* 6. Args supplied as free-form `/do <goal>` → use snapshot project + args as goal.
* Same missing-snapshot hint as case 5.
* 7. Otherwise: build CreateTaskCommand whose orchestrator path resumes
* `lastSessionId`. Conversation is NOT closed; chat lane lives on so the
* user can continue brainstorming after the task lands.
*
Expand All @@ -21,7 +23,7 @@ import { truncate } from '../agent/format.js';
import type { ConversationsRepo } from '../datastore/repos/conversations.js';
import type { TaskOrchestrator } from '../orchestrator/orchestrator.js';
import type { CreateTaskCommand } from '../orchestrator/types.js';
import { parseDiscordCommand } from '../parser/index.js';
import { parseDiscordCommand, parseRoutedMessage } from '../parser/index.js';
import type { Project } from '../types/index.js';
import type { DoCommandHandler } from './dispatcher.js';
import type { RunCoordinator } from './run-coordinator.js';
Expand All @@ -36,6 +38,8 @@ export const REPLY_DO_RUN_IN_FLIGHT =
'A run is currently active on this conversation. Wait for it to finish or run `/new`.';
export const REPLY_DO_NO_PROJECT =
'This conversation has no project context. Use `/do work on <goal> in <project>` to specify one.';
export const REPLY_DO_EMPTY_GOAL =
'Could not parse `/do` arguments. Try: `/do <goal>` or `/do work on <goal> in <project>`.';

export interface DoCommandOpts {
conversations: Pick<ConversationsRepo, 'getActive' | 'getById'>;
Expand All @@ -59,22 +63,33 @@ export function makeDoCommandHandler(opts: DoCommandOpts): DoCommandHandler {
let goal: string;
let branchRef: string | undefined;

if (args.length > 0) {
const parsed = parseDiscordCommand(args);
if (!parsed) {
return 'Could not parse `/do` arguments. Try: `/do work on <goal> in <project>`.';
}
const resolved = opts.resolveProject(parsed.projectHint);
if (!resolved) return `unknown project: ${parsed.projectHint}`;
project = resolved;
goal = parsed.goal;
branchRef = parsed.branchRef;
} else {
if (args.length === 0) {
if (!conversation.projectId) return REPLY_DO_NO_PROJECT;
const snapshot = opts.resolveProjectById(conversation.projectId);
if (!snapshot) return `unknown project: ${conversation.projectId}`;
project = snapshot;
goal = RESUME_PROMPT;
} else {
// Try the strict `work on <goal> in <project>` grammar first — it lets the
// user explicitly override the conversation's snapshot project.
const explicit = parseDiscordCommand(args);
if (explicit) {
const resolved = opts.resolveProject(explicit.projectHint);
if (!resolved) return `unknown project: ${explicit.projectHint}`;
project = resolved;
goal = explicit.goal;
branchRef = explicit.branchRef;
} else {
// Free-form `/do <anything>` — infer project from conversation snapshot.
if (!conversation.projectId) return REPLY_DO_NO_PROJECT;
const snapshot = opts.resolveProjectById(conversation.projectId);
if (!snapshot) return `unknown project: ${conversation.projectId}`;
const routed = parseRoutedMessage(args);
if (!routed) return REPLY_DO_EMPTY_GOAL;
project = snapshot;
goal = routed.goal;
branchRef = routed.branchRef;
}
}

const ctc: CreateTaskCommand = {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/messaging/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
export type { DoCommandOpts } from './do-command.js';
export {
makeDoCommandHandler,
REPLY_DO_EMPTY_GOAL,
REPLY_DO_NO_CONVERSATION,
REPLY_DO_NO_PROJECT,
REPLY_DO_NO_SESSION,
Expand Down
Loading