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
10 changes: 5 additions & 5 deletions .kilo/skills/kilo-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ They do not expose API `search` or `call`.
1. Launch only when the user explicitly requests a Cloud Agent session. Children have independent sandboxes/worktrees; use local tools for this worktree.
2. Resolve the requested model or established user preference with `search_models`; copy its exact `id`. Otherwise browse with `{}` and ask the user to choose. No implicit default: `isPreferred` is a recommendation. Omit `variant` unless requested; pass only a supported variant separately. Never guess or substitute IDs. `alias_target` names a floating alias's target; discovery does not prove policy approval.
3. Call `spawn_session` with repository, self-contained task prompt, mode, exact model ID, and fresh UUID `operationKey`. Include constraints, verification, and Git/PR instructions. It starts immediately with multi-chat support. Organization and `createdOnPlatform` are inherited, not caller inputs.
4. Set `callbackTarget: { "url": "session:parent" }` for completion/failure/interruption messages; omit for none. Prefer callbacks over polling; never wait in a tool for one.
4. Set `callbackTarget` to `{"url":"workspace:new-chat"}` (parent workspace) or `{"url":"workspace:<session-id>"}` (this chat); omit for none. Prefer callbacks.
5. `list_child_sessions` recovers IDs and `initialMessageId` when recorded; `get_session` checks status; `get_message_result` reads outcomes. Before a new authorized follow-up, call `generate_message_id({})` and copy its `messageId` into `send_message`. This generates no work. Retain it for retries; never replace it after an uncertain send. Initial tasks use `initialMessageId`; report if absent, never invent it. Admission, queued/running, and missing results are not completion. `interrupt_session` is for authorized Stop.
6. `list_folders` discovers existing folder IDs, not workspace membership. Use `assign_workspace_to_folder` with `folderId` and the child's `cloudAgentSessionId`/`worktreeId` from listing or status. It files the whole worktree, including sibling chats; `folderId: null` unfiles it. Only this parent's child workspaces are movable. Folder creation, editing, deletion, and reordering are unavailable.
7. After an ambiguous mutation failure, reconcile state before retrying. Keep the same `operationKey` or follow-up `messageId` for that same submission.
8. Omitted `autoCommit` requests no automatic commit/push; follow-ups do not inherit it. Set `true` per authorized message. If publication is forbidden, set `false` and prohibit it in the prompt: `false` cannot restrict agent Git tools or cancel another prompt's request. Request PR creation in the prompt.

Authority/callbacks belong to the canonical parent (original root chat), even from
siblings. Only direct children are manageable, never unrelated sessions or the
parent. No grandchildren.
Callbacks go to the child's canonical parent workspace: `workspace:new-chat` opens
a sibling chat there; `workspace:<session-id>` queues into an existing chat. Manage
only direct children, never the parent. No grandchildren.

### Examples

Expand All @@ -67,7 +67,7 @@ const child = await spawn_session({
githubRepo: 'owner/repo', model: modelId, mode: 'code',
prompt: 'Fix the bug, run focused tests, and report. Do not commit, push, or create a PR.',
operationKey: launchUuid, autoCommit: false,
callbackTarget: { url: 'session:parent' },
callbackTarget: { url: 'workspace:new-chat' },
});
```

Expand Down
11 changes: 10 additions & 1 deletion .specs/cloud-agent-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,16 @@ repository.
MUST check current canonical-parent authorization and persisted child
lineage, owner and organization. It MUST NOT manage the parent itself,
sibling chats or unrelated sessions. Child completion callbacks MUST target
the canonical workspace parent, not an arbitrary invoking sibling.
either a new chat created in the parent's workspace
(`workspace:new-chat`) or one existing chat in that workspace
(`workspace:<session-id>`). The server MUST verify that a session target is
a root or sibling chat with the same owner, organization and workspace, and
that it is still accessible; it MUST reject a target outside the spawning
parent's workspace or any target that is a child session. The target chat
learns its own session id from the `cloud-agent-context` line in its turns.
A `workspace:new-chat` callback starts the new chat with the child result as
its first message, and every workspace callback MUST be idempotent for the
persisted callback message ID.

### Watch PR (Observation-Only Pilot)

Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/components/agents/collect-copyable-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {

import { i18n } from '@/i18n';

import { humanTextParts } from './part-types';

type CopyableMessage = {
parts: readonly Part[];
};
Expand Down Expand Up @@ -62,10 +64,11 @@ function collectToolPartText(part: ToolPart): string {
* The message's own prose. The Copy message action reads only these parts:
* the thinking block the transcript shows above a reply and the tool calls it
* shows between its paragraphs are transcript chrome, not message text, so
* they never reach the clipboard ahead of or around the reply.
* they never reach the clipboard ahead of or around the reply. Synthetic
* server context is chrome too and is dropped when a human text part exists.
*/
export function messageTextParts(parts: readonly Part[]): Part[] {
return parts.filter((part): part is TextPart => isTextPart(part));
return humanTextParts(parts);
}

/**
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,45 @@ describe('MessageBubble copy-to-composer human text', () => {
expect(onCopyToComposer).toHaveBeenCalledWith('prompt');
});

it('passes the prompt, not injected server context, to Copy', async () => {
const message = userMessage('m-copy-context');
message.parts = [
{
id: 'm-copy-context-synthetic',
sessionID: 'ses_1',
messageID: 'm-copy-context',
type: 'text',
text: '<cloud-agent-context session-id="workspace_123" />',
synthetic: true,
},
{
id: 'm-copy-context-prompt',
sessionID: 'ses_1',
messageID: 'm-copy-context',
type: 'text',
text: 'prompt',
},
] as typeof message.parts;

const onCopyToComposer = vi.fn<(text: string) => void>();
const tree = await renderBubbleWithHandlers(message, {
deliveryState: { status: 'failed', error: 'nope', reason: 'exhausted' },
onCopyToComposer,
});

const copy = findElementByType(
tree,
'Button',
p => p.accessibilityLabel === 'Copy to composer'
);
expect(copy).not.toBeNull();
if (!copy) {
throw new Error('expected Copy to composer button');
}
(copy.props.onPress as () => void)();
expect(onCopyToComposer).toHaveBeenCalledWith('prompt');
});

it('hides Copy for a file-only failed row', async () => {
const message = userMessage('m-copy-file');
message.parts = [
Expand Down Expand Up @@ -788,6 +827,35 @@ describe('MessageBubble user text join', () => {
expect(element?.props.value as string).toContain('\n\n');
}
);

it('renders only the prompt, not injected server context', async () => {
const { ChatMarkdownText: MockChatMarkdownText } = await import('./chat-markdown-text');

const message = userMessage('m-context');
message.parts = [
{
id: 'm-context-synthetic',
sessionID: 'ses_1',
messageID: 'm-context',
type: 'text',
text: '<cloud-agent-context session-id="workspace_123" />',
synthetic: true,
},
{
id: 'm-context-prompt',
sessionID: 'ses_1',
messageID: 'm-context',
type: 'text',
text: 'prompt',
},
] as typeof message.parts;

const tree = await renderBubble(message);

const element = findElementByTypeFn(tree, MockChatMarkdownText);
expect(element).not.toBeNull();
expect(element?.props.value).toBe('prompt');
});
});

describe('MessageBubble assistant treatment', () => {
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/components/agents/message-bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { MessageLongPressProvider } from './message-long-press-context';
import { messageFailureLabel, selectMessageFailure } from './message-failure-state';
import { partRendersContent } from './message-visibility';
import { PartRenderer } from './part-renderer';
import { firstHumanText, isFilePart, isTextPart } from './part-types';
import { firstHumanText, humanTextParts, isFilePart } from './part-types';
import { groupMessageParts } from './session-tool-run';
import { CondensedToolRunRow } from './tool-run-rows';
import { useMessageCopy } from './use-message-copy';
Expand Down Expand Up @@ -116,8 +116,7 @@ function MessageBubbleImpl({
const userTextContent = useMemo(
() =>
isUser
? parts
.filter(part => isTextPart(part))
? humanTextParts(parts)
.map(p => p.text)
.join('\n\n')
: '',
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/components/agents/message-copy-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ describe('messageTextParts — the Copy message payload', () => {
expect(collectCopyableText({ parts: messageTextParts(parts) })).toBe('Hello\n\nworld');
});

it('drops injected server context and keeps the human prompt', () => {
const parts: Part[] = [
makeTextPart({
id: 'context',
text: '<cloud-agent-context session-id="workspace_123" />',
synthetic: true,
}),
makeTextPart({ id: 'prompt', text: 'Fix the bug' }),
];
expect(collectCopyableText({ parts: messageTextParts(parts) })).toBe('Fix the bug');
});

it('still drops the synthetic snapshot-progress text', () => {
const parts: Part[] = [
makeTextPart({ text: '⠋ Initializing snapshot…', synthetic: true }),
Expand Down
43 changes: 43 additions & 0 deletions apps/mobile/src/components/agents/part-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { describe, expect, it } from 'vitest';

import { assistantMessage } from './message-bubble-test-utils';
import {
firstHumanText,
hasNonWhitespaceText,
humanTextParts,
isPartStreaming,
isPatchPart,
isSnapshotProgressPart,
Expand Down Expand Up @@ -63,6 +65,17 @@ function makeToolPart(): ToolPart {
};
}

function makeFilePart(): FilePart {
return {
id: 'file-1',
sessionID: 's1',
messageID: 'm1',
type: 'file',
mime: 'text/plain',
url: 'file:///a.txt',
};
}

function storedMessage(id: string, parts: StoredMessage['parts']): StoredMessage {
return { info: assistantMessage(id).info, parts };
}
Expand All @@ -89,6 +102,36 @@ describe('isSnapshotProgressPart', () => {
});
});

describe('humanTextParts', () => {
const contextText =
'Cloud Agent session context.\n<cloud-agent-context session-id="workspace_123" />';

it('skips a synthetic server context part when a human prompt exists', () => {
const context = { ...makeTextPart(contextText, true), id: 'context' };
const prompt = { ...makeTextPart('Fix the bug'), id: 'prompt' };
expect(humanTextParts([context, prompt]).map(part => part.text)).toEqual(['Fix the bug']);
expect(firstHumanText([context, prompt])).toBe('Fix the bug');
});

it('does not fall back to server context for an attachment-only row', () => {
const context = { ...makeTextPart(contextText, true), id: 'context' };
expect(humanTextParts([context, makeFilePart()])).toEqual([]);
expect(firstHumanText([context, makeFilePart()])).toBe('');
});

it('keeps a synthetic-only optimistic prompt', () => {
const prompt = makeTextPart('Fix the bug', true);
expect(humanTextParts([prompt]).map(part => part.text)).toEqual(['Fix the bug']);
expect(firstHumanText([prompt])).toBe('Fix the bug');
});

it('skips ignored text parts', () => {
const ignored = { ...makeTextPart('hidden'), ignored: true };
const prompt = makeTextPart('Fix the bug');
expect(humanTextParts([ignored, prompt]).map(part => part.text)).toEqual(['Fix the bug']);
});
});

describe('isPatchPart', () => {
it('is true for a patch part', () => {
const part: PatchPart = {
Expand Down
34 changes: 29 additions & 5 deletions apps/mobile/src/components/agents/part-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,37 @@ export function isTextPart(part: Part): part is TextPart {
}

/**
* Returns the first text part's text, or '' when there is none.
* The human-authored prompt is always the first text part, so only `ignored`
* parts are skipped. A file-only message yields an empty string.
* Server-injected routing context. The wrapper prepends this synthetic part to
* every prompt, so it is present even on an attachment-only row.
*/
function isCloudAgentContextPart(part: TextPart): boolean {
return part.synthetic === true && part.text.includes('<cloud-agent-context');
}

/**
* The human-authored text parts of a message.
*
* The server may prepend a synthetic `cloud-agent-context` part to a user
* message. When a non-synthetic text part exists it is the human prompt, so
* synthetic parts are transcript chrome and are left out. A row whose only
* text parts are synthetic is an optimistic or queued prompt, which still
* belongs to the human; server context is never part of that fallback.
*/
export function humanTextParts(parts: readonly Part[]): TextPart[] {
const textParts = parts.filter(
(part): part is TextPart =>
isTextPart(part) && part.ignored !== true && !isCloudAgentContextPart(part)
);
const nonSynthetic = textParts.filter(part => part.synthetic !== true);
return nonSynthetic.length > 0 ? nonSynthetic : textParts;
Comment thread
eshurakov marked this conversation as resolved.
Resolved
}

/**
* Returns the first human-authored text part's text, or '' when there is none.
* A file-only message yields an empty string.
*/
export function firstHumanText(parts: readonly Part[]): string {
const part = parts.find((p): p is TextPart => isTextPart(p) && p.ignored !== true);
return part?.text ?? '';
return humanTextParts(parts)[0]?.text ?? '';
}

/** CLI snapshot-init progress injected as a synthetic text part (matches kilo-vscode). */
Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/lib/auth/cloud-agent-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,15 +486,18 @@ describe('scope-preserving control token exchange', () => {
});

describe('ordinary prepare schema', () => {
it('preserves the parent callback through personal and organization web prepare schemas', () => {
const input = { ...launch, callbackTarget: { url: 'session:parent' } };
expect(personalPrepareSessionNextSchema.parse(input).callbackTarget).toEqual(
input.callbackTarget
);
expect(
organizationPrepareSessionNextSchema.parse({ ...input, organizationId }).callbackTarget
).toEqual(input.callbackTarget);
});
it.each(['workspace:new-chat', 'workspace:workspace_22345678-1234-4234-8234-123456789abc'])(
'preserves the %s callback through personal and organization web prepare schemas',
url => {
const input = { ...launch, callbackTarget: { url } };
expect(personalPrepareSessionNextSchema.parse(input).callbackTarget).toEqual(
input.callbackTarget
);
expect(
organizationPrepareSessionNextSchema.parse({ ...input, organizationId }).callbackTarget
).toEqual(input.callbackTarget);
}
);
it('strips the retired spawn opt-in through prepare parsing', () => {
expect(
personalPrepareSessionNextSchema.parse({ ...launch, allowSubagentSessions: true })
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routers/cloud-agent-next-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { setupCommandsSchema } from '@kilocode/cloud-agent-profile/setup-script'
import * as z from 'zod';
import { egressPolicySchema } from '@kilocode/worker-utils/egress-allowlist';
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import { ParentSessionCallbackTargetSchema } from '@kilocode/worker-utils/cloud-agent-authority';
import { ParentCallbackTargetSchema } from '@kilocode/worker-utils/cloud-agent-authority';
import { CloudAgentSafeFailureSchema } from '@kilocode/worker-utils/cloud-agent-failure';
import { SandboxStatusSessionIdSchema } from '../../../../services/cloud-agent-next/src/shared/sandbox-status';
import { SESSION_ID_RE } from '../../../../services/cloud-agent-next/src/shared/protocol';
Expand Down Expand Up @@ -383,7 +383,7 @@ const requestedEnvironmentSchema = z
* initial turn fields.
*/
const PrepareSessionSharedFields = {
callbackTarget: ParentSessionCallbackTargetSchema.optional(),
callbackTarget: ParentCallbackTargetSchema.optional(),
// Repository source (mutually exclusive - must provide exactly one)
githubRepo: z
.string()
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/scripts/mcp-catalog/skill-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ They do not expose API `search` or `call`.
1. Launch only when the user explicitly requests a Cloud Agent session. Children have independent sandboxes/worktrees; use local tools for this worktree.
2. Resolve the requested model or established user preference with `search_models`; copy its exact `id`. Otherwise browse with `{}` and ask the user to choose. No implicit default: `isPreferred` is a recommendation. Omit `variant` unless requested; pass only a supported variant separately. Never guess or substitute IDs. `alias_target` names a floating alias's target; discovery does not prove policy approval.
3. Call `spawn_session` with repository, self-contained task prompt, mode, exact model ID, and fresh UUID `operationKey`. Include constraints, verification, and Git/PR instructions. It starts immediately with multi-chat support. Organization and `createdOnPlatform` are inherited, not caller inputs.
4. Set `callbackTarget: { "url": "session:parent" }` for completion/failure/interruption messages; omit for none. Prefer callbacks over polling; never wait in a tool for one.
4. Set `callbackTarget` to `{"url":"workspace:new-chat"}` (parent workspace) or `{"url":"workspace:<session-id>"}` (this chat); omit for none. Prefer callbacks.
5. `list_child_sessions` recovers IDs and `initialMessageId` when recorded; `get_session` checks status; `get_message_result` reads outcomes. Before a new authorized follow-up, call `generate_message_id({})` and copy its `messageId` into `send_message`. This generates no work. Retain it for retries; never replace it after an uncertain send. Initial tasks use `initialMessageId`; report if absent, never invent it. Admission, queued/running, and missing results are not completion. `interrupt_session` is for authorized Stop.
6. `list_folders` discovers existing folder IDs, not workspace membership. Use `assign_workspace_to_folder` with `folderId` and the child's `cloudAgentSessionId`/`worktreeId` from listing or status. It files the whole worktree, including sibling chats; `folderId: null` unfiles it. Only this parent's child workspaces are movable. Folder creation, editing, deletion, and reordering are unavailable.
7. After an ambiguous mutation failure, reconcile state before retrying. Keep the same `operationKey` or follow-up `messageId` for that same submission.
8. Omitted `autoCommit` requests no automatic commit/push; follow-ups do not inherit it. Set `true` per authorized message. If publication is forbidden, set `false` and prohibit it in the prompt: `false` cannot restrict agent Git tools or cancel another prompt's request. Request PR creation in the prompt.

Authority/callbacks belong to the canonical parent (original root chat), even from
siblings. Only direct children are manageable, never unrelated sessions or the
parent. No grandchildren.
Callbacks go to the child's canonical parent workspace: `workspace:new-chat` opens
a sibling chat there; `workspace:<session-id>` queues into an existing chat. Manage
only direct children, never the parent. No grandchildren.

### Examples

Expand All @@ -67,7 +67,7 @@ const child = await spawn_session({
githubRepo: 'owner/repo', model: modelId, mode: 'code',
prompt: 'Fix the bug, run focused tests, and report. Do not commit, push, or create a PR.',
operationKey: launchUuid, autoCommit: false,
callbackTarget: { url: 'session:parent' },
callbackTarget: { url: 'workspace:new-chat' },
});
```

Expand Down
Loading
Loading