Enrich agent task board workflow (#2891)

Co-authored-by: cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
This commit is contained in:
Steven Enamakel
2026-05-29 19:19:03 +05:30
committed by GitHub
co-authored by cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
parent b72877445b
commit 3e59548f12
44 changed files with 1711 additions and 21 deletions
@@ -50,6 +50,7 @@ const AgentAccessPanel = () => {
const [level, setLevel] = useState<AutonomyLevel>('supervised');
const [workspaceOnly, setWorkspaceOnly] = useState(false);
const [requireTaskPlanApproval, setRequireTaskPlanApproval] = useState(true);
const [trustedRoots, setTrustedRoots] = useState<TrustedRoot[]>([]);
// "Always allow" allowlist — populated by the in-chat "Always allow" button;
// shown here read-only with a Remove action (the re-protect path).
@@ -78,6 +79,7 @@ const AgentAccessPanel = () => {
if (cancelled) return;
setLevel(resp.result.level);
setWorkspaceOnly(resp.result.workspace_only);
setRequireTaskPlanApproval(resp.result.require_task_plan_approval ?? true);
setTrustedRoots(resp.result.trusted_roots ?? []);
setAutoApprove(resp.result.auto_approve ?? []);
} catch (e) {
@@ -100,6 +102,7 @@ const AgentAccessPanel = () => {
const persist = async (next: {
level: AutonomyLevel;
workspaceOnly: boolean;
requireTaskPlanApproval: boolean;
trustedRoots: TrustedRoot[];
// Only sent when the allowlist itself is being changed. Omitting it leaves
// the server's `auto_approve` untouched (partial patch) — important so a
@@ -118,6 +121,7 @@ const AgentAccessPanel = () => {
workspace_only: next.workspaceOnly,
trusted_roots: next.trustedRoots,
allow_tool_install: ALLOW_TOOL_INSTALL,
require_task_plan_approval: next.requireTaskPlanApproval,
...(next.autoApprove !== undefined ? { auto_approve: next.autoApprove } : {}),
});
// Only the most recent persist may write UI state back.
@@ -137,12 +141,17 @@ const AgentAccessPanel = () => {
const selectTier = (next: AutonomyLevel) => {
setLevel(next);
void persist({ level: next, workspaceOnly, trustedRoots });
void persist({ level: next, workspaceOnly, requireTaskPlanApproval, trustedRoots });
};
const toggleWorkspaceOnly = (next: boolean) => {
setWorkspaceOnly(next);
void persist({ level, workspaceOnly: next, trustedRoots });
void persist({ level, workspaceOnly: next, requireTaskPlanApproval, trustedRoots });
};
const toggleTaskPlanApproval = (next: boolean) => {
setRequireTaskPlanApproval(next);
void persist({ level, workspaceOnly, requireTaskPlanApproval: next, trustedRoots });
};
const addRoot = () => {
@@ -156,19 +165,25 @@ const AgentAccessPanel = () => {
setTrustedRoots(nextRoots);
setNewRootPath('');
setNewRootAccess('read');
void persist({ level, workspaceOnly, trustedRoots: nextRoots });
void persist({ level, workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots });
};
const removeRoot = (path: string) => {
const nextRoots = trustedRoots.filter(r => r.path !== path);
setTrustedRoots(nextRoots);
void persist({ level, workspaceOnly, trustedRoots: nextRoots });
void persist({ level, workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots });
};
const removeAutoApprove = (tool: string) => {
const nextList = autoApprove.filter(name => name !== tool);
setAutoApprove(nextList);
void persist({ level, workspaceOnly, trustedRoots, autoApprove: nextList });
void persist({
level,
workspaceOnly,
requireTaskPlanApproval,
trustedRoots,
autoApprove: nextList,
});
};
return (
@@ -248,6 +263,25 @@ const AgentAccessPanel = () => {
</label>
</section>
<section className="space-y-1">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 cursor-pointer"
checked={requireTaskPlanApproval}
onChange={e => toggleTaskPlanApproval(e.target.checked)}
/>
<span>
<span className="text-sm font-medium text-ink">
{t('settings.agentAccess.requireTaskPlanApproval.label')}
</span>
<span className="block text-xs text-ink-soft">
{t('settings.agentAccess.requireTaskPlanApproval.desc')}
</span>
</span>
</label>
</section>
{/* Granted folders (trusted roots) — extra read/write reach. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-ink">
@@ -74,12 +74,23 @@ describe('AgentAccessPanel', () => {
it('toggling "confine to workspace" persists workspace_only', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Read-only');
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('checkbox', { name: /confine to workspace/i }));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ workspace_only: true }))
);
});
it('toggling task plan approval persists require_task_plan_approval', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Read-only');
fireEvent.click(screen.getByRole('checkbox', { name: /require task plan approval/i }));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ require_task_plan_approval: false })
)
);
});
it('adding then removing a granted folder persists the updated list', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Granted folders');
@@ -111,7 +122,9 @@ describe('AgentAccessPanel', () => {
});
renderWithProviders(<AgentAccessPanel />);
expect(await screen.findByText('/home/u/notes')).toBeInTheDocument();
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(true);
expect(
(screen.getByRole('checkbox', { name: /confine to workspace/i }) as HTMLInputElement).checked
).toBe(true);
});
it('shows the empty "always-allow" state when no tools are allow-listed', async () => {
+22
View File
@@ -428,6 +428,28 @@ const ar4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'التوجيه والمشغلات وسجل عمليات التكامل المدعومة بواسطة Composio.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default ar4;
+3
View File
@@ -946,6 +946,9 @@ const ar5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default ar5;
+22
View File
@@ -431,6 +431,28 @@ const bn4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Composio দ্বারা চালিত ইন্টিগ্রেশনের জন্য রাউটিং, ট্রিগার এবং ইতিহাস।',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default bn4;
+3
View File
@@ -959,6 +959,9 @@ const bn5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default bn5;
+22
View File
@@ -437,6 +437,28 @@ const de4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Routing, Trigger und Verlauf für Integrationen, die von Composio unterstützt werden.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default de4;
+3
View File
@@ -987,6 +987,9 @@ const de5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default de5;
+22
View File
@@ -76,6 +76,28 @@ const en4: TranslationMap = {
'conversations.taskKanban.moveLeft': 'Move left',
'conversations.taskKanban.moveRight': 'Move right',
'conversations.taskKanban.title': 'Tasks',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
'conversations.toolTimeline.turn': 'turn',
'conversations.toolTimeline.workerThread': 'worker thread',
'daemon.serviceBlockingGate.body': 'Body',
+3
View File
@@ -313,6 +313,9 @@ const en5: TranslationMap = {
'settings.agentAccess.confine.label': 'Confine to workspace',
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
+22
View File
@@ -435,6 +435,28 @@ const es4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Enrutamiento, activadores e historial para integraciones impulsadas por Composio.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default es4;
+3
View File
@@ -973,6 +973,9 @@ const es5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default es5;
+22
View File
@@ -434,6 +434,28 @@ const fr4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Routage, déclencheurs et historique pour les intégrations optimisées par Composio.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default fr4;
+3
View File
@@ -977,6 +977,9 @@ const fr5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default fr5;
+22
View File
@@ -432,6 +432,28 @@ const hi4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Composio द्वारा संचालित एकीकरण के लिए रूटिंग, ट्रिगर और इतिहास।',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default hi4;
+3
View File
@@ -960,6 +960,9 @@ const hi5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default hi5;
+22
View File
@@ -433,6 +433,28 @@ const id4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Perutean, pemicu, dan riwayat untuk integrasi yang didukung oleh Composio.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default id4;
+3
View File
@@ -960,6 +960,9 @@ const id5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default id5;
+22
View File
@@ -436,6 +436,28 @@ const it4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Routing, trigger e cronologia per le integrazioni fornite da Composio.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default it4;
+3
View File
@@ -971,6 +971,9 @@ const it5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default it5;
+22
View File
@@ -434,6 +434,28 @@ const ko4: TranslationMap = {
'settings.ai.openAiCompat.rotateKey': '키 회전',
'settings.ai.openAiCompat.setKey': '키 설정',
'settings.ai.openAiCompat.title': 'OpenAI 호환 엔드포인트',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default ko4;
+3
View File
@@ -949,6 +949,9 @@ const ko5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default ko5;
+22
View File
@@ -434,6 +434,28 @@ const pt4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Roteamento, gatilhos e histórico para integrações desenvolvidas por Composio.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default pt4;
+3
View File
@@ -970,6 +970,9 @@ const pt5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default pt5;
+22
View File
@@ -431,6 +431,28 @@ const ru4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Маршрутизация, триггеры и история интеграций на базе Composio.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default ru4;
+3
View File
@@ -966,6 +966,9 @@ const ru5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default ru5;
+22
View File
@@ -422,6 +422,28 @@ const zhCN4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'由 Composio 提供支持的集成的路由、触发器和历史记录。',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
export default zhCN4;
+3
View File
@@ -920,6 +920,9 @@ const zhCN5: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
};
export default zhCN5;
+25
View File
@@ -2505,6 +2505,28 @@ const en: TranslationMap = {
'conversations.taskKanban.moveLeft': 'Move left',
'conversations.taskKanban.moveRight': 'Move right',
'conversations.taskKanban.title': 'Tasks',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
'conversations.taskKanban.approval.required': 'Required',
'conversations.taskKanban.approval.requiredBadge': 'approval',
'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution',
'conversations.taskKanban.briefButton': 'Task brief',
'conversations.taskKanban.briefTitle': 'Task brief',
'conversations.taskKanban.closeBrief': 'Close task brief',
'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria',
'conversations.taskKanban.field.allowedTools': 'Allowed tools',
'conversations.taskKanban.field.approval': 'Approval',
'conversations.taskKanban.field.assignedAgent': 'Assigned agent',
'conversations.taskKanban.field.blocker': 'Blocker',
'conversations.taskKanban.field.evidence': 'Evidence',
'conversations.taskKanban.field.notes': 'Notes',
'conversations.taskKanban.field.objective': 'Objective',
'conversations.taskKanban.field.plan': 'Plan',
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
'conversations.toolTimeline.turn': 'turn',
'conversations.toolTimeline.workerThread': 'worker thread',
'daemon.serviceBlockingGate.body':
@@ -3529,6 +3551,9 @@ const en: TranslationMap = {
'settings.agentAccess.confine.label': 'Confine to workspace',
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval',
'settings.agentAccess.requireTaskPlanApproval.desc':
'Pause before an assigned agent executes an agent-authored task brief.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
+31 -1
View File
@@ -1115,7 +1115,34 @@ const Conversations = ({
dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: saved }));
} catch (error) {
debug('putTaskBoard failed: %o', error);
setSendAdvisory('Could not move task; changes were not saved.');
setSendAdvisory(t('conversations.taskKanban.updateFailed'));
dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: selectedTaskBoard }));
}
};
const handleUpdateTaskCard = async (
card: TaskBoardCard,
nextCard: TaskBoardCard
): Promise<void> => {
if (!selectedThreadId || !selectedTaskBoard) return;
const now = new Date().toISOString();
const nextBoard = {
...selectedTaskBoard,
cards: selectedTaskBoard.cards.map(existing =>
existing.id === card.id ? { ...nextCard, updatedAt: now } : existing
),
updatedAt: now,
};
dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: nextBoard }));
try {
const saved = await threadApi.putTaskBoard(selectedThreadId, nextBoard.cards);
if (!saved) {
throw new Error('Task board update returned no board');
}
dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: saved }));
} catch (error) {
debug('putTaskBoard failed: %o', error);
setSendAdvisory(t('conversations.taskKanban.updateFailed'));
dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: selectedTaskBoard }));
}
};
@@ -1554,6 +1581,9 @@ const Conversations = ({
onMove={(card, status) => {
void handleMoveTaskCard(card, status);
}}
onUpdateCard={(card, nextCard) => {
void handleUpdateTaskCard(card, nextCard);
}}
/>
)}
{visibleMessages.map(msg => (
@@ -933,7 +933,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
fireEvent.click(screen.getByLabelText('Move right'));
await waitFor(() => {
expect(screen.getByText('Could not move task; changes were not saved.')).toBeInTheDocument();
expect(
screen.getByText('Could not update task; changes were not saved.')
).toBeInTheDocument();
});
expect(threadApi.putTaskBoard).toHaveBeenCalledWith(
'board-thread',
@@ -941,6 +943,65 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
);
});
it('rolls back and shows feedback when task board edit persistence fails', async () => {
const thread = makeThread({ id: 'edit-board-thread', title: 'Edit Board Thread' });
const board = {
threadId: 'edit-board-thread',
updatedAt: '2026-05-04T10:00:00Z',
cards: [
{
id: 'task-1',
title: 'Plan rollout',
status: 'todo' as const,
objective: 'Draft the launch task brief',
assignedAgent: 'planner',
approvalMode: 'required' as const,
plan: ['Read docs'],
allowedTools: ['todo'],
acceptanceCriteria: ['Saved board round-trips'],
evidence: [],
order: 0,
updatedAt: '2026-05-04T10:00:00Z',
},
],
};
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
vi.mocked(threadApi.getTaskBoard).mockResolvedValueOnce(board);
vi.mocked(threadApi.putTaskBoard).mockRejectedValueOnce(new Error('write failed'));
await act(async () => {
await renderConversations({
thread: selectedThreadState(thread),
socket: socketState('connected'),
});
});
expect(await screen.findByText('Plan rollout')).toBeInTheDocument();
fireEvent.click(screen.getByText('Task brief'));
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Updated rollout' } });
fireEvent.change(screen.getByLabelText('Assigned agent'), {
target: { value: 'code_executor' },
});
fireEvent.click(screen.getByText('Save changes'));
await waitFor(() => {
expect(
screen.getByText('Could not update task; changes were not saved.')
).toBeInTheDocument();
});
expect(threadApi.putTaskBoard).toHaveBeenCalledWith(
'edit-board-thread',
expect.arrayContaining([
expect.objectContaining({
id: 'task-1',
title: 'Updated rollout',
assignedAgent: 'code_executor',
}),
])
);
});
it('sends with Enter when the composer is not composing text', async () => {
const { textarea, thread } = await renderSelectedConversation();
@@ -1,4 +1,14 @@
import { LuArrowLeft, LuArrowRight } from 'react-icons/lu';
import { useMemo, useState } from 'react';
import {
LuArrowLeft,
LuArrowRight,
LuBot,
LuCircleCheck,
LuClipboardList,
LuShieldCheck,
LuWrench,
LuX,
} from 'react-icons/lu';
import { useT } from '../../../lib/i18n/I18nContext';
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../../types/turnState';
@@ -18,10 +28,21 @@ interface TaskKanbanBoardProps {
board: TaskBoard;
disabled?: boolean;
onMove?: (card: TaskBoardCard, status: TaskBoardCardStatus) => void;
onUpdateCard?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void;
}
export function TaskKanbanBoard({ board, disabled = false, onMove }: TaskKanbanBoardProps) {
export function TaskKanbanBoard({
board,
disabled = false,
onMove,
onUpdateCard,
}: TaskKanbanBoardProps) {
const { t } = useT();
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const selectedCard = useMemo(
() => board.cards.find(card => card.id === selectedCardId) ?? null,
[board.cards, selectedCardId]
);
if (board.cards.length === 0) return null;
@@ -99,6 +120,39 @@ export function TaskKanbanBoard({ board, disabled = false, onMove }: TaskKanbanB
</div>
)}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{card.assignedAgent && (
<span className="inline-flex max-w-full items-center gap-1 rounded-md bg-ocean-50 px-1.5 py-0.5 text-[10px] text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200">
<LuBot className="h-3 w-3 flex-none" />
<span className="truncate">{card.assignedAgent}</span>
</span>
)}
{card.allowedTools && card.allowedTools.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-300">
<LuWrench className="h-3 w-3" />
{card.allowedTools.length}
</span>
)}
{card.approvalMode && (
<span className="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-200">
<LuShieldCheck className="h-3 w-3" />
{card.approvalMode === 'required'
? t('conversations.taskKanban.approval.requiredBadge')
: t('conversations.taskKanban.approval.notRequiredBadge')}
</span>
)}
{card.acceptanceCriteria && card.acceptanceCriteria.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-sage-50 px-1.5 py-0.5 text-[10px] text-sage-700 dark:bg-sage-500/10 dark:text-sage-200">
<LuCircleCheck className="h-3 w-3" />
{card.acceptanceCriteria.length}
</span>
)}
</div>
{card.objective && (
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
{card.objective}
</p>
)}
{card.notes && (
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
{card.notes}
@@ -109,12 +163,376 @@ export function TaskKanbanBoard({ board, disabled = false, onMove }: TaskKanbanB
{card.blocker}
</p>
)}
{(onUpdateCard ||
card.plan?.length ||
card.allowedTools?.length ||
card.acceptanceCriteria?.length ||
card.evidence?.length ||
card.objective ||
card.assignedAgent ||
card.approvalMode) && (
<button
type="button"
onClick={() => setSelectedCardId(card.id)}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
<LuClipboardList className="h-3 w-3" />
{t('conversations.taskKanban.briefButton')}
</button>
)}
</article>
))}
</div>
</section>
))}
</div>
{selectedCard && (
<TaskBriefDialog
card={selectedCard}
disabled={disabled}
onClose={() => setSelectedCardId(null)}
onUpdate={onUpdateCard}
/>
)}
</div>
);
}
function TaskBriefDialog({
card,
disabled,
onClose,
onUpdate,
}: {
card: TaskBoardCard;
disabled: boolean;
onClose: () => void;
onUpdate?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void;
}) {
const { t } = useT();
const editable = Boolean(onUpdate) && !disabled;
const [title, setTitle] = useState(card.title);
const [status, setStatus] = useState<TaskBoardCardStatus>(card.status);
const [objective, setObjective] = useState(card.objective ?? '');
const [assignedAgent, setAssignedAgent] = useState(card.assignedAgent ?? '');
const [approvalMode, setApprovalMode] = useState(card.approvalMode ?? '');
const [plan, setPlan] = useState(joinLines(card.plan));
const [allowedTools, setAllowedTools] = useState(joinLines(card.allowedTools));
const [acceptanceCriteria, setAcceptanceCriteria] = useState(joinLines(card.acceptanceCriteria));
const [evidence, setEvidence] = useState(joinLines(card.evidence));
const [notes, setNotes] = useState(card.notes ?? '');
const [blocker, setBlocker] = useState(card.blocker ?? '');
const save = () => {
if (!editable) return;
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
onUpdate?.(card, {
...card,
title: trimmedTitle,
status,
objective: emptyToNull(objective),
assignedAgent: emptyToNull(assignedAgent),
approvalMode:
approvalMode === 'required' || approvalMode === 'not_required' ? approvalMode : null,
plan: splitLines(plan),
allowedTools: splitLines(allowedTools),
acceptanceCriteria: splitLines(acceptanceCriteria),
evidence: splitLines(evidence),
notes: emptyToNull(notes),
blocker: emptyToNull(blocker),
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4 py-6">
<section className="max-h-full w-full max-w-xl overflow-y-auto rounded-lg border border-stone-200 bg-white p-4 shadow-xl dark:border-neutral-800 dark:bg-neutral-900">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase text-stone-400 dark:text-neutral-500">
{t('conversations.taskKanban.briefTitle')}
</p>
<h3 className="break-words text-base font-semibold text-stone-900 dark:text-neutral-50">
{card.title}
</h3>
</div>
<button
type="button"
aria-label={t('conversations.taskKanban.closeBrief')}
onClick={onClose}
className="flex h-7 w-7 flex-none items-center justify-center rounded-md text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
<LuX className="h-4 w-4" />
</button>
</div>
{editable ? (
<div className="space-y-3 text-sm">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('conversations.taskKanban.field.title')}
</span>
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50"
/>
</label>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('conversations.taskKanban.field.status')}
</span>
<select
value={status}
onChange={e => setStatus(e.target.value as TaskBoardCardStatus)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50">
{COLUMN_DEFS.map(column => (
<option key={column.status} value={column.status}>
{t(column.labelKey)}
</option>
))}
</select>
</label>
<BriefInput
label={t('conversations.taskKanban.field.assignedAgent')}
value={assignedAgent}
onChange={setAssignedAgent}
/>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('conversations.taskKanban.field.approval')}
</span>
<select
value={approvalMode}
onChange={e => setApprovalMode(e.target.value)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50">
<option value="">{t('conversations.taskKanban.approval.default')}</option>
<option value="required">
{t('conversations.taskKanban.approval.required')}
</option>
<option value="not_required">
{t('conversations.taskKanban.approval.notRequired')}
</option>
</select>
</label>
</div>
<BriefInput
label={t('conversations.taskKanban.field.objective')}
value={objective}
onChange={setObjective}
/>
<BriefTextarea
label={t('conversations.taskKanban.field.plan')}
value={plan}
onChange={setPlan}
/>
<BriefTextarea
label={t('conversations.taskKanban.field.allowedTools')}
value={allowedTools}
onChange={setAllowedTools}
/>
<BriefTextarea
label={t('conversations.taskKanban.field.acceptanceCriteria')}
value={acceptanceCriteria}
onChange={setAcceptanceCriteria}
/>
<BriefTextarea
label={t('conversations.taskKanban.field.evidence')}
value={evidence}
onChange={setEvidence}
/>
<BriefTextarea
label={t('conversations.taskKanban.field.notes')}
value={notes}
onChange={setNotes}
/>
<BriefTextarea
label={t('conversations.taskKanban.field.blocker')}
value={blocker}
onChange={setBlocker}
/>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('common.cancel')}
</button>
<button
type="button"
onClick={save}
disabled={!title.trim()}
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
{t('conversations.taskKanban.saveChanges')}
</button>
</div>
</div>
) : (
<div className="space-y-4 text-sm">
<BriefText
label={t('conversations.taskKanban.field.objective')}
value={card.objective}
/>
<BriefText
label={t('conversations.taskKanban.field.assignedAgent')}
value={card.assignedAgent}
mono
/>
<BriefText
label={t('conversations.taskKanban.field.approval')}
value={
card.approvalMode === 'required'
? t('conversations.taskKanban.approval.requiredBeforeExecution')
: card.approvalMode === 'not_required'
? t('conversations.taskKanban.approval.notRequired')
: undefined
}
/>
<BriefList
label={t('conversations.taskKanban.field.plan')}
values={card.plan}
ordered
/>
<BriefList
label={t('conversations.taskKanban.field.allowedTools')}
values={card.allowedTools}
mono
/>
<BriefList
label={t('conversations.taskKanban.field.acceptanceCriteria')}
values={card.acceptanceCriteria}
/>
<BriefList
label={t('conversations.taskKanban.field.evidence')}
values={card.evidence}
/>
<BriefText label={t('conversations.taskKanban.field.notes')} value={card.notes} />
<BriefText
label={t('conversations.taskKanban.field.blocker')}
value={card.blocker}
tone="danger"
/>
</div>
)}
</section>
</div>
);
}
function BriefInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{label}
</span>
<input
value={value}
onChange={e => onChange(e.target.value)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50"
/>
</label>
);
}
function BriefTextarea({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{label}
</span>
<textarea
value={value}
onChange={e => onChange(e.target.value)}
rows={3}
className="w-full resize-y rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50"
/>
</label>
);
}
function BriefText({
label,
value,
mono = false,
tone = 'default',
}: {
label: string;
value?: string | null;
mono?: boolean;
tone?: 'default' | 'danger';
}) {
if (!value) return null;
return (
<div>
<h4 className="mb-1 text-xs font-semibold text-stone-500 dark:text-neutral-400">{label}</h4>
<p
className={`break-words text-sm ${
mono ? 'font-mono' : ''
} ${tone === 'danger' ? 'text-coral-600' : 'text-stone-800 dark:text-neutral-100'}`}>
{value}
</p>
</div>
);
}
function BriefList({
label,
values,
ordered = false,
mono = false,
}: {
label: string;
values?: string[];
ordered?: boolean;
mono?: boolean;
}) {
if (!values?.length) return null;
const List = ordered ? 'ol' : 'ul';
return (
<div>
<h4 className="mb-1 text-xs font-semibold text-stone-500 dark:text-neutral-400">{label}</h4>
<List
className={`space-y-1 ${
ordered ? 'list-decimal' : 'list-disc'
} list-inside text-sm text-stone-800 dark:text-neutral-100 ${mono ? 'font-mono' : ''}`}>
{values.map((value, index) => (
<li key={index} className="break-words">
{value}
</li>
))}
</List>
</div>
);
}
function joinLines(values?: string[]): string {
return values?.join('\n') ?? '';
}
function splitLines(value: string): string[] {
return value
.split('\n')
.map(line => line.trim())
.filter(Boolean);
}
function emptyToNull(value: string): string | null {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
@@ -12,6 +12,13 @@ const board: TaskBoard = {
id: 'task-1',
title: 'Draft plan',
status: 'todo',
objective: 'Prepare the implementation handoff',
plan: ['Read existing board code', 'Update shared card shape'],
assignedAgent: 'planner',
allowedTools: ['todo', 'spawn_subagent'],
approvalMode: 'required',
acceptanceCriteria: ['Schema round-trips'],
evidence: ['unit tests'],
notes: 'Scope frontend and backend work',
order: 0,
updatedAt: '2026-05-04T10:00:05Z',
@@ -36,10 +43,26 @@ describe('TaskKanbanBoard', () => {
expect(screen.getByText('Blocked')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.getByText('Draft plan')).toBeInTheDocument();
expect(screen.getByText('Prepare the implementation handoff')).toBeInTheDocument();
expect(screen.getByText('planner')).toBeInTheDocument();
expect(screen.getByText('approval')).toBeInTheDocument();
expect(screen.getByText('Scope frontend and backend work')).toBeInTheDocument();
expect(screen.getByText('Missing credentials')).toBeInTheDocument();
});
it('opens a task brief with plan, tools, criteria, and evidence', () => {
render(<TaskKanbanBoard board={board} />);
fireEvent.click(screen.getByText('Task brief'));
expect(screen.getByRole('heading', { name: 'Draft plan' })).toBeInTheDocument();
expect(screen.getByText('Required before execution')).toBeInTheDocument();
expect(screen.getByText('Read existing board code')).toBeInTheDocument();
expect(screen.getByText('spawn_subagent')).toBeInTheDocument();
expect(screen.getByText('Schema round-trips')).toBeInTheDocument();
expect(screen.getByText('unit tests')).toBeInTheDocument();
});
it('calls onMove with the next status when a card is moved', () => {
const onMove = vi.fn();
render(<TaskKanbanBoard board={board} onMove={onMove} />);
@@ -49,4 +72,58 @@ describe('TaskKanbanBoard', () => {
expect(onMove).toHaveBeenCalledWith(board.cards[0], 'in_progress');
});
it('lets users edit a task brief and save the updated card', () => {
const onUpdateCard = vi.fn();
render(<TaskKanbanBoard board={board} onUpdateCard={onUpdateCard} />);
fireEvent.click(screen.getAllByText('Task brief')[0]);
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Updated plan' } });
fireEvent.change(screen.getByLabelText('Assigned agent'), {
target: { value: 'code_executor' },
});
fireEvent.change(screen.getByLabelText('Plan'), {
target: { value: 'Inspect files\nPatch UI' },
});
fireEvent.change(screen.getByLabelText('Allowed tools'), {
target: { value: 'todo\nfile_read' },
});
fireEvent.change(screen.getByLabelText('Approval'), { target: { value: 'not_required' } });
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'in_progress' } });
fireEvent.click(screen.getByText('Save changes'));
expect(onUpdateCard).toHaveBeenCalledWith(
board.cards[0],
expect.objectContaining({
title: 'Updated plan',
assignedAgent: 'code_executor',
plan: ['Inspect files', 'Patch UI'],
allowedTools: ['todo', 'file_read'],
approvalMode: 'not_required',
status: 'in_progress',
})
);
});
it('shows not-required approval details and danger tone blockers', () => {
render(
<TaskKanbanBoard
board={{
...board,
cards: [
{
...board.cards[0],
approvalMode: 'not_required',
blocker: 'External dependency is down',
},
],
}}
/>
);
fireEvent.click(screen.getByText('Task brief'));
expect(screen.getByText('Not required')).toBeInTheDocument();
expect(screen.getByText('External dependency is down')).toHaveClass('text-coral-600');
});
});
+8
View File
@@ -12,11 +12,19 @@ export type PersistedTurnPhase = 'thinking' | 'tool_use' | 'subagent';
export type PersistedToolStatus = 'running' | 'success' | 'error';
export type TaskBoardCardStatus = 'todo' | 'in_progress' | 'blocked' | 'done';
export type TaskApprovalMode = 'required' | 'not_required';
export interface TaskBoardCard {
id: string;
title: string;
status: TaskBoardCardStatus;
objective?: string | null;
plan?: string[];
assignedAgent?: string | null;
allowedTools?: string[];
approvalMode?: TaskApprovalMode | null;
acceptanceCriteria?: string[];
evidence?: string[];
notes?: string | null;
blocker?: string | null;
order: number;
+3
View File
@@ -318,6 +318,8 @@ export interface AutonomySettings {
max_actions_per_hour: number;
/** "Always allow" allowlist — tool names the agent runs without a prompt. */
auto_approve: string[];
/** Require approval before an agent executes a task-board plan. */
require_task_plan_approval?: boolean;
}
/** Partial update — omitted fields are left unchanged. */
@@ -331,6 +333,7 @@ export interface AutonomySettingsUpdate {
max_actions_per_hour?: number;
/** Replaces the "Always allow" allowlist wholesale. */
auto_approve?: string[];
require_task_plan_approval?: boolean;
}
export async function openhumanGetAutonomySettings(): Promise<CommandResponse<AutonomySettings>> {
+92
View File
@@ -34,6 +34,22 @@ impl TaskCardStatus {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskApprovalMode {
Required,
NotRequired,
}
impl TaskApprovalMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Required => "required",
Self::NotRequired => "not_required",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskBoardCard {
@@ -41,6 +57,20 @@ pub struct TaskBoardCard {
pub title: String,
pub status: TaskCardStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub objective: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub plan: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_agent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_mode: Option<TaskApprovalMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acceptance_criteria: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocker: Option<String>,
@@ -283,6 +313,20 @@ pub fn normalise_board(board: &mut TaskBoard) {
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
card.objective = card
.objective
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
card.assigned_agent = card
.assigned_agent
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
trim_string_vec(&mut card.plan);
trim_string_vec(&mut card.allowed_tools);
trim_string_vec(&mut card.acceptance_criteria);
trim_string_vec(&mut card.evidence);
card.blocker = card
.blocker
.as_ref()
@@ -328,6 +372,13 @@ fn validate_thread_id(thread_id: &str) -> Result<String, String> {
Ok(trimmed.to_string())
}
fn trim_string_vec(values: &mut Vec<String>) {
values.retain_mut(|value| {
*value = value.trim().to_string();
!value.is_empty()
});
}
#[cfg(test)]
mod tests {
use super::*;
@@ -360,6 +411,13 @@ mod tests {
id: String::new(),
title: " Draft plan ".into(),
status: TaskCardStatus::Todo,
objective: Some(" Ship task briefs ".into()),
plan: vec![" extend schema ".into(), " ".into()],
assigned_agent: Some(" planner ".into()),
allowed_tools: vec![" todo ".into(), "".into()],
approval_mode: Some(TaskApprovalMode::Required),
acceptance_criteria: vec![" tests pass ".into()],
evidence: vec![" cargo test ".into()],
notes: Some(" note ".into()),
blocker: None,
order: 99,
@@ -369,6 +427,13 @@ mod tests {
id: "blocked".into(),
title: "Need approval".into(),
status: TaskCardStatus::Blocked,
objective: None,
plan: Vec::new(),
assigned_agent: None,
allowed_tools: Vec::new(),
approval_mode: None,
acceptance_criteria: Vec::new(),
evidence: Vec::new(),
notes: Some("waiting on user".into()),
blocker: None,
order: 99,
@@ -380,6 +445,19 @@ mod tests {
let saved = store.put(board).expect("put");
assert_eq!(saved.cards[0].title, "Draft plan");
assert_eq!(
saved.cards[0].objective.as_deref(),
Some("Ship task briefs")
);
assert_eq!(saved.cards[0].plan, vec!["extend schema"]);
assert_eq!(saved.cards[0].assigned_agent.as_deref(), Some("planner"));
assert_eq!(saved.cards[0].allowed_tools, vec!["todo"]);
assert_eq!(
saved.cards[0].approval_mode,
Some(TaskApprovalMode::Required)
);
assert_eq!(saved.cards[0].acceptance_criteria, vec!["tests pass"]);
assert_eq!(saved.cards[0].evidence, vec!["cargo test"]);
assert_eq!(saved.cards[0].order, 0);
assert!(saved.cards[0].id.starts_with("task-"));
assert_eq!(saved.cards[1].blocker.as_deref(), Some("waiting on user"));
@@ -419,6 +497,13 @@ mod tests {
id: "empty".into(),
title: " ".into(),
status: TaskCardStatus::Todo,
objective: None,
plan: Vec::new(),
assigned_agent: None,
allowed_tools: Vec::new(),
approval_mode: None,
acceptance_criteria: Vec::new(),
evidence: Vec::new(),
notes: None,
blocker: None,
order: 99,
@@ -428,6 +513,13 @@ mod tests {
id: "real".into(),
title: "Real".into(),
status: TaskCardStatus::Todo,
objective: None,
plan: Vec::new(),
assigned_agent: None,
allowed_tools: Vec::new(),
approval_mode: None,
acceptance_criteria: Vec::new(),
evidence: Vec::new(),
notes: None,
blocker: None,
order: 99,
+4
View File
@@ -377,6 +377,7 @@ pub struct AutonomySettingsPatch {
pub max_actions_per_hour: Option<u32>,
/// "Always allow" allowlist — tool names the gate skips prompting for.
pub auto_approve: Option<Vec<String>>,
pub require_task_plan_approval: Option<bool>,
}
#[derive(Debug, Clone, Default)]
@@ -870,6 +871,9 @@ pub async fn apply_autonomy_settings(
if let Some(auto_approve) = update.auto_approve {
config.autonomy.auto_approve = auto_approve;
}
if let Some(require_task_plan_approval) = update.require_task_plan_approval {
config.autonomy.require_task_plan_approval = require_task_plan_approval;
}
config.save().await.map_err(|e| e.to_string())?;
+5
View File
@@ -41,6 +41,10 @@ pub struct AutonomyConfig {
/// Intended to be enabled only in Full access mode.
#[serde(default)]
pub allow_tool_install: bool,
/// When enabled, an agent-authored task brief must be approved before an
/// assigned agent treats it as executable work.
#[serde(default = "default_true")]
pub require_task_plan_approval: bool,
}
fn default_true() -> bool {
@@ -161,6 +165,7 @@ impl Default for AutonomyConfig {
auto_approve: default_auto_approve(),
trusted_roots: Vec::new(),
allow_tool_install: false,
require_task_plan_approval: default_true(),
}
}
}
+3
View File
@@ -218,6 +218,7 @@ struct AutonomySettingsUpdate {
/// Replaces the "Always allow" allowlist wholesale — tool names the agent
/// may run without an approval prompt. Empty list clears it.
auto_approve: Option<Vec<String>>,
require_task_plan_approval: Option<bool>,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
@@ -615,6 +616,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
comment: "Replace the \"Always allow\" allowlist (array of tool names the agent runs without an approval prompt). Empty array clears it.",
required: false,
},
optional_bool("require_task_plan_approval", "Require approval before an agent executes a task-board plan."),
],
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
},
@@ -1244,6 +1246,7 @@ fn handle_update_autonomy_settings(params: Map<String, Value>) -> ControllerFutu
.max_actions_per_hour
.map(|v| u32::try_from(v).unwrap_or(u32::MAX)),
auto_approve: update.auto_approve,
require_task_plan_approval: update.require_task_plan_approval,
};
to_json(config_rpc::load_and_apply_autonomy_settings(patch).await?)
})
@@ -155,6 +155,13 @@ fn task_board_update_is_stored_and_flushed() {
id: "task-1".into(),
title: "Draft".into(),
status: TaskCardStatus::Todo,
objective: None,
plan: Vec::new(),
assigned_agent: None,
allowed_tools: Vec::new(),
approval_mode: None,
acceptance_criteria: Vec::new(),
evidence: Vec::new(),
notes: None,
blocker: None,
order: 0,
+148 -2
View File
@@ -9,7 +9,7 @@
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::agent::task_board::{
normalise_board, TaskBoard, TaskBoardCard, TaskBoardStore, TaskCardStatus,
normalise_board, TaskApprovalMode, TaskBoard, TaskBoardCard, TaskBoardStore, TaskCardStatus,
};
use chrono::Utc;
use parking_lot::{Mutex, MutexGuard};
@@ -59,6 +59,13 @@ pub struct TodosSnapshot {
pub struct CardPatch {
pub content: Option<String>,
pub status: Option<TaskCardStatus>,
pub objective: Option<String>,
pub plan: Option<Vec<String>>,
pub assigned_agent: Option<String>,
pub allowed_tools: Option<Vec<String>>,
pub approval_mode: Option<Option<TaskApprovalMode>>,
pub acceptance_criteria: Option<Vec<String>>,
pub evidence: Option<Vec<String>>,
pub notes: Option<String>,
pub blocker: Option<String>,
}
@@ -163,6 +170,51 @@ pub fn render_markdown(cards: &[TaskBoardCard]) -> String {
out.push_str(&format!(" `({})`", card.id));
out.push('\n');
if let Some(objective) = card.objective.as_deref() {
out.push_str(" - objective: ");
out.push_str(objective);
out.push('\n');
}
if let Some(agent) = card.assigned_agent.as_deref() {
out.push_str(" - agent: ");
out.push_str(agent);
out.push('\n');
}
if !card.allowed_tools.is_empty() {
out.push_str(" - tools: ");
out.push_str(&card.allowed_tools.join(", "));
out.push('\n');
}
if let Some(mode) = card.approval_mode.as_ref() {
out.push_str(" - approval: ");
out.push_str(mode.as_str());
out.push('\n');
}
if !card.plan.is_empty() {
out.push_str(" - plan:\n");
for step in &card.plan {
out.push_str(" - ");
out.push_str(step);
out.push('\n');
}
}
if !card.acceptance_criteria.is_empty() {
out.push_str(" - acceptance criteria:\n");
for criterion in &card.acceptance_criteria {
out.push_str(" - ");
out.push_str(criterion);
out.push('\n');
}
}
if !card.evidence.is_empty() {
out.push_str(" - evidence:\n");
for item in &card.evidence {
out.push_str(" - ");
out.push_str(item);
out.push('\n');
}
}
if matches!(card.status, TaskCardStatus::Blocked) {
if let Some(reason) = card.blocker.as_deref().or(card.notes.as_deref()) {
out.push_str(" - _blocked:_ ");
@@ -200,6 +252,13 @@ pub fn add(
id: format!("task-{}", Uuid::new_v4()),
title: content.to_string(),
status: patch.status.unwrap_or(TaskCardStatus::Todo),
objective: patch.objective.and_then(non_empty),
plan: patch.plan.unwrap_or_default(),
assigned_agent: patch.assigned_agent.and_then(non_empty),
allowed_tools: patch.allowed_tools.unwrap_or_default(),
approval_mode: patch.approval_mode.flatten(),
acceptance_criteria: patch.acceptance_criteria.unwrap_or_default(),
evidence: patch.evidence.unwrap_or_default(),
notes: patch.notes.and_then(non_empty),
blocker: patch.blocker.and_then(non_empty),
order: cards.len() as u32,
@@ -236,6 +295,27 @@ pub fn edit(location: &BoardLocation, id: &str, patch: CardPatch) -> Result<Todo
if let Some(status) = patch.status {
card.status = status;
}
if let Some(objective) = patch.objective {
card.objective = non_empty(objective);
}
if let Some(plan) = patch.plan {
card.plan = plan;
}
if let Some(assigned_agent) = patch.assigned_agent {
card.assigned_agent = non_empty(assigned_agent);
}
if let Some(allowed_tools) = patch.allowed_tools {
card.allowed_tools = allowed_tools;
}
if let Some(approval_mode) = patch.approval_mode {
card.approval_mode = approval_mode;
}
if let Some(acceptance_criteria) = patch.acceptance_criteria {
card.acceptance_criteria = acceptance_criteria;
}
if let Some(evidence) = patch.evidence {
card.evidence = evidence;
}
if let Some(notes) = patch.notes {
card.notes = non_empty(notes);
}
@@ -405,9 +485,33 @@ mod tests {
fn add_appends_and_returns_markdown() {
let dir = tempdir().unwrap();
let loc = thread_loc(dir.path(), "t1");
let snap = add(&loc, "First task", CardPatch::default()).unwrap();
let snap = add(
&loc,
"First task",
CardPatch {
objective: Some("Ship a richer handoff".into()),
plan: Some(vec![
"Inspect existing board".into(),
"Update schema".into(),
]),
assigned_agent: Some("planner".into()),
allowed_tools: Some(vec!["todo".into(), "spawn_subagent".into()]),
approval_mode: Some(Some(TaskApprovalMode::Required)),
acceptance_criteria: Some(vec!["Tests pass".into()]),
evidence: Some(vec!["cargo test".into()]),
..Default::default()
},
)
.unwrap();
assert_eq!(snap.cards.len(), 1);
assert!(snap.markdown.contains("[ ] First task"));
assert!(snap.markdown.contains("objective: Ship a richer handoff"));
assert!(snap.markdown.contains("agent: planner"));
assert!(snap.markdown.contains("tools: todo, spawn_subagent"));
assert!(snap.markdown.contains("approval: required"));
assert!(snap.markdown.contains("Inspect existing board"));
assert!(snap.markdown.contains("Tests pass"));
assert!(snap.markdown.contains("cargo test"));
assert!(snap.markdown.contains(&snap.cards[0].id));
}
@@ -429,6 +533,34 @@ mod tests {
assert_eq!(snap.cards[0].title, "Refined plan");
}
#[test]
fn edit_can_clear_approval_mode() {
let dir = tempdir().unwrap();
let loc = thread_loc(dir.path(), "t1");
let added = add(
&loc,
"Draft plan",
CardPatch {
approval_mode: Some(Some(TaskApprovalMode::Required)),
..Default::default()
},
)
.unwrap();
let id = added.cards[0].id.clone();
let snap = edit(
&loc,
&id,
CardPatch {
approval_mode: Some(None),
..Default::default()
},
)
.unwrap();
assert_eq!(snap.cards[0].approval_mode, None);
}
#[test]
fn edit_unknown_id_errors() {
let dir = tempdir().unwrap();
@@ -468,6 +600,13 @@ mod tests {
id: "a".into(),
title: "A".into(),
status: TaskCardStatus::InProgress,
objective: None,
plan: Vec::new(),
assigned_agent: None,
allowed_tools: Vec::new(),
approval_mode: None,
acceptance_criteria: Vec::new(),
evidence: Vec::new(),
notes: None,
blocker: None,
order: 0,
@@ -477,6 +616,13 @@ mod tests {
id: "b".into(),
title: "B".into(),
status: TaskCardStatus::InProgress,
objective: None,
plan: Vec::new(),
assigned_agent: None,
allowed_tools: Vec::new(),
approval_mode: None,
acceptance_criteria: Vec::new(),
evidence: Vec::new(),
notes: None,
blocker: None,
order: 1,
+119 -1
View File
@@ -9,7 +9,7 @@ use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::agent::task_board::TaskBoardCard;
use crate::openhuman::agent::task_board::{TaskApprovalMode, TaskBoardCard};
use super::ops::{self, BoardLocation, CardPatch, TodosSnapshot};
@@ -76,6 +76,22 @@ pub fn schemas(function: &str) -> ControllerSchema {
thread_id_input(),
required_string("content", "Card title / description."),
optional_string("status", "Initial status (todo|in_progress|blocked|done)."),
optional_string("objective", "Task objective / desired outcome."),
string_array_input("plan", "Ordered lightweight execution steps."),
optional_string("assignedAgent", "Agent id expected to pick up this task."),
string_array_input(
"allowedTools",
"Task-local allowed tool names or toolkit slugs.",
),
optional_string(
"approvalMode",
"Task approval mode: required | not_required.",
),
string_array_input(
"acceptanceCriteria",
"Checklist required before the task is done.",
),
string_array_input("evidence", "Verification output, links, files, or notes."),
optional_string("notes", "Free-text notes."),
optional_string("blocker", "Reason the card is blocked, if any."),
],
@@ -90,6 +106,22 @@ pub fn schemas(function: &str) -> ControllerSchema {
required_string("id", "Card identifier returned by `add` / `list`."),
optional_string("content", "New title / description."),
optional_string("status", "New status."),
optional_string("objective", "Task objective / desired outcome."),
string_array_input("plan", "Ordered lightweight execution steps."),
optional_string("assignedAgent", "Agent id expected to pick up this task."),
string_array_input(
"allowedTools",
"Task-local allowed tool names or toolkit slugs.",
),
optional_string(
"approvalMode",
"Task approval mode: required | not_required (pass null to clear).",
),
string_array_input(
"acceptanceCriteria",
"Checklist required before the task is done.",
),
string_array_input("evidence", "Verification output, links, files, or notes."),
optional_string("notes", "New notes (pass empty string to clear)."),
optional_string(
"blocker",
@@ -165,6 +197,23 @@ struct AddParams {
#[serde(default)]
status: Option<String>,
#[serde(default)]
objective: Option<String>,
#[serde(default)]
plan: Option<Vec<String>>,
#[serde(default, alias = "assignedAgent")]
assigned_agent: Option<String>,
#[serde(default)]
#[serde(alias = "allowedTools")]
allowed_tools: Option<Vec<String>>,
#[serde(default)]
#[serde(alias = "approvalMode")]
approval_mode: Option<String>,
#[serde(default)]
#[serde(alias = "acceptanceCriteria")]
acceptance_criteria: Option<Vec<String>>,
#[serde(default)]
evidence: Option<Vec<String>>,
#[serde(default)]
notes: Option<String>,
#[serde(default)]
blocker: Option<String>,
@@ -179,6 +228,20 @@ struct EditParams {
#[serde(default)]
status: Option<String>,
#[serde(default)]
objective: Option<String>,
#[serde(default)]
plan: Option<Vec<String>>,
#[serde(default, alias = "assignedAgent")]
assigned_agent: Option<String>,
#[serde(default)]
#[serde(alias = "allowedTools")]
allowed_tools: Option<Vec<String>>,
#[serde(default)]
#[serde(alias = "acceptanceCriteria")]
acceptance_criteria: Option<Vec<String>>,
#[serde(default)]
evidence: Option<Vec<String>>,
#[serde(default)]
notes: Option<String>,
#[serde(default)]
blocker: Option<String>,
@@ -219,6 +282,13 @@ fn handle_add(params: Map<String, Value>) -> ControllerFuture {
let patch = CardPatch {
content: None,
status: p.status.as_deref().map(ops::parse_status).transpose()?,
objective: p.objective,
plan: p.plan,
assigned_agent: p.assigned_agent,
allowed_tools: p.allowed_tools,
approval_mode: Some(parse_approval_mode(p.approval_mode)?),
acceptance_criteria: p.acceptance_criteria,
evidence: p.evidence,
notes: p.notes,
blocker: p.blocker,
};
@@ -229,11 +299,19 @@ fn handle_add(params: Map<String, Value>) -> ControllerFuture {
fn handle_edit(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let approval_mode = approval_mode_patch_from_params(&params)?;
let p = parse::<EditParams>(params)?;
let loc = thread_location(&p.thread_id).await?;
let patch = CardPatch {
content: p.content,
status: p.status.as_deref().map(ops::parse_status).transpose()?,
objective: p.objective,
plan: p.plan,
assigned_agent: p.assigned_agent,
allowed_tools: p.allowed_tools,
approval_mode,
acceptance_criteria: p.acceptance_criteria,
evidence: p.evidence,
notes: p.notes,
blocker: p.blocker,
};
@@ -257,6 +335,37 @@ fn handle_update_status(params: Map<String, Value>) -> ControllerFuture {
})
}
fn parse_approval_mode(raw: Option<String>) -> Result<Option<TaskApprovalMode>, String> {
let Some(raw) = raw else {
return Ok(None);
};
match raw.trim() {
"required" => Ok(Some(TaskApprovalMode::Required)),
"not_required" => Ok(Some(TaskApprovalMode::NotRequired)),
other => Err(format!(
"invalid approval_mode '{other}' (expected required|not_required)"
)),
}
}
fn approval_mode_patch_from_params(
params: &Map<String, Value>,
) -> Result<Option<Option<TaskApprovalMode>>, String> {
let Some(value) = params
.get("approvalMode")
.or_else(|| params.get("approval_mode"))
else {
return Ok(None);
};
if value.is_null() {
return Ok(Some(None));
}
let Some(raw) = value.as_str() else {
return Err("invalid approval_mode type (expected required|not_required|null)".to_string());
};
parse_approval_mode(Some(raw.to_string())).map(Some)
}
fn handle_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = parse::<RemoveParams>(params)?;
@@ -339,6 +448,15 @@ fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
}
}
fn string_array_input(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
comment,
required: false,
}
}
fn snapshot_output() -> FieldSchema {
FieldSchema {
name: "snapshot",
+98 -4
View File
@@ -7,7 +7,7 @@
//! tool falls back to a process-global scratch list. Returns a markdown
//! rendering so transcripts read cleanly.
use crate::openhuman::agent::task_board::{TaskBoardCard, TaskCardStatus};
use crate::openhuman::agent::task_board::{TaskApprovalMode, TaskBoardCard, TaskCardStatus};
use crate::openhuman::inference::provider::thread_context;
use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
@@ -36,8 +36,10 @@ impl Tool for TodoTool {
fn description(&self) -> &str {
"Manage the agent's per-thread task board. Dispatch via the `op` field: \
`add` (content, status?, notes?, blocker?), \
`edit` (id, content?, status?, notes?, blocker?), \
`add` (content, status?, objective?, plan?, assignedAgent?, allowedTools?, \
approvalMode?, acceptanceCriteria?, evidence?, notes?, blocker?), \
`edit` (id, content?, status?, objective?, plan?, assignedAgent?, allowedTools?, \
approvalMode?, acceptanceCriteria?, evidence?, notes?, blocker?), \
`update_status` (id, status), \
`remove` (id), \
`replace` (cards: full list wholesale replace), \
@@ -63,6 +65,32 @@ impl Tool for TodoTool {
},
"notes": { "type": "string" },
"blocker": { "type": "string" },
"objective": { "type": "string", "description": "Desired outcome for this task." },
"plan": {
"type": "array",
"description": "Ordered lightweight execution steps.",
"items": { "type": "string" }
},
"assignedAgent": { "type": "string", "description": "Agent id expected to pick up this task." },
"allowedTools": {
"type": "array",
"description": "Task-local tool names or toolkit slugs the assigned agent may use.",
"items": { "type": "string" }
},
"approvalMode": {
"type": ["string", "null"],
"enum": ["required", "not_required", null]
},
"acceptanceCriteria": {
"type": "array",
"description": "Checklist that must be true before the task is done.",
"items": { "type": "string" }
},
"evidence": {
"type": "array",
"description": "Verification output, links, files, or notes produced while executing the task.",
"items": { "type": "string" }
},
"cards": {
"type": "array",
"description": "Full card list for op=replace.",
@@ -91,7 +119,10 @@ impl Tool for TodoTool {
let result = match op.as_str() {
"add" => {
let content = required_string(&args, "content")?;
let patch = patch_from_args(&args)?;
let mut patch = patch_from_args(&args)?;
if patch.approval_mode.is_none() {
patch.approval_mode = Some(default_task_approval_mode().await);
}
ops::add(&location, &content, patch)
}
"edit" => {
@@ -141,6 +172,23 @@ impl Tool for TodoTool {
}
}
async fn default_task_approval_mode() -> Option<TaskApprovalMode> {
match crate::openhuman::config::ops::load_config_with_timeout().await {
Ok(config) => Some(if config.autonomy.require_task_plan_approval {
TaskApprovalMode::Required
} else {
TaskApprovalMode::NotRequired
}),
Err(err) => {
tracing::debug!(
error = %err,
"[tool][todo] failed to load config for task approval default"
);
None
}
}
}
fn current_location() -> BoardLocation {
let Some(parent) = crate::openhuman::agent::harness::fork_context::current_parent() else {
return BoardLocation::Scratch;
@@ -177,14 +225,60 @@ fn patch_from_args(args: &serde_json::Value) -> anyhow::Result<CardPatch> {
Some(s) => Some(ops::parse_status(s).map_err(anyhow::Error::msg)?),
None => None,
};
let approval_mode = match args.get("approvalMode") {
Some(value) if value.is_null() => Some(None),
Some(value) => match value.as_str() {
Some("required") => Some(Some(TaskApprovalMode::Required)),
Some("not_required") => Some(Some(TaskApprovalMode::NotRequired)),
Some(other) => {
return Err(anyhow::anyhow!(
"invalid approvalMode '{other}' (expected required|not_required|null)"
))
}
None => {
return Err(anyhow::anyhow!(
"invalid approvalMode type (expected required|not_required|null)"
))
}
},
None => None,
};
Ok(CardPatch {
content: None,
status,
objective: optional_string(args, "objective"),
plan: optional_string_array(args, "plan")?,
assigned_agent: optional_string(args, "assignedAgent"),
allowed_tools: optional_string_array(args, "allowedTools")?,
approval_mode,
acceptance_criteria: optional_string_array(args, "acceptanceCriteria")?,
evidence: optional_string_array(args, "evidence")?,
notes: optional_string(args, "notes"),
blocker: optional_string(args, "blocker"),
})
}
fn optional_string_array(
args: &serde_json::Value,
key: &str,
) -> anyhow::Result<Option<Vec<String>>> {
let Some(value) = args.get(key) else {
return Ok(None);
};
let values = value
.as_array()
.ok_or_else(|| anyhow::anyhow!("`{key}` must be an array of strings"))?;
values
.iter()
.map(|item| {
item.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("`{key}` must be an array of strings"))
})
.collect::<anyhow::Result<Vec<_>>>()
.map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
+230 -3
View File
@@ -1726,6 +1726,215 @@ async fn json_rpc_thread_turn_state_lifecycle() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_task_board_brief_roundtrips_across_todos_and_threads_rpc() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await;
let api_origin = format!("http://{api_addr}");
write_min_config(openhuman_home.as_path(), &api_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
let thread_id = "thread-task-brief-e2e";
let added = post_json_rpc(
&rpc_base,
9201,
"openhuman.todos_add",
json!({
"thread_id": thread_id,
"content": " Draft implementation plan ",
"status": "todo",
"objective": " Ship richer task briefs ",
"plan": [" Inspect board store ", " Patch RPC shape ", ""],
"assignedAgent": " planner ",
"allowedTools": [" todo ", "spawn_subagent", ""],
"approvalMode": "required",
"acceptanceCriteria": [" Brief survives JSON-RPC ", " UI can save edits "],
"evidence": [" cargo test --test json_rpc_e2e task_board "],
"notes": "initial note"
}),
)
.await;
let added_result = assert_no_jsonrpc_error(&added, "todos_add task brief");
let added_cards = added_result
.get("cards")
.and_then(Value::as_array)
.expect("todos_add cards");
assert_eq!(added_cards.len(), 1);
let task_id = added_cards[0]
.get("id")
.and_then(Value::as_str)
.expect("generated task id")
.to_string();
assert_eq!(added_cards[0]["title"], "Draft implementation plan");
assert_eq!(added_cards[0]["objective"], "Ship richer task briefs");
assert_eq!(added_cards[0]["assignedAgent"], "planner");
assert_eq!(
added_cards[0]["allowedTools"],
json!(["todo", "spawn_subagent"])
);
assert_eq!(added_cards[0]["approvalMode"], "required");
assert!(
added_result
.get("markdown")
.and_then(Value::as_str)
.unwrap_or_default()
.contains("approval: required"),
"markdown should include task approval metadata: {added_result}"
);
let edited = post_json_rpc(
&rpc_base,
9202,
"openhuman.todos_edit",
json!({
"thread_id": thread_id,
"id": task_id,
"content": "Implement editable task briefs",
"status": "in_progress",
"objective": "Let users refine agent handoff data",
"plan": ["Open the brief", "Edit fields", "Persist through core"],
"assignedAgent": "code_executor",
"allowedTools": ["todo", "file_read", "edit_file"],
"approvalMode": "not_required",
"acceptanceCriteria": ["Saved board contains edited fields"],
"evidence": ["focused vitest passed"],
"notes": "",
"blocker": ""
}),
)
.await;
let edited_result = assert_no_jsonrpc_error(&edited, "todos_edit task brief");
let edited_card = &edited_result["cards"][0];
assert_eq!(edited_card["title"], "Implement editable task briefs");
assert_eq!(edited_card["status"], "in_progress");
assert_eq!(edited_card["assignedAgent"], "code_executor");
assert_eq!(edited_card["approvalMode"], "not_required");
assert_eq!(
edited_card["plan"],
json!(["Open the brief", "Edit fields", "Persist through core"])
);
assert!(
edited_card.get("notes").is_none(),
"empty notes should clear"
);
let cleared_approval = post_json_rpc(
&rpc_base,
9207,
"openhuman.todos_edit",
json!({
"thread_id": thread_id,
"id": task_id,
"approvalMode": null
}),
)
.await;
let cleared_result =
assert_no_jsonrpc_error(&cleared_approval, "todos_edit clears approvalMode");
assert!(
cleared_result["cards"][0].get("approvalMode").is_none(),
"null approvalMode should clear the optional field: {cleared_result}"
);
let thread_get = post_json_rpc(
&rpc_base,
9203,
"openhuman.threads_task_board_get",
json!({ "thread_id": thread_id }),
)
.await;
let thread_get_result = assert_no_jsonrpc_error(&thread_get, "threads_task_board_get");
let board = thread_get_result
.get("taskBoard")
.expect("taskBoard in threads get");
assert_eq!(board["threadId"], thread_id);
assert_eq!(board["cards"][0]["title"], "Implement editable task briefs");
assert!(
board["cards"][0].get("approvalMode").is_none(),
"cleared approvalMode should persist through threads get: {board}"
);
let mut cards = board
.get("cards")
.and_then(Value::as_array)
.expect("cards in board")
.clone();
cards[0]["evidence"] = json!(["focused vitest passed", "json_rpc_e2e persisted UI edit"]);
cards[0]["acceptanceCriteria"] = json!(["Core and UI save paths agree"]);
let replaced = post_json_rpc(
&rpc_base,
9204,
"openhuman.threads_task_board_put",
json!({
"thread_id": thread_id,
"cards": cards
}),
)
.await;
let replaced_result = assert_no_jsonrpc_error(&replaced, "threads_task_board_put rich board");
assert_eq!(
replaced_result["taskBoard"]["cards"][0]["evidence"],
json!(["focused vitest passed", "json_rpc_e2e persisted UI edit"])
);
let listed = post_json_rpc(
&rpc_base,
9205,
"openhuman.todos_list",
json!({ "thread_id": thread_id }),
)
.await;
let listed_result = assert_no_jsonrpc_error(&listed, "todos_list after threads put");
assert_eq!(
listed_result["cards"][0]["acceptanceCriteria"],
json!(["Core and UI save paths agree"])
);
assert!(
listed_result
.get("markdown")
.and_then(Value::as_str)
.unwrap_or_default()
.contains("json_rpc_e2e persisted UI edit"),
"todos markdown should render evidence after threads put: {listed_result}"
);
let invalid_approval = post_json_rpc(
&rpc_base,
9206,
"openhuman.todos_add",
json!({
"thread_id": thread_id,
"content": "Invalid approval",
"approvalMode": "sometimes"
}),
)
.await;
let invalid_err = assert_jsonrpc_error(&invalid_approval, "todos_add invalid approvalMode");
let invalid_msg = invalid_err
.get("message")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
invalid_msg.contains("approval_mode") && invalid_msg.contains("required|not_required"),
"expected approvalMode validation error, got: {invalid_err}"
);
api_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_memory_sync_and_learn() {
let _env_lock = json_rpc_e2e_env_lock();
@@ -7645,6 +7854,10 @@ async fn json_rpc_config_autonomy_settings_roundtrip() {
.get("result")
.and_then(|r| r.get("max_actions_per_hour"))
.and_then(Value::as_u64);
let initial_task_approval = initial_outer
.get("result")
.and_then(|r| r.get("require_task_plan_approval"))
.and_then(Value::as_bool);
// Default is `u32::MAX` (functionally unlimited) — fresh installs should
// not be rate-limited until the user opts into a ceiling. See the
// autonomy schema for the rationale.
@@ -7653,18 +7866,23 @@ async fn json_rpc_config_autonomy_settings_roundtrip() {
Some(u32::MAX as u64),
"expected default u32::MAX (unlimited), got envelope: {initial_outer}"
);
assert_eq!(
initial_task_approval,
Some(true),
"task plan approval should default on, got envelope: {initial_outer}"
);
// UPDATE → 250.
// UPDATE → 250, and disable task-plan approval.
let update = post_json_rpc(
&rpc_base,
7002,
"openhuman.config_update_autonomy_settings",
json!({ "max_actions_per_hour": 250 }),
json!({ "max_actions_per_hour": 250, "require_task_plan_approval": false }),
)
.await;
assert_no_jsonrpc_error(&update, "update_autonomy_settings");
// GET again → expect 250.
// GET again → expect 250 and disabled task-plan approval.
let after = post_json_rpc(
&rpc_base,
7003,
@@ -7677,11 +7895,20 @@ async fn json_rpc_config_autonomy_settings_roundtrip() {
.get("result")
.and_then(|r| r.get("max_actions_per_hour"))
.and_then(Value::as_u64);
let after_task_approval = after_outer
.get("result")
.and_then(|r| r.get("require_task_plan_approval"))
.and_then(Value::as_bool);
assert_eq!(
after_value,
Some(250),
"expected 250 after update, got envelope: {after_outer}"
);
assert_eq!(
after_task_approval,
Some(false),
"expected task plan approval to persist as disabled, got envelope: {after_outer}"
);
// Invalid value rejected — server returns JSON-RPC error envelope, not a result.
// Upper bound was lifted to u32::MAX (the new "unlimited" sentinel that the