diff --git a/apps/desktop/e2e/live-reginald.spec.ts b/apps/desktop/e2e/live-reginald.spec.ts index a26b757..2a90f30 100644 --- a/apps/desktop/e2e/live-reginald.spec.ts +++ b/apps/desktop/e2e/live-reginald.spec.ts @@ -30,4 +30,27 @@ test.describe('live Reginald', () => { await app.close() }) + + test('proposes an estimate change for inline approval (writes via chat)', async () => { + test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in') + test.setTimeout(300_000) + const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) + const win = await app.firstWindow() + await win.waitForLoadState('domcontentloaded') + await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 }) + + const composer = win.getByPlaceholder(/Tell me what to do/) + await composer.fill('Set the estimate on issue #3 to est/5d.') + await composer.press('Enter') + + // propose_change → an inline propose-approve card (never an auto-write) + await expect(win.getByText('Proposed · #3')).toBeVisible({ timeout: 240_000 }) + await expect(win.getByText(/→ est\/5d/)).toBeVisible() + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald-propose.png'), fullPage: true, animations: 'disabled' }) + // Dismiss so the live run never mutates the repo (the write path itself is #41-tested) + await win.getByRole('button', { name: 'Dismiss' }).click() + await expect(win.getByText(/Left #3 as it was/)).toBeVisible() + + await app.close() + }) }) diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts index 18f8208..0c2016f 100644 --- a/apps/desktop/src/main/model.ts +++ b/apps/desktop/src/main/model.ts @@ -9,10 +9,14 @@ import { buildProjectView, + type ChangeProposal, type ChatMessage, createChatClient, + describeChange, type ModelRouter, type ProjectView, + proposalsFor, + type ProposeChangeArgs, type QueryFilters, REGINALD_SYSTEM, REGINALD_TOOLS, @@ -80,12 +84,28 @@ export function registerModelIpc(): void { const model = await resolveLoadedModel(router.small.baseUrl, router.small.model) const chat = createChatClient({ ...router.small, model }, fetch) + // Proposals the model formulates this turn; the renderer approves them (the + // write happens through gitea:applyChange, never inside the loop). + const proposals: ChangeProposal[] = [] + const execute = async (name: string, args: unknown) => { - if (name !== 'query_project') return { error: `unknown tool: ${name}` } if (!client) return { error: 'gitea is not configured' } - const snap = await reconcileSnapshot(client) - const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters } - return buildProjectView(a.view, a.filters, snap, new Date()) + if (name === 'query_project') { + const snap = await reconcileSnapshot(client) + const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters } + return buildProjectView(a.view, a.filters, snap, new Date()) + } + if (name === 'propose_change') { + const a = (args ?? {}) as ProposeChangeArgs + const issue = await client.getIssue(a.issue).catch(() => null) + if (!issue) return { error: `issue #${a.issue} not found` } + const built = proposalsFor(a, issue.labels, issue.title) + proposals.push(...built) + return built.length + ? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) } + : { proposed: [], note: 'no change — already at that value' } + } + return { error: `unknown tool: ${name}` } } try { @@ -95,7 +115,7 @@ export function registerModelIpc(): void { tools: REGINALD_TOOLS, execute, }) - return { ok: true as const, content: turn.content, steps: turn.steps } + return { ok: true as const, content: turn.content, steps: turn.steps, proposals } } catch (e) { return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) } } diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index 7b64514..2603cab 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -337,7 +337,7 @@ export function AppShell() { - setView('directives')} offline={offline} /> + setView('directives')} offline={offline} onApplyChange={applyChange} /> ) } diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx index 081dbf7..f50737f 100644 --- a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -1,21 +1,25 @@ import React, { useEffect, useRef, useState } from 'react' +import { describeChange, type IssueChange } from '@commitea/core' + import { useChat } from '../../lib/use-chat.js' -import { Icon, IconButton } from '../ui/index.js' +import { Button, Icon, IconButton } from '../ui/index.js' /** * Reginald's panel — chat is the write-path (decisions.md D1). Wired to the - * model bridge via `useChat`: when a model is configured, sending drives a real - * agent turn (query_project + prose); otherwise it echoes the scripted fixture - * reply so the layout stays real. Writes still go through propose-approve. + * model bridge via `useChat`: a configured model drives a real agent turn + * (query_project + prose, propose_change for edits); otherwise it echoes the + * scripted fixture reply. Proposed changes are approved inline here — the write + * runs through `onApplyChange`, the same guarded handler the Issue screen uses. */ export interface ChatPanelProps { onOpenDirectives?: () => void offline?: boolean + onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }> } -export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { - const { msgs, thinking, live, model, steps, send: sendChat } = useChat() +export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPanelProps) { + const { msgs, thinking, live, model, steps, proposals, send: sendChat, approve, dismiss } = useChat(onApplyChange) // shorten "google/gemma-4-26b-a4b-qat" → "gemma-4-26b" for the header chip const modelLabel = model ? (model.split('/').pop() ?? model).replace(/-(qat|instruct|it|gguf)$/i, '') : 'gemma-4' const [text, setText] = useState('') @@ -97,9 +101,36 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { {thinking ?
considering…
: null} {!thinking && steps.length ? (
- consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project')))).join(', ')} + consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project').replace('propose_change', 'the labels')))).join(', ')}
) : null} + {proposals.map((p) => ( +
+
+ Proposed · #{p.change.issue} +
+
{describeChange(p.plan)}
+
+ + +
+
+ ))}
diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 75428b2..835eb4e 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -1,5 +1,6 @@ import type { AgentStep, + ChangeProposal, ChatMessage, DependencyEdge, GiteaIssue, @@ -32,7 +33,7 @@ export interface GiteaBridge { /** One agent turn's result. */ export type ChatResult = | { ok: false; reason: 'unconfigured' | 'error'; message?: string } - | { ok: true; content: string; steps: AgentStep[] } + | { ok: true; content: string; steps: AgentStep[]; proposals: ChangeProposal[] } /** The model bridge (Reginald) exposed by the preload over IPC. */ export interface ModelBridge { diff --git a/apps/desktop/src/renderer/src/lib/use-chat.ts b/apps/desktop/src/renderer/src/lib/use-chat.ts index 934970f..ae12830 100644 --- a/apps/desktop/src/renderer/src/lib/use-chat.ts +++ b/apps/desktop/src/renderer/src/lib/use-chat.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { ChatMessage as WireMessage } from '@commitea/core' +import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core' import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js' @@ -18,7 +18,11 @@ export interface ChatState { model: string | null /** Tools Reginald consulted on the last turn (for a subtle activity line). */ steps: string[] + /** Changes Reginald has proposed and is awaiting approval on. */ + proposals: ChangeProposal[] send: (text: string) => void + approve: (p: ChangeProposal) => void + dismiss: (p: ChangeProposal) => void } /** @@ -26,18 +30,23 @@ export interface ChatState { * turn through the main-process bridge (which runs the tool loop). Otherwise it * echoes the scripted fixture reply, so the layout stays real with no model and * fixture e2e is unaffected. The fixture greeting is display-only — only real - * turns (`convo`) are sent to the model as history. + * turns (`convo`) are sent to the model as history. `onApplyChange` performs an + * approved write (the same handler the Issue screen uses — it refetches). */ -export function useChat(): ChatState { +export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>): ChatState { const [seed, setSeed] = useState(CHAT) const [convo, setConvo] = useState([]) const [thinking, setThinking] = useState(false) const [live, setLive] = useState(false) const [model, setModel] = useState(null) const [steps, setSteps] = useState([]) + const [proposals, setProposals] = useState([]) const convoRef = useRef(convo) convoRef.current = convo + const proposalKey = (p: ChangeProposal) => `${p.change.issue}:${p.change.kind}` + const drop = (p: ChangeProposal) => setProposals((ps) => ps.filter((x) => proposalKey(x) !== proposalKey(p))) + useEffect(() => { let alive = true window.commitea.model @@ -63,6 +72,7 @@ export function useChat(): ChatState { setConvo(nextConvo) setThinking(true) setSteps([]) + setProposals([]) if (!live) { window.setTimeout(() => { @@ -82,6 +92,7 @@ export function useChat(): ChatState { setThinking(false) if (res.ok) { setSteps(res.steps.map((s) => s.tool)) + setProposals(res.proposals) setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }]) } else { setConvo((c) => [ @@ -101,5 +112,30 @@ export function useChat(): ChatState { [live], ) - return { msgs: [...seed, ...convo], thinking, live, model, steps, send } + const approve = useCallback( + (p: ChangeProposal) => { + if (!onApplyChange) return + drop(p) + const label = p.plan.added[0] ?? p.plan.removed[0] ?? 'change' + void onApplyChange(p.change).then((res) => { + setConvo((c) => [ + ...c, + { + from: 'agent', + text: res.ok + ? `Done — #${p.change.issue} is now ${label}. The plan's been re-run.` + : `That didn't take — #${p.change.issue} is unchanged.`, + }, + ]) + }) + }, + [onApplyChange], + ) + + const dismiss = useCallback((p: ChangeProposal) => { + drop(p) + setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }]) + }, []) + + return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, send, approve, dismiss } } diff --git a/packages/core/src/agent/agent-tools.ts b/packages/core/src/agent/agent-tools.ts index 909f618..2acff52 100644 --- a/packages/core/src/agent/agent-tools.ts +++ b/packages/core/src/agent/agent-tools.ts @@ -35,12 +35,30 @@ export const QUERY_PROJECT_TOOL: ToolDecl = { }, } -export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL] +export const PROPOSE_CHANGE_TOOL: ToolDecl = { + name: 'propose_change', + description: + "Propose an estimate and/or priority change to an issue. This does NOT apply anything — it shows the " + + 'human a diff to approve. Use it whenever the user asks to re-estimate or reprioritize. After calling it, ' + + "tell the user you've *proposed* the change for approval — never say it is done.", + parameters: { + type: 'object', + properties: { + issue: { type: 'number', description: 'the issue number to change' }, + estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] }, + priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] }, + }, + required: ['issue'], + }, +} + +export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL] export const REGINALD_SYSTEM = [ 'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.', 'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.', 'The scheduler and forecasts are deterministic code — report their output, do not recompute it.', - 'Forecasts are ranges, never single dates. Refer to issues as #.', - 'Be brief and plain. A sentence or two is usually enough. No preamble, no bullet-point dumps.', + 'To change an estimate or priority, call propose_change — it shows the human a diff to approve.', + 'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.', + 'Refer to issues as #. Be brief and plain — a sentence or two. No preamble, no bullet dumps.', ].join(' ') diff --git a/packages/core/src/changes/apply-changes-v0.test.ts b/packages/core/src/changes/apply-changes-v0.test.ts index 72b97b2..e1cea9e 100644 --- a/packages/core/src/changes/apply-changes-v0.test.ts +++ b/packages/core/src/changes/apply-changes-v0.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { describeChange, type IssueChange, planIssueChange } from './apply-changes-v0.js' +import { describeChange, type IssueChange, planIssueChange, proposalsFor } from './apply-changes-v0.js' describe('planIssueChange', () => { it('swaps the estimate label, keeping non-axis labels', () => { @@ -52,3 +52,28 @@ describe('planIssueChange', () => { expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change') }) }) + +describe('proposalsFor', () => { + it('builds one proposal per changed axis, carrying the concrete change + diff', () => { + const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource') + expect(props).toHaveLength(2) + expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' }) + expect(describeChange(props[0].plan)).toBe('est/2d → est/5d') + expect(props[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' }) + expect(props[0].issueTitle).toBe('ChangeSource') + }) + + it('drops a noop axis (already at the requested value)', () => { + const props = proposalsFor({ issue: 2, estimate: 'est/2d', priority: 'p/1' }, ['est/2d', 'p/3']) + expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged + }) + + it('ignores invalid label values from the model', () => { + const props = proposalsFor({ issue: 2, estimate: 'est/4d' as never, priority: 'high' as never }, []) + expect(props).toEqual([]) + }) + + it('returns nothing when no axis is provided', () => { + expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([]) + }) +}) diff --git a/packages/core/src/changes/apply-changes-v0.ts b/packages/core/src/changes/apply-changes-v0.ts index f2ae8ac..ccd529e 100644 --- a/packages/core/src/changes/apply-changes-v0.ts +++ b/packages/core/src/changes/apply-changes-v0.ts @@ -57,3 +57,48 @@ export function describeChange(plan: LabelPlan): string { const to = plan.added.length ? plan.added.join(', ') : '∅' return `${from} → ${to}` } + +/** A change the agent proposes: the concrete op + its diff, ready for approve-then-apply. */ +export interface ChangeProposal { + change: IssueChange + plan: LabelPlan + issueTitle?: string +} + +/** What the `propose_change` tool accepts — a target issue and the axes to set. */ +export interface ProposeChangeArgs { + issue: number + estimate?: EstimateLabel + priority?: PriorityLabel +} + +function isEstimate(v: unknown): v is EstimateLabel { + return typeof v === 'string' && (ESTIMATE_LABELS as readonly string[]).includes(v) +} +function isPriority(v: unknown): v is PriorityLabel { + return typeof v === 'string' && (PRIORITY_LABELS as readonly string[]).includes(v) +} + +/** + * Build the concrete, non-noop proposals for a `propose_change` request against + * an issue's current labels. Invalid or unchanged axes are dropped — the agent + * proposes only real changes, and never a label outside the est/* · p/* axes. + */ +export function proposalsFor( + args: ProposeChangeArgs, + currentLabels: string[], + issueTitle?: string, +): ChangeProposal[] { + const out: ChangeProposal[] = [] + if (isEstimate(args.estimate)) { + const change: IssueChange = { kind: 'reestimate', issue: args.issue, estimate: args.estimate } + const plan = planIssueChange(currentLabels, change) + if (!plan.noop) out.push({ change, plan, issueTitle }) + } + if (isPriority(args.priority)) { + const change: IssueChange = { kind: 'reprioritize', issue: args.issue, priority: args.priority } + const plan = planIssueChange(currentLabels, change) + if (!plan.noop) out.push({ change, plan, issueTitle }) + } + return out +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 554c3ae..9881069 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,8 +23,8 @@ export type { GiteaRequestInit, } from './gitea/types.js' -export { describeChange, planIssueChange } from './changes/apply-changes-v0.js' -export type { IssueChange, LabelPlan } from './changes/apply-changes-v0.js' +export { describeChange, planIssueChange, proposalsFor } from './changes/apply-changes-v0.js' +export type { ChangeProposal, IssueChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js' export { inferColumnV0, @@ -85,6 +85,6 @@ export { pickModel } from './agent/model-router.js' export type { ModelRouter, TaskKind } from './agent/model-router.js' export { runAgentTurn } from './agent/agent-loop.js' export type { AgentStep, AgentTurn, ToolExecutor } from './agent/agent-loop.js' -export { QUERY_PROJECT_TOOL, REGINALD_SYSTEM, REGINALD_TOOLS } from './agent/agent-tools.js' +export { PROPOSE_CHANGE_TOOL, QUERY_PROJECT_TOOL, REGINALD_SYSTEM, REGINALD_TOOLS } from './agent/agent-tools.js' export { buildProjectView } from './agent/query-project.js' export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js'