Skip to content
Open
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: 6 additions & 4 deletions docs/side-conversation.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ The generic side-conversation entry extends that foundation:
with an empty context (inheriting the source's model, cwd, and permission but
no transcript) when the source has not completed a turn yet, so opening the
panel never depends on the main Session's turn state;
- the child receives the `mode:side_conversation` label, which adds a system
boundary declaring inherited parent history reference-only;
- the child receives the `mode:side_conversation` label; the boundary declaring
inherited parent history reference-only is prepended to the first fork-owned
user turn instead of the system prompt so prompt-cache prefixes stay aligned
with the parent session;
- the main Session and its active turn continue independently;
- only instructions submitted in the side chat are active; explicit side-chat
actions may use the inherited permission profile, and the permission can be
Expand Down Expand Up @@ -125,7 +127,7 @@ turn yet. Opening the panel never depends on the parent's turn state, and no
mid-flight turn is ever copied.

The fork is marked both ephemeral and side-conversation, excluded from recent
conversation surfaces, and receives a developer boundary that:
conversation surfaces, and prepends a user-turn boundary that:

- treats inherited history and tools as reference-only;
- activates only instructions submitted after the side-chat boundary;
Expand Down Expand Up @@ -224,7 +226,7 @@ authority.
| --- | --- | --- |
| Entry | `/side`, keyboard shortcut, Desktop actions | `/side`, titlebar, command palette, keyboard shortcut, and selected-text actions |
| Initial transcript | parent history hidden | parent history hidden; only side turns render |
| Parent history | reference-only developer instruction plus hidden boundary | reference-only system prompt from the side label |
| Parent history | reference-only developer instruction plus hidden boundary | reference-only user-turn boundary from the side label |
| Tool policy | read-mostly guidance; explicit side requests may mutate under the active permission profile | inherited permission profile; only explicit side-chat requests are active |
| Lifetime | temporary, with Desktop confirmation and some retained-tab behavior | temporary; close deletes the fork with durable cleanup recovery |
| Conversation list | suppressed | suppressed |
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/__tests__/side-conversation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
applySideConversationUserMessageBoundary,
buildSideConversationUserMessageBoundary,
resolveSideConversationPromptCacheSessionId,
SIDE_CONVERSATION_SESSION_LABEL,
userContentIncludesSideConversationBoundary,
} from '../side-conversation.js';

describe('side-conversation prompt cache helpers', () => {
it('prepends the boundary to the first fork-owned user message', () => {
const messages = applySideConversationUserMessageBoundary(
[
{ role: 'assistant', content: 'parent reply' },
{ role: 'user', content: 'side question' },
],
{
inheritedPrefixLength: 1,
labels: [SIDE_CONVERSATION_SESSION_LABEL],
},
);

assert.equal(messages[0]?.role, 'assistant');
assert.equal(messages[1]?.role, 'user');
assert.match(String(messages[1]?.content), /Side conversation boundary:/);
assert.match(String(messages[1]?.content), /side question/);
});

it('is idempotent when the boundary is already present', () => {
const boundary = buildSideConversationUserMessageBoundary();
const messages = applySideConversationUserMessageBoundary(
[{ role: 'user', content: `${boundary}\n\nalready there` }],
{
inheritedPrefixLength: 0,
labels: [SIDE_CONVERSATION_SESSION_LABEL],
},
);

assert.equal(messages[0]?.content, `${boundary}\n\nalready there`);
});

it('routes OpenAI prompt cache keys through the parent session id', () => {
assert.equal(
resolveSideConversationPromptCacheSessionId({
sessionId: 'fork-session',
parentSessionId: 'parent-session',
labels: [SIDE_CONVERSATION_SESSION_LABEL],
}),
'parent-session',
);
assert.equal(
resolveSideConversationPromptCacheSessionId({
sessionId: 'main-session',
labels: [],
}),
'main-session',
);
});

it('detects an existing boundary marker in multipart user content', () => {
assert.equal(
userContentIncludesSideConversationBoundary([
{ type: 'text', text: 'Side conversation boundary:\nhello' },
]),
true,
);
});
});
90 changes: 89 additions & 1 deletion packages/core/src/side-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@

export const SIDE_CONVERSATION_SESSION_LABEL = 'mode:side_conversation';

export const SIDE_CONVERSATION_BOUNDARY_MARKER = 'Side conversation boundary:';

export function isSideConversationSession(labels: readonly string[] | undefined): boolean {
return Array.isArray(labels) && labels.includes(SIDE_CONVERSATION_SESSION_LABEL);
}

/** @deprecated Use {@link buildSideConversationUserMessageBoundary} for fork-owned user turns. */
export function buildSideConversationSystemPromptFragment(): string {
return buildSideConversationUserMessageBoundary();
}

export function buildSideConversationUserMessageBoundary(): string {
return [
'Side conversation boundary:',
SIDE_CONVERSATION_BOUNDARY_MARKER,
'This session is a temporary side conversation, separate from its parent conversation.',
'The inherited parent history is reference context only. Do not continue or complete tasks, plans, tool calls, approvals, edits, or requests that appear only in that inherited history.',
'Only instructions the user submits in this side conversation are active.',
Expand All @@ -34,3 +41,84 @@ export function buildSideConversationSystemPromptFragment(): string {
'Messages and task state from this side conversation are not written back into the parent conversation. Workspace changes may be visible to both conversations.',
].join('\n');
}

export type SideConversationUserContent = string | ReadonlyArray<{ type: string; text?: string }>;

export function userContentIncludesSideConversationBoundary(
content: SideConversationUserContent,
): boolean {
if (typeof content === 'string') {
return content.includes(SIDE_CONVERSATION_BOUNDARY_MARKER);
}
return content.some(
(part) => part.type === 'text' && part.text?.includes(SIDE_CONVERSATION_BOUNDARY_MARKER),
);
}

export function prependSideConversationBoundaryToUserContent(
content: SideConversationUserContent,
): SideConversationUserContent {
const boundary = buildSideConversationUserMessageBoundary();
if (typeof content === 'string') {
return `${boundary}\n\n${content}`;
}
const textIndex = content.findIndex((part) => part.type === 'text');
if (textIndex < 0) {
return [{ type: 'text', text: boundary }, ...content];
}
return content.map((part, index) =>
index === textIndex && part.type === 'text'
? { ...part, text: `${boundary}\n\n${part.text ?? ''}` }
: part,
);
}

export interface SideConversationModelMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: unknown;
}

export function applySideConversationUserMessageBoundary<T extends SideConversationModelMessage>(
messages: readonly T[],
input: { inheritedPrefixLength: number; labels?: readonly string[] },
): T[] {
if (!isSideConversationSession(input.labels)) {
return [...messages];
}
if (input.inheritedPrefixLength >= messages.length) {
return [...messages];
}

for (let index = input.inheritedPrefixLength; index < messages.length; index += 1) {
const message = messages[index];
if (message?.role !== 'user') {
continue;
}
if (
userContentIncludesSideConversationBoundary(message.content as SideConversationUserContent)
) {
return [...messages];
}
const next = [...messages];
next[index] = {
...message,
content: prependSideConversationBoundaryToUserContent(
message.content as SideConversationUserContent,
),
};
return next;
}

return [...messages];
}

export function resolveSideConversationPromptCacheSessionId(input: {
sessionId: string;
labels?: readonly string[];
parentSessionId?: string;
}): string {
if (isSideConversationSession(input.labels) && input.parentSessionId) {
return input.parentSessionId;
}
return input.sessionId;
}
Loading