Skip to content
Draft
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
12 changes: 12 additions & 0 deletions apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ type CatalogAcpProviderRuntimeParams<TBackendOptions extends object> = {
onThinkingChange: (thinking: boolean) => void;
getSessionOpenAbortSignal?: () => AbortSignal | undefined;
backendOptions?: Omit<TBackendOptions, 'cwd' | 'mcpServers' | 'permissionHandler' | 'permissionMode' | 'happierSessionId'>;
/**
* Async resolver invoked inside `ensureBackend` before the backend is constructed.
* Returns additional backend options merged on top of `backendOptions`. Useful when
* an option (e.g. a resolved system prompt) depends on the live session and cannot
* be computed synchronously at runtime-construction time.
*/
resolveBackendOptions?: (ctx: { session: ApiSessionClient }) => Promise<Partial<TBackendOptions>>;
getPermissionMode?: () => PermissionMode | null | undefined;
resolvePermissionMode?: (args: {
getPermissionMode?: () => PermissionMode | null | undefined;
Expand Down Expand Up @@ -160,10 +167,15 @@ export function createCatalogProviderAcpRuntime<TBackendOptions extends object =
: params.getPermissionMode?.();
const permissionMode = typeof permissionModeRaw === 'string' ? permissionModeRaw : undefined;

const resolvedBackendOptions = params.resolveBackendOptions
? await params.resolveBackendOptions({ session: params.session })
: {};

const created = await createCatalogAcpBackend<TBackendOptions>(params.provider, {
cwd: params.directory,
mcpServers: params.mcpServers,
...(params.backendOptions ?? {}),
...(resolvedBackendOptions ?? {}),
permissionHandler: params.permissionHandler,
permissionMode,
happierSessionId: params.session.sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ describe('resolveEffectiveCodingPromptText', () => {
expect(out).not.toContain('again if the task changes significantly');
});

it('renders only tool-delivery blocks when renderBlockScopes restricts the rendered scopes', async () => {
const credentials = createCredentials();

const out = await resolveEffectiveCodingPromptText({
credentials,
settings: {},
profileId: null,
baseOverride: 'BASE',
executionRunsFeatureEnabled: false,
toolDelivery: 'shell_bridge',
toolDeliverySessionId: 's1',
toolDeliveryDirectory: '/tmp/worktree',
renderBlockScopes: ['tool_delivery'],
fetchPromptArtifactRecord: async () => null,
});

expect(out).toContain('Happier tools are available through the CLI bridge');
expect(out).toContain("'--session-id' 's1'");
expect(out).not.toContain('BASE');
expect(out).not.toContain('# Attachments');
});

it('applies prompt personalization settings to the effective coding prompt', async () => {
const credentials = createCredentials();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import {
buildCodingSessionPromptPlanBaseV1,
buildPromptPlanDiagnosticsV1,
buildPromptPlanV1,
renderPromptPlanV1,
renderPromptBlocksV1,
type PromptBlockScopeV1,
type PromptBlockV1,
type PromptPlanV1,
} from '@happier-dev/protocol';
Expand Down Expand Up @@ -34,6 +35,14 @@ type ResolveEffectiveCodingPromptArgs = Readonly<{
memoryMachineId?: string | null;
cache?: Map<string, string | null>;
fetchPromptArtifactRecord?: FetchPromptArtifactRecord;
/**
* When set, only blocks whose scope is listed here render into `text`; the
* plan and diagnostics still include every composed block. Used when the
* backend already delivers the session-scope system prompt at process spawn
* and a first-message prepend must carry only the remaining blocks (e.g.
* tool delivery).
*/
renderBlockScopes?: readonly PromptBlockScopeV1[];
}>;

export async function resolveEffectiveCodingPromptText(
Expand Down Expand Up @@ -103,10 +112,13 @@ export async function resolveEffectiveCodingPromptPlan(
modality: 'coding',
blocks: [...basePlan.blocks, ...promptStackBlocks, ...providerBehaviorBlocks, ...toolDeliveryBlocks],
});
const renderedBlocks = args.renderBlockScopes
? plan.blocks.filter((block) => args.renderBlockScopes!.includes(block.scope))
: plan.blocks;

return {
plan,
text: renderPromptPlanV1(plan),
text: renderPromptBlocksV1(renderedBlocks),
diagnostics: buildPromptPlanDiagnosticsV1(plan),
};
}
30 changes: 30 additions & 0 deletions apps/cli/src/agent/runtime/runStandardAcpProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,36 @@ describe('runStandardAcpProvider', () => {
expect(resolvedPrompt).not.toContain('vendor-session-123');
});

it('prepends only tool-delivery blocks when the backend delivers the system prompt at spawn', async () => {
const harness = createHarness();
harness.config.deliversSystemPromptAtSpawn = true;

let defaultPrepend = '';
let overridePrepend = '';
harness.deps.runPermissionModePromptLoopFn = async (params: Readonly<{
resolveFreshSessionSystemPrompt?: (args: { baseOverride?: string | null }) => Promise<string>;
}>) => {
defaultPrepend = await params.resolveFreshSessionSystemPrompt?.({}) ?? '';
overridePrepend = await params.resolveFreshSessionSystemPrompt?.({ baseOverride: 'USER SYSTEM OVERRIDE' }) ?? '';
};

await runStandardAcpProvider(harness.opts, harness.config, harness.deps);

// The spawn flag (--append-system-prompt) already carries the shared base
// sections, so the first-message prepend must not duplicate them.
expect(defaultPrepend).toContain('Happier tools are available through the CLI bridge');
expect(defaultPrepend).toContain("'--session-id' 'session-1'");
expect(defaultPrepend).not.toContain('# Session title');
expect(defaultPrepend).not.toContain('# Attachments');
// Explicit per-message base overrides cannot ride the spawn flag and must
// still reach the provider, ahead of the tool-delivery appendix.
expect(overridePrepend).toContain('USER SYSTEM OVERRIDE');
expect(overridePrepend).toContain('Happier tools are available through the CLI bridge');
expect(overridePrepend).not.toContain('# Attachments');
expect(overridePrepend.indexOf('USER SYSTEM OVERRIDE'))
.toBeLessThan(overridePrepend.indexOf('Happier tools are available through the CLI bridge'));
});

it('in-flight steer controller calls steerPrompt with correct receiver', async () => {
const harness = createHarness();

Expand Down
34 changes: 30 additions & 4 deletions apps/cli/src/agent/runtime/runStandardAcpProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ export type StandardAcpProviderConfig = {
onDispose?: (params: { session: ApiSessionClient; runtime: RuntimeForLoop }) => void | Promise<void>;
startRuntimeBeforeFirstPrompt?: boolean;
failClosedOnResumeFailure?: boolean;
/**
* True when the backend applies the effective coding system prompt itself at
* process spawn (e.g. pi's --append-system-prompt flag). The fresh-session
* first-message prepend then carries only tool-delivery blocks plus any
* explicit per-message base override, instead of duplicating the
* spawn-delivered system prompt.
*/
deliversSystemPromptAtSpawn?: boolean;
onTerminalDisplayControllerReady?: (controller: TerminalDisplayController) => void;
shouldRenderTerminalDisplay?: (params: { opts: StandardAcpProviderRunOptions; session: ApiSessionClient; metadata: Metadata }) => boolean;
resolveKeepAliveMode?: () => KeepAliveMode;
Expand Down Expand Up @@ -657,12 +665,11 @@ export async function runStandardAcpProvider(
strictInitialResume: initialResumeId.length > 0,
failClosedOnResumeFailure: config.failClosedOnResumeFailure === true,
startRuntimeBeforeFirstPrompt: config.startRuntimeBeforeFirstPrompt === true,
resolveFreshSessionSystemPrompt: async ({ baseOverride }) =>
await resolveEffectiveCodingPromptText({
resolveFreshSessionSystemPrompt: async ({ baseOverride }) => {
const commonArgs = {
credentials: opts.credentials,
settings: opts.accountSettingsContext?.settings ?? null,
profileId: session.getMetadataSnapshot()?.profileId ?? null,
baseOverride,
executionRunsFeatureEnabled: resolveCliFeatureDecision({
featureId: 'execution.runs',
env: process.env,
Expand All @@ -674,7 +681,26 @@ export async function runStandardAcpProvider(
memoryMachineId: machineId,
memoryRecallGuidanceEnabled,
cache: promptArtifactBodyCache,
}),
};
if (config.deliversSystemPromptAtSpawn !== true) {
return await resolveEffectiveCodingPromptText({ ...commonArgs, baseOverride });
}
// The backend applies the session system prompt at process spawn (e.g.
// pi's --append-system-prompt flag); the first-message prepend must not
// duplicate it. Carry only the tool-delivery bridge blocks, plus an
// explicit per-message base override, which cannot ride the spawn flag.
const explicitBaseOverride = typeof baseOverride === 'string' && baseOverride.trim()
? baseOverride.trim()
: '';
const toolDeliveryText = await resolveEffectiveCodingPromptText({
...commonArgs,
renderBlockScopes: ['tool_delivery'],
});
if (explicitBaseOverride && toolDeliveryText) {
return `${explicitBaseOverride}\n\n${toolDeliveryText}`;
}
return explicitBaseOverride || toolDeliveryText;
},
onAfterStart: config.onAfterStart ? () => config.onAfterStart?.({ session, runtime }) : undefined,
onAfterReset: config.onAfterReset ? () => config.onAfterReset?.({ session, runtime }) : undefined,
formatPromptErrorMessage: config.formatPromptErrorMessage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -608,4 +608,115 @@ describe('ensureDirectSessionLink', () => {
expect(JSON.stringify(updatedMetadata)).not.toContain('OPENCODE_AUTH_CONTENT');
expect(JSON.stringify(updatedMetadata)).not.toContain('must-not-be-copied');
});

it('creates a pi direct link with piSessionId metadata and a piAgentDir source key', async () => {
getOrCreateSessionByTagMock.mockResolvedValueOnce({
session: { id: 'sess_direct_pi_1', metadata: {} },
});

const result = await ensureDirectSessionLink({
credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } },
machineId: 'machine_1',
providerId: 'pi',
remoteSessionId: 'pi_sess_1',
source: { kind: 'piAgentDir', agentDir: '/home/user/.pi/agent' },
titleHint: 'Pi linked session',
directoryHint: '/repo',
nowMs: () => 123,
});

const expectedTag = `direct:v1:${sha256Hex('machine_1|pi|pi_sess_1|piAgentDir:/home/user/.pi/agent')}`;
expect(result.tag).toBe(expectedTag);
expect(getOrCreateSessionByTagMock.mock.calls[0]?.[0]?.tag).toBe(expectedTag);
const createdMetadata = getOrCreateSessionByTagMock.mock.calls[0]?.[0]?.metadata;
expect(createdMetadata).toMatchObject({
flavor: 'pi',
piSessionId: 'pi_sess_1',
directSessionV1: {
v: 1,
providerId: 'pi',
machineId: 'machine_1',
remoteSessionId: 'pi_sess_1',
source: { kind: 'piAgentDir', agentDir: '/home/user/.pi/agent' },
},
});
});

it('discriminates pi direct sessions by agentDir so identical remote ids do not collide', async () => {
getOrCreateSessionByTagMock.mockResolvedValueOnce({ session: { id: 'sess_direct_pi_a', metadata: {} } });
getOrCreateSessionByTagMock.mockResolvedValueOnce({ session: { id: 'sess_direct_pi_b', metadata: {} } });

const resultAlice = await ensureDirectSessionLink({
credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } },
machineId: 'machine_1',
providerId: 'pi',
remoteSessionId: 'pi_sess_shared',
source: { kind: 'piAgentDir', agentDir: '/home/alice/.pi/agent' },
nowMs: () => 123,
});
const resultBob = await ensureDirectSessionLink({
credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } },
machineId: 'machine_1',
providerId: 'pi',
remoteSessionId: 'pi_sess_shared',
source: { kind: 'piAgentDir', agentDir: '/home/bob/.pi/agent' },
nowMs: () => 123,
});

expect(resultAlice.tag).not.toBe(resultBob.tag);
expect(resultAlice.tag).toBe(`direct:v1:${sha256Hex('machine_1|pi|pi_sess_shared|piAgentDir:/home/alice/.pi/agent')}`);
expect(resultBob.tag).toBe(`direct:v1:${sha256Hex('machine_1|pi|pi_sess_shared|piAgentDir:/home/bob/.pi/agent')}`);
});

it('recognizes a pi daemon marker by flavor and resolves its remoteSessionId from piSessionId metadata', async () => {
const connectedServices = {
v: 1,
bindingsByServiceId: {
'openai-codex': { source: 'connected', selection: 'group', groupId: 'happier', profileId: 'work' },
},
} satisfies ConnectedServiceBindingsV1;
const materializationIdentity = {
v: 1,
id: 'csm_pi_link',
createdAtMs: 1_718_719_900_000,
} satisfies ConnectedServiceMaterializationIdentityV1;
listSessionMarkersMock.mockResolvedValueOnce([
{
pid: 12345,
updatedAt: 200,
flavor: 'pi',
cwd: '/repo',
metadata: { flavor: 'pi', path: '/repo', piSessionId: 'pi_connected' },
respawn: {
version: 1,
directory: '/repo',
backendTarget: { kind: 'builtInAgent', agentId: 'pi' },
connectedServices,
connectedServicesUpdatedAt: 1_718_719_899_000,
connectedServiceMaterializationIdentityV1: materializationIdentity,
},
},
]);
getOrCreateSessionByTagMock.mockResolvedValueOnce({
session: { id: 'sess_direct_pi_connected', metadata: {} },
});

await ensureDirectSessionLink({
credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } },
machineId: 'machine_1',
providerId: 'pi',
remoteSessionId: 'pi_connected',
source: { kind: 'piAgentDir', agentDir: '/home/user/.pi/agent' },
directoryHint: '/repo',
nowMs: () => 123,
});

const createdMetadata = getOrCreateSessionByTagMock.mock.calls[0]?.[0]?.metadata;
expect(createdMetadata).toMatchObject({
connectedServices,
connectedServicesUpdatedAt: 1_718_719_899_000,
connectedServiceMaterializationIdentityV1: materializationIdentity,
directSessionV1: { remoteSessionId: 'pi_connected' },
});
});
});
19 changes: 16 additions & 3 deletions apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ function resolveMetadataRemoteSessionId(
if (openCodeSessionId) return openCodeSessionId;
break;
}
case 'pi': {
const piSessionId = normalizeNullableString(metadata.piSessionId);
if (piSessionId) return piSessionId;
break;
}
}

const runtimeDescriptor = asMetadataRecord(metadata.agentRuntimeDescriptorV1);
Expand All @@ -91,16 +96,16 @@ function resolveMetadataRemoteSessionId(
function resolveMarkerProviderId(marker: DaemonSessionMarker): DirectSessionsProviderId | null {
const metadata = asMetadataRecord(marker.metadata);
const metadataFlavor = normalizeNullableString(metadata?.flavor);
if (metadataFlavor === 'claude' || metadataFlavor === 'codex' || metadataFlavor === 'opencode') {
if (metadataFlavor === 'claude' || metadataFlavor === 'codex' || metadataFlavor === 'opencode' || metadataFlavor === 'pi') {
return metadataFlavor;
}
if (marker.flavor === 'claude' || marker.flavor === 'codex' || marker.flavor === 'opencode') {
if (marker.flavor === 'claude' || marker.flavor === 'codex' || marker.flavor === 'opencode' || marker.flavor === 'pi') {
return marker.flavor;
}
const respawn = asMetadataRecord(marker.respawn);
const backendTarget = asMetadataRecord(respawn?.backendTarget);
const agentId = normalizeNullableString(backendTarget?.agentId);
return agentId === 'claude' || agentId === 'codex' || agentId === 'opencode' ? agentId : null;
return agentId === 'claude' || agentId === 'codex' || agentId === 'opencode' || agentId === 'pi' ? agentId : null;
}

function resolveMarkerRemoteSessionId(marker: DaemonSessionMarker, providerId: DirectSessionsProviderId): string | null {
Expand Down Expand Up @@ -378,6 +383,11 @@ function resolveSourceKey(providerId: DirectSessionsProviderId, source: DirectSe
const directory = normalizeNullableString(source.directory) ?? '';
return `opencodeServer:${baseUrl}:${directory}`;
}
case 'pi': {
if (source.kind !== 'piAgentDir') return 'piAgentDir:invalid';
const agentDir = normalizeNullableString(source.agentDir) ?? '';
return `piAgentDir:${agentDir}`;
}
default:
return 'unknown';
}
Expand Down Expand Up @@ -679,6 +689,9 @@ function buildDirectSessionMetadata(params: Readonly<{
case 'claude':
base.claudeSessionId = params.remoteSessionId;
break;
case 'pi':
base.piSessionId = params.remoteSessionId;
break;
case 'opencode':
base.opencodeSessionId = params.remoteSessionId;
if (params.runtimeDescriptor?.providerId === 'opencode') {
Expand Down
Loading
Loading