mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Enrich agent task board workflow (#2891)
Co-authored-by: cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
This commit is contained in:
co-authored by
cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
parent
b72877445b
commit
3e59548f12
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(¶ms)?;
|
||||
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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user