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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/changed-issue-4153.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- CoS tasks: split the overloaded `metadata.context` into `metadata.prompt` (the full agent-facing payload) and `metadata.context` (a one-line human note), with a reader fallback for pre-split tasks and a migration for existing queues (#4153)
47 changes: 39 additions & 8 deletions client/src/components/cos/tabs/TaskItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,16 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
setEditingInternal(val);
onEditingChange?.(val);
}, [onEditingChange]);
// A task written since the #4153 split keeps its full agent-facing payload in
// `metadata.prompt` and only a short human note in `metadata.context`. Legacy
// tasks (and peers still on the old code) have no `prompt` at all — their
// payload is still in `context` — so the Prompt field is offered ONLY when the
// task actually carries one, and is omitted from the PATCH otherwise rather
// than writing an empty `prompt` key onto every task the user edits.
const hasPromptField = typeof task.metadata?.prompt === 'string';
const [editData, setEditData] = useState({
description: task.description,
prompt: task.metadata?.prompt || '',

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPROVEMENT] This useState initializer runs only on mount. TasksTab keeps TaskItem keyed by id, so a live refresh or federation update can add metadata.prompt to a mounted legacy task while editData.prompt remains ''. hasPromptField then becomes true, and saving any edit sends that stale empty prompt via the payload at line 184, clearing the agent body. Re-seed the draft when the task changes while not actively editing (or when edit mode opens), and add a regression test for this prop transition.

context: task.metadata?.context || '',
model: task.metadata?.model || '',
provider: task.metadata?.provider || ''
Expand Down Expand Up @@ -172,7 +180,9 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
};

const handleSave = async () => {
const result = await api.updateCosTask(task.id, { ...editData, type: taskSource }, { silent: true }).catch(err => {
const { prompt, ...rest } = editData;
const payload = hasPromptField ? { ...rest, prompt, type: taskSource } : { ...rest, type: taskSource };
const result = await api.updateCosTask(task.id, payload, { silent: true }).catch(err => {
toast.error(err.message);
return null;
});
Expand All @@ -187,6 +197,7 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
// changed something, so an unmodified Cancel still discards with no friction.
const hasUnsavedEdits =
editData.description !== task.description ||
(hasPromptField && editData.prompt !== (task.metadata?.prompt || '')) ||
editData.context !== (task.metadata?.context || '') ||
editData.model !== (task.metadata?.model || '') ||
editData.provider !== (task.metadata?.provider || '');
Expand All @@ -207,6 +218,7 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
confirmDiscard(() => {
setEditData({
description: task.description,
prompt: task.metadata?.prompt || '',
context: task.metadata?.context || '',
model: task.metadata?.model || '',
provider: task.metadata?.provider || ''
Expand Down Expand Up @@ -339,13 +351,24 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm"
/>
{/* A textarea, not an input: for orchestrator tasks the context
holds the task's entire multi-line prompt, which is unreadable
and unnavigable in a single-line field. Bounded rows + its own
{/* A textarea, not an input: for orchestrator tasks this holds the
task's entire multi-line prompt, which is unreadable and
unnavigable in a single-line field. Bounded rows + its own
scroll so editing a long prompt doesn't stretch the card. */}
{hasPromptField && (
<textarea
rows={4}
placeholder="Prompt"
aria-label="Task prompt"
value={editData.prompt}
onChange={e => setEditData(d => ({ ...d, prompt: e.target.value }))}
className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm font-mono resize-y overflow-auto"
/>
)}
<textarea
rows={4}
placeholder="Context"
aria-label="Task context"
value={editData.context}
onChange={e => setEditData(d => ({ ...d, context: e.target.value }))}
className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm font-mono resize-y overflow-auto"
Expand Down Expand Up @@ -407,10 +430,18 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
text={task.description}
className="text-white"
/>
{/* The context often carries the task's full prompt (hundreds of
lines for orchestrator tasks), so it gets the same clamp as the
description — an unclamped one turns the pending list into a
wall of text the user has to scroll past. */}
{/* The prompt runs to hundreds of lines for orchestrator tasks, so
it gets the same clamp as the description — an unclamped one
turns the pending list into a wall of text the user has to
scroll past. The note below it gets the same treatment, since a
legacy task still carries its payload there. */}
{task.metadata?.prompt && (
<CollapsibleText
id={`task-prompt-${idScope}-${task.id}`}
text={task.metadata.prompt}
className="text-sm text-gray-500 mt-1"
/>
)}
{task.metadata?.context && (
<CollapsibleText
id={`task-context-${idScope}-${task.id}`}
Expand Down
62 changes: 62 additions & 0 deletions client/src/components/cos/tabs/TaskItem.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,68 @@ describe('TaskItem long-text clamping', () => {
});
});

// #4153 — the agent-facing payload moved to `metadata.prompt`; `metadata.context`
// is now the one-line human note. A task written before the split has no
// `prompt` at all, so the field must not appear (and must not be PATCHed) then.
describe('TaskItem prompt field (#4153)', () => {
const split = {
...task,
id: 'sys-split',
metadata: { prompt: 'the agent body\nsecond line', context: 'a short note' },
};

it('renders the prompt and the note as separate blocks', () => {
render(<TaskItem task={split} isSystem onRefresh={vi.fn()} providers={providers} />);
expect(screen.getByText(/the agent body/)).toBeInTheDocument();
expect(screen.getByText('a short note')).toBeInTheDocument();
});

it('offers a Prompt textarea in edit mode and PATCHes the edit', async () => {
const onRefresh = vi.fn();
render(<TaskItem task={split} isSystem onRefresh={onRefresh} providers={providers} />);

fireEvent.click(screen.getByRole('button', { name: 'Edit task' }));
const promptField = screen.getByPlaceholderText('Prompt');
expect(promptField.tagName).toBe('TEXTAREA');
expect(promptField).toHaveValue('the agent body\nsecond line');

fireEvent.change(promptField, { target: { value: 'rewritten body' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() => expect(api.updateCosTask).toHaveBeenCalledWith(
'sys-split',
expect.objectContaining({ prompt: 'rewritten body', context: 'a short note' }),
{ silent: true }
));
});

it('shows the confirm row when only the prompt field changed', () => {
render(<TaskItem task={split} isSystem onRefresh={vi.fn()} providers={providers} />);
fireEvent.click(screen.getByRole('button', { name: 'Edit task' }));
fireEvent.change(screen.getByPlaceholderText('Prompt'), { target: { value: 'edited' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByRole('group', { name: 'Confirm discard task edits' })).toBeInTheDocument();
});

// Writing `prompt: ''` onto every legacy task the user edits would create an
// empty key the markdown store then serializes — absent must stay absent.
it('omits the Prompt field, and the prompt key, for a legacy context-only task', async () => {
const legacy = { ...task, id: 'sys-legacy', metadata: { context: 'legacy body' } };
render(<TaskItem task={legacy} isSystem onRefresh={vi.fn()} providers={providers} />);

fireEvent.click(screen.getByRole('button', { name: 'Edit task' }));
expect(screen.queryByPlaceholderText('Prompt')).not.toBeInTheDocument();

fireEvent.change(screen.getByPlaceholderText('Context'), { target: { value: 'edited note' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() => expect(api.updateCosTask).toHaveBeenCalled());
const [, payload] = api.updateCosTask.mock.calls.at(-1);
expect(payload).not.toHaveProperty('prompt');
expect(payload.context).toBe('edited note');
});
});

describe('TaskItem cancel-edit confirmation (#4037)', () => {
it('discards immediately when Cancel is clicked with no unsaved changes', () => {
render(<TaskItem task={task} isSystem onRefresh={vi.fn()} providers={providers} />);
Expand Down
218 changes: 218 additions & 0 deletions scripts/migrations/270-cos-task-prompt-split.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* Migration 270 — move on-disk CoS task PROMPT payloads out of `metadata.context`
* and into `metadata.prompt` (issue #4153).
*
* Background:
* `metadata.context` carried two unrelated kinds of content: a one-line human
* note, and a multi-thousand-character agent prompt (the generator's Phase 1–7
* body, a `/do:*` claim prompt, a repo-study brief). The prompt landed there
* because `generateTasksMarkdown` flattens `description` onto one line, so a
* multi-line description would corrupt the queue file — `metadata.context` was
* the newline-escaped escape hatch that survived serialization.
*
* The two are now separate fields (`server/lib/cosTaskPrompt.js`), and
* `cosTaskStore.addTask` routes new tasks at write time. This migration does
* the same for the tasks already queued on this install.
*
* What it writes:
* `data/TASKS.md` and `data/COS-TASKS.md` — renames the `- context: …`
* metadata line to `- prompt: …` on every task whose context value is a PROMPT
* payload, and re-stamps `updatedAt` on each so the migrated copy wins the
* last-write federation merge against a peer that hasn't migrated yet
* (`pickContentBase` in `cosTaskMerge.js` breaks an equal-status tie on that
* stamp). `PORTOS_SCHEMA_VERSIONS.cosTasks` is bumped to 5 in the same change,
* so a not-yet-upgraded peer skips cos-task sync rather than receiving a
* `metadata.prompt` its prompt builder cannot read.
*
* The classification is `isPromptPayload` from `server/lib/cosTaskPrompt.js` —
* the SAME predicate the store's write path uses, so a task can't be sorted
* one way at creation and the other way here. A single-line context is a note
* and is left exactly where it is.
*
* A text-level rewrite rather than a parse/regenerate round-trip (the choice
* migration 234 made for these files): regenerating would reorder, re-escape
* and re-sort every task in the user's live queue.
*
* Safety:
* Under-migrating is harmless — every reader goes through `getTaskPrompt`,
* which falls back to `metadata.context`. So a task this skips (or an install
* that never runs it) keeps resolving correctly; the rewrite just makes the
* file on disk say what the code means.
*
* Idempotent: a task that already carries a `prompt:` line, or whose context is
* a one-line note, is skipped — so a second run changes nothing and the files
* are left untouched when there is nothing to do.
*/

import { readFile } from 'fs/promises';
import { join } from 'path';
import { atomicWrite } from '../../server/lib/fileUtils.js';
import { isPromptPayload, TASK_PROMPT_KEY, TASK_CONTEXT_KEY } from '../../server/lib/cosTaskPrompt.js';

// The task header line, per `server/lib/taskParser.js`. Both spellings — with
// and without the AUTO/APPROVAL flag — since internal tasks carry it and the
// legacy shape does not.
const TASK_LINE = /^-\s*\[([ x~!?])\]\s*#([\w-]+)\s*\|\s*(?:CRITICAL|HIGH|MEDIUM|LOW)\s*\|\s*(?:(?:AUTO|APPROVAL)\s*\|\s*)?(.+)$/i;
const METADATA_LINE = /^(\s+)-\s*(\w+):\s*(.*)$/;

// Sentinel prefix `taskParser.js#escapeNewlines` writes in front of a
// JSON-encoded metadata value. Every multi-line value written since that
// encoding landed carries it; older ones use bare `\n` escapes.
const JSON_SENTINEL = '__json__:';

const QUEUE_FILES = [
{ configKey: 'userTasksFile', file: 'data/TASKS.md' },
{ configKey: 'cosTasksFile', file: 'data/COS-TASKS.md' },
];

/**
* Decode a persisted metadata value back to the string a reader would see.
* Mirrors `unescapeNewlines` in `server/lib/taskParser.js` — the sentinel form
* first, then the legacy bare-`\n` form. Returns null for anything that does not
* decode to a string, so a JSON-encoded array/object can never be reclassified.
*/
function decodeMetadataValue(raw) {
if (raw.startsWith(JSON_SENTINEL)) {
let parsed;
try { parsed = JSON.parse(raw.slice(JSON_SENTINEL.length)); } catch { return null; }
return typeof parsed === 'string' ? parsed : null;
}
if (raw === 'null' || raw === 'undefined') return null;
return raw.replace(/\\n/g, '\n');
}

/**
* Rename the prompt-carrying `context:` metadata line to `prompt:` on every task
* that has one, re-stamping `updatedAt` on the ones changed. Pure — exported for
* the test.
*
* @param {string} markdown raw TASKS.md / COS-TASKS.md
* @param {{ stamp: string }} options
* @returns {{ markdown: string, split: string[] }} ids of the tasks rewritten
*/
export function splitPromptMetadata(markdown, { stamp }) {
const lines = markdown.split('\n');
const out = [];
const split = [];
// Id of the task whose block we are currently inside.
let taskId = null;
// Indices in `out` of the current task's context / prompt / updatedAt / last
// metadata lines, so the rewrite happens once the whole block has been seen.
let contextAt = -1;
let hasPrompt = false;
let stampAt = -1;
let lastMetaAt = -1;
let indent = ' ';

// Rewrite the block we just finished walking. No-op unless it carried a
// prompt payload under `context` and no `prompt` of its own.
const finish = () => {
if (taskId && contextAt >= 0 && !hasPrompt) {
const meta = out[contextAt].match(METADATA_LINE);
out[contextAt] = `${meta[1]}- ${TASK_PROMPT_KEY}: ${meta[3]}`;
const stampLine = `${indent}- updatedAt: ${stamp}`;
if (stampAt >= 0) out[stampAt] = stampLine;
// No stamp yet — insert directly after the last metadata line rather than
// at the end of the block, so it can't land after a description that
// spilled onto its own lines (see the block-scan note below).
else out.splice(lastMetaAt + 1, 0, stampLine);
split.push(taskId);
}
taskId = null;
contextAt = -1;
hasPrompt = false;
stampAt = -1;
lastMetaAt = -1;
};

for (const line of lines) {
const header = line.match(TASK_LINE);
// A task's block runs to the NEXT task header — NOT to the first
// non-metadata line, and NOT to a `#` heading. Two reasons, both load-bearing
// for exactly the payloads this migration targets:
// - A description written with embedded newlines is interpolated into the
// file verbatim by `generateTasksMarkdown`, so a freshly-filed task can
// carry blank/prose lines between its header and its metadata (they are
// dropped on the next parse round-trip, but a migration may well run
// before that happens).
// - Those spilled lines are usually MARKDOWN HEADINGS (`## Phase 1` — the
// generator's Phase 1–7 body is the canonical case). Ending the block at
// a `#` line would walk right past the `context:` line below it.
// `parseTasksMarkdown` does the same: a `##` section heading advances the
// section but never clears `currentTask`, so metadata after one still
// attaches to the preceding task. Mirror it, or this rewrite and the parser
// would disagree about which task owns a line.
if (header) {
finish();
out.push(line);
taskId = header[2];
continue;
}
const meta = taskId ? line.match(METADATA_LINE) : null;
if (meta) {
indent = meta[1];
lastMetaAt = out.length;
// Normalize the key exactly as `parseMetadataLine` does — legacy
// Title-Case keys (`Context`, `Prompt`) are real on older installs and
// read back as the camelCase key, so a case-sensitive compare here would
// skip them, record the migration applied, and leave them unsplit forever.
const key = meta[2].charAt(0).toLowerCase() + meta[2].slice(1);
if (key === TASK_PROMPT_KEY) hasPrompt = true;
else if (key === TASK_CONTEXT_KEY) {
const decoded = decodeMetadataValue(meta[3].trim());
if (isPromptPayload(decoded)) contextAt = out.length;
} else if (key === 'updatedAt') stampAt = out.length;
}
out.push(line);
}
finish();

return { markdown: out.join('\n'), split };
}

/**
* Repo-relative path of a queue file, honouring an install that moved it in
* `data/cos/state.json` (`config.userTasksFile` / `config.cosTasksFile` — the
* same values `cosTaskStore` reads). Migrating only the defaults would record
* this migration as applied while the live queue stayed unsplit.
*/
async function queuePaths(rootDir) {
const raw = await readFile(join(rootDir, 'data', 'cos', 'state.json'), 'utf-8').catch(() => null);
const config = raw ? JSON.parse(raw)?.config ?? {} : {};
return QUEUE_FILES.map(({ configKey, file }) => (
typeof config[configKey] === 'string' && config[configKey] ? config[configKey] : file
));
}

export default {
async up({ rootDir, now = new Date().toISOString() }) {
const split = [];
let seenAFile = false;

for (const relPath of await queuePaths(rootDir)) {
const file = join(rootDir, relPath);
const raw = await readFile(file, 'utf-8').catch((err) => {
if (err.code === 'ENOENT') return null;
throw err;
});
if (raw == null) continue;
seenAFile = true;

const result = splitPromptMetadata(raw, { stamp: now });
if (!result.split.length) continue;
await atomicWrite(file, result.markdown);
split.push(...result.split);
}

if (!seenAFile) {
console.log('🧩 migration 270: no task queue on this install — nothing to split');
return { ok: true, reason: 'no-task-file' };
}
if (!split.length) {
console.log('🧩 migration 270: no task carries a prompt payload under `context`');
return { ok: true, reason: 'already-split', split: 0 };
}
console.log(`🧩 migration 270: moved ${split.length} task prompt payload(s) from metadata.context to metadata.prompt`);
return { ok: true, split: split.length };
},
};
Loading