Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2802ced
feat(acp): restore sessions and interrupted turns
Sun-GLiang Sep 23, 2026
a9d3350
docs(acp): record Zed smoke and Desktop E2E
Sun-GLiang Sep 23, 2026
378f932
test(acp): await transcript page entry deterministically
Sun-GLiang Sep 23, 2026
6d4b305
test(acp): cover restored turns and guard live MCP replacement
Sun-GLiang Sep 23, 2026
fd2e6a6
test(acp): verify incompatible resume contracts stay parked
Sun-GLiang Sep 23, 2026
62eea00
fix(acp): fence restored turns and MCP replacement
Sun-GLiang Sep 23, 2026
813c955
fix(acp): preserve live turns and MCP providers during recovery
Sun-GLiang Sep 23, 2026
10b2990
fix(acp): settle rejected resume and preserve attached turn status
Sun-GLiang Sep 23, 2026
17ea84a
Merge apache/main into feat/acp-session-restore
Sun-GLiang Sep 24, 2026
f0c507a
refactor(acp): share Turn admission state and refresh validation
Sun-GLiang Sep 24, 2026
561b5a3
docs(acp): record validation on latest main
Sun-GLiang Sep 24, 2026
876dffe
fix(acp): complete copy workflows and restore cancellation
Sun-GLiang Sep 24, 2026
e02db21
test(acp): synchronize restore cancellation on lifecycle events
Sun-GLiang Sep 24, 2026
8fdfc8c
test(acp): bound asynchronous waits by elapsed time
Sun-GLiang Sep 24, 2026
6afe13c
test(acp): keep polling independent of mocked timers
Sun-GLiang Sep 24, 2026
4efbae5
fix(acp): preserve restore state and fence MCP configuration conflicts
Sun-GLiang Sep 24, 2026
c50987a
fix(acp): retain queued terminal facts across observation barriers
Sun-GLiang Sep 24, 2026
518f482
fix(acp): restore retained Turn observation and await Stop delivery
Sun-GLiang Sep 24, 2026
d05fbb2
fix(acp): fence restore interaction replay against close
Sun-GLiang Sep 24, 2026
80dd7cf
fix(acp): preserve restore ordering and Stop lifetime
Sun-GLiang Sep 24, 2026
f5380fc
fix(acp): reconcile restore review findings
Sun-GLiang Sep 24, 2026
14720aa
fix(runtime-host): scope provider handoff to one session
Sun-GLiang Sep 24, 2026
a01dfc5
Merge apache/main into feat/acp-session-restore
Sun-GLiang Sep 25, 2026
745430a
fix(acp): fence provider handoffs and live replay overlap
Sun-GLiang Sep 25, 2026
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
119 changes: 118 additions & 1 deletion packages/cli/src/__tests__/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ describe('Maka ACP agent', () => {
async (agent) => {
assert.deepEqual(await agent.request(methods.agent.initialize, { protocolVersion: 1 }), {
protocolVersion: 1,
agentCapabilities: { sessionCapabilities: { list: {}, close: {} } },
agentCapabilities: {
loadSession: true,
sessionCapabilities: { list: {}, resume: {}, close: {} },
},
authMethods: [],
agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' },
});
Expand Down Expand Up @@ -70,6 +73,92 @@ describe('Maka ACP agent', () => {
assert.deepEqual(lists, [{ cwd: '/workspace' }]);
});

test('routes the concrete Turn resume extension through the SDK', async () => {
await client({ name: 'test-client' }).connectWith(
createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }),
async (agent) => {
assert.deepEqual(
await agent.request('_maka/turn/resume', {
sessionId: 'session-1',
}),
{
kind: 'parked',
plan: {
sessionId: 'session-1',
disposition: 'parked',
reason: 'resume_candidate_missing',
},
},
);
await assert.rejects(
agent.request('_maka/turn/resume', { sessionId: '' }),
(error: unknown) => error instanceof RequestError && error.code === -32602,
);
},
);
});

test('routes bounded copy-source queries and rejects invalid input through the SDK', async () => {
await client({ name: 'test-client' }).connectWith(
createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }),
async (agent) => {
const query = {
sessionId: 'session-1',
throughSequence: null,
position: 0,
maxContributions: 1,
};
assert.deepEqual(await agent.request('_maka/session/copy-source/query', query), {
sessionId: 'session-1',
expectedSourceRevision: 1,
throughSequence: 8,
contributions: [],
nextPosition: null,
});
for (const invalid of [
{ ...query, sessionId: '' },
{ ...query, maxContributions: 129 },
{ ...query, position: -1 },
]) {
await assert.rejects(
agent.request('_maka/session/copy-source/query', invalid),
(error: unknown) => error instanceof RequestError && error.code === -32602,
);
}
},
);
});

test('routes branch, revision, and abandon extensions through the SDK', async () => {
await client({ name: 'test-client' }).connectWith(
createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }),
async (agent) => {
const copy = {
sourceSessionId: 'session-1',
targetSessionId: 'session-2',
sourceTurnId: 'turn-1',
expectedSourceRevision: 1,
};
assert.deepEqual(await agent.request('_maka/session/branch/create', copy), {
kind: 'source_revision_conflict',
expectedRevision: 1,
actualRevision: 2,
});
assert.deepEqual(await agent.request('_maka/session/revision/create', copy), {
kind: 'source_revision_conflict',
expectedRevision: 1,
actualRevision: 2,
});
assert.deepEqual(
await agent.request('_maka/session/revision/abandon', {
targetSessionId: 'session-1',
}),
{ kind: 'retained', sessionId: 'session-1' },
);
},
);
});

test('routes official SDK set-config requests through the Session registry', async () => {
const configurationRequests: unknown[] = [];
await client({ name: 'test-client' }).connectWith(
Expand Down Expand Up @@ -201,6 +290,34 @@ function fakeSessionRegistry(
observations.creates?.push(params);
return { sessionId: 'session-1' };
},
load: async () => ({}),
resume: async () => ({}),
resumeTurn: async () => ({
kind: 'parked' as const,
plan: {
sessionId: 'session-1',
disposition: 'parked' as const,
reason: 'resume_candidate_missing' as const,
},
}),
queryCopySource: async () => ({
sessionId: 'session-1',
expectedSourceRevision: 1,
throughSequence: 8,
contributions: [],
nextPosition: null,
}),
branch: async () => ({
kind: 'source_revision_conflict' as const,
expectedRevision: 1,
actualRevision: 2,
}),
createRevision: async () => ({
kind: 'source_revision_conflict' as const,
expectedRevision: 1,
actualRevision: 2,
}),
abandonRevision: async () => ({ kind: 'retained' as const, sessionId: 'session-1' }),
list: async (params: unknown) => {
observations.lists?.push(params);
return {
Expand Down
56 changes: 50 additions & 6 deletions packages/cli/src/__tests__/acp-child-process-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ const DEFAULT_TIMEOUT_MS = 15_000;
export interface AcpChildProcessHarnessOptions {
readonly timeoutMs?: number;
readonly startRuntimeHost?: boolean;
readonly safeBoundaryResume?: boolean;
readonly beforeHostStart?: (input: {
workspaceRoot: string;
modelConnectionId?: string;
}) => Promise<void>;
readonly model?: {
readonly id: string;
readonly thinkingLevels: readonly ThinkingLevel[];
Expand Down Expand Up @@ -78,6 +83,8 @@ export class AcpChildProcessHarness {
readonly #exit: Promise<AcpChildProcessExit>;
readonly #spawn: Promise<void>;
readonly #timeoutMs: number;
readonly #env: NodeJS.ProcessEnv;
readonly #ownsResources: boolean;
#connection: ClientConnection | undefined;
#clientOpened = false;
#stdinClosed = false;
Expand All @@ -90,13 +97,17 @@ export class AcpChildProcessHarness {
host?: RuntimeHostKernel;
stdoutTap: PassThrough;
timeoutMs: number;
env: NodeJS.ProcessEnv;
ownsResources?: boolean;
}) {
this.#root = input.root;
this.#workspaceRoot = input.workspaceRoot;
this.#child = input.child;
this.#host = input.host;
this.#stdout = new StdoutCaptureBridge(input.stdoutTap);
this.#timeoutMs = input.timeoutMs;
this.#env = input.env;
this.#ownsResources = input.ownsResources ?? true;
this.#child.stderr.on('data', (chunk: Buffer) => this.#stderr.push(Buffer.from(chunk)));
this.#spawn = waitForChildSpawn(this.#child);
this.#exit = new Promise<AcpChildProcessExit>((resolve, reject) => {
Expand Down Expand Up @@ -193,6 +204,28 @@ export class AcpChildProcessHarness {
}
}

/** Spawn another ACP process against this harness's existing Runtime Host root. */
async spawnSibling(): Promise<AcpChildProcessHarness> {
const child = spawn(
process.execPath,
[fileURLToPath(new URL('../dev-cli.js', import.meta.url)), '--acp'],
{ cwd: this.#workspaceRoot, env: this.#env, stdio: ['pipe', 'pipe', 'pipe'] },
);
const stdoutTap = new PassThrough();
pipeCapturedStdout(child.stdout, stdoutTap);
const sibling = new AcpChildProcessHarness({
root: this.#root,
workspaceRoot: this.#workspaceRoot,
child,
stdoutTap,
timeoutMs: this.#timeoutMs,
env: this.#env,
ownsResources: false,
});
await sibling.waitForSpawn();
return sibling;
}

close(): Promise<void> {
this.#closePromise ??= this.closeOnce();
return this.#closePromise;
Expand All @@ -203,15 +236,15 @@ export class AcpChildProcessHarness {
for (const cleanup of [
() => this.closeConnection(),
() => this.stopChild(),
() => this.#host?.close(),
...(this.#ownsResources ? [() => this.#host?.close()] : []),
]) {
try {
await cleanup();
} catch (error) {
failure ??= error;
}
}
await rm(this.#root, { recursive: true, force: true });
if (this.#ownsResources) await rm(this.#root, { recursive: true, force: true });
if (failure !== undefined) throw failure;
}

Expand Down Expand Up @@ -297,18 +330,23 @@ export async function startAcpChildProcessHarness(
let rootCleanupFollowsHostStartup = false;
try {
await mkdir(workspaceRoot, { recursive: true });
if (options.model) await seedModelConnection(workspaceRoot, options.model);
const modelConnectionId = options.model
? await seedModelConnection(workspaceRoot, options.model)
: undefined;
await options.beforeHostStart?.({ workspaceRoot, modelConnectionId });
if (options.startRuntimeHost) {
hostStartup = startExecutionRuntimeHostService({ rootPath: workspaceRoot });
const previousSafeBoundaryResume = process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME;
if (options.safeBoundaryResume) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1';
try {
hostStartup = startExecutionRuntimeHostService({ rootPath: workspaceRoot });
host = await withStartupTimeout(
hostStartup,
timeoutMs,
'Runtime Host startup',
workspaceRoot,
);
} catch (error) {
if (error instanceof StartupTimeoutError) {
if (error instanceof StartupTimeoutError && hostStartup) {
rootCleanupFollowsHostStartup = true;
void hostStartup
.then(
Expand All @@ -321,6 +359,10 @@ export async function startAcpChildProcessHarness(
.catch(() => undefined);
}
throw error;
} finally {
if (previousSafeBoundaryResume === undefined)
delete process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME;
else process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = previousSafeBoundaryResume;
}
}
const child = spawn(
Expand All @@ -337,6 +379,7 @@ export async function startAcpChildProcessHarness(
...(host ? { host } : {}),
stdoutTap,
timeoutMs,
env,
});
await harness.waitForSpawn();
return harness;
Expand All @@ -353,7 +396,7 @@ export async function startAcpChildProcessHarness(
async function seedModelConnection(
rootPath: string,
model: NonNullable<AcpChildProcessHarnessOptions['model']>,
): Promise<void> {
): Promise<string> {
const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' });
const owner = await tryAcquireInteractiveRootOwner(capability);
if (!owner) throw new Error('Unable to acquire ACP model fixture root');
Expand Down Expand Up @@ -396,6 +439,7 @@ async function seedModelConnection(
target: { connectionId: connection.connectionId, modelId: model.id },
});
if (defaulted.kind !== 'committed') throw new Error('ACP model fixture was not selected');
return connection.connectionId;
} finally {
await owner.close();
}
Expand Down
Loading