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
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn
workspace: { kind: 'host_path', path: base },
}),
resolveSessionCreateProject: async () => ({ kind: 'host_path', path: base }),
resolveExternalSessionImportWorkspace: async () => ({ kind: 'host_path', path: base }),
emitSessionsChanged: (_hostId, reason, sessionId) => changes.push({ reason, sessionId }),
completeDesktopInteractionTurn() {},
createSessionCopyCleanup: () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,7 @@ function deps(
workspace: { kind: 'host_path', path: '/workspace' },
}),
resolveSessionCreateProject: async () => ({ kind: 'host_path', path: '/workspace' }),
resolveExternalSessionImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
emitSessionsChanged() {},
completeDesktopInteractionTurn() {},
createSessionCopyCleanup: () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ test('forwards bounded external Session requests and publishes imported Sessions
},
}),
emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }),
resolveImportWorkspace: async () => ({ kind: 'project', projectId: 'selected-project' }),
},
ipc,
);
Expand Down Expand Up @@ -99,7 +100,11 @@ test('forwards bounded external Session requests and publishes imported Sessions
);
assert.deepEqual(requests, [
{ adapterId: 'codex', includeArchived: true, cursor: '16' },
{ adapterId: 'codex', sourceSessionId: 'source-1' },
{
adapterId: 'codex',
sourceSessionId: 'source-1',
workspace: { kind: 'project', projectId: 'selected-project' },
},
]);
assert.deepEqual(events, [{ reason: 'created', sessionId: 'imported-1' }]);
});
Expand All @@ -119,6 +124,7 @@ test('an uncertain commit still asks the shell to re-read the catalog', async ()
},
}),
emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }),
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand All @@ -138,6 +144,37 @@ test('an uncertain commit still asks the shell to re-read the catalog', async ()
assert.deepEqual(events, [{ reason: 'created', sessionId: undefined }]);
});

test('does not turn a missing import destination into an uncertain commit', async () => {
let imports = 0;
const events: string[] = [];
const ipc = ipcHarness();
registerRuntimeHostExternalSessionsIpc(
{
client: clientFixture({
importExternalSession: async () => {
imports += 1;
return { kind: 'imported', session: session('unexpected') };
},
}),
emitSessionsChanged: (reason) => events.push(reason),
resolveImportWorkspace: async () => {
throw new Error('Select a project from the Runtime Host first');
},
},
ipc,
);

await assert.rejects(
() => ipc.invoke('external-sessions:import', {
adapterId: 'codex',
sourceSessionId: 'source-1',
}),
/Select a project from the Runtime Host first/,
);
assert.equal(imports, 0);
assert.deepEqual(events, []);
});

test('keeps catalog eligibility owned by the Host after an uncertain import', async () => {
const ipc = ipcHarness();
registerRuntimeHostExternalSessionsIpc(
Expand All @@ -161,6 +198,7 @@ test('keeps catalog eligibility owned by the Host after an uncertain import', as
},
}),
emitSessionsChanged() {},
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand Down Expand Up @@ -200,6 +238,7 @@ test('a dispatched interrupted import has the same uncertain outcome as the Host
},
}),
emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }),
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand All @@ -225,6 +264,7 @@ test('fails closed when a dispatched import response cannot be decoded', async (
},
}),
emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }),
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand Down Expand Up @@ -254,6 +294,7 @@ test('does not relabel an explicitly undispatched import as uncertain', async ()
},
}),
emitSessionsChanged() {},
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand Down Expand Up @@ -284,6 +325,7 @@ test('maps a no-usable-model failure to a distinct, non-recovering reason', asyn
},
}),
emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }),
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand Down Expand Up @@ -313,6 +355,7 @@ test('maps a pre-commit conversion failure to source_unreadable', async () => {
},
}),
emitSessionsChanged() {},
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand All @@ -338,6 +381,7 @@ test('maps a decoded source limit to IPC data without publishing a created Sessi
registerRuntimeHostExternalSessionsIpc({
client: clientFixture({ importExternalSession: async () => wireResult }),
emitSessionsChanged: (reason) => events.push(reason),
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
}, ipc);

assert.deepEqual(await ipc.invoke('external-sessions:import', {
Expand Down Expand Up @@ -365,6 +409,7 @@ test('rethrows import failures that have no distinct renderer reason', async ()
},
}),
emitSessionsChanged() {},
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand Down Expand Up @@ -395,6 +440,7 @@ test('rejects malformed renderer requests before they reach the Host client', as
},
}),
emitSessionsChanged() {},
resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }),
},
ipc,
);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,8 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager(
{ allowHostPath: !runtimeHostProfileUsesHostWorkspace(target.kind) },
);
},
resolveExternalSessionImportWorkspace: (target) =>
currentDesktopWorkspaceTarget(target),
emitSessionsChanged,
cacheTranscript: (scope, snapshot) => sessionLocal.cacheTranscript(scope, snapshot),
...(e2eFixture?.scenario === "chat-partial-history"
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
type ExternalSessionCatalogQueryResult,
type ExternalSessionImportResult,
type ExternalSessionSourceQueryResult,
type WorkspaceTarget,
type ClientCapabilityReplaceResult,
type ClientCapabilityUnregisterResult,
type InteractionAnswerInput,
Expand Down Expand Up @@ -154,7 +155,6 @@ import {
type TurnMessageSubmitInput,
type TurnMessageSubmitResult,
type WorkspaceProjection,
type WorkspaceTarget,
} from "@maka/runtime-host/protocol";

const decodeStoredMessage = (value: unknown): StoredMessage =>
Expand Down Expand Up @@ -1047,6 +1047,7 @@ export class DesktopRuntimeHostClient {
async importExternalSession(input: {
readonly adapterId: string;
readonly sourceSessionId: string;
readonly workspace?: WorkspaceTarget;
}): Promise<ExternalSessionImportResult<SessionCatalogProjection>> {
const result = await this.request("external-session.import", input);
return result.kind === 'imported'
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/runtime-host-desktop-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export interface DesktopRuntimeHostCandidateDeps {
input: Pick<CreateSessionRequestInput, "cwd" | "projectId">,
target: DesktopRuntimeHostTargetPolicy,
) => Promise<WorkspaceTarget>;
/** Resolves the selected import destination on the target Host. */
readonly resolveExternalSessionImportWorkspace: (
target: DesktopRuntimeHostTargetPolicy,
) => Promise<WorkspaceTarget>;
readonly emitSessionsChanged: (
scope: DesktopTargetScope,
reason: SessionChangedReason,
Expand Down Expand Up @@ -897,6 +901,7 @@ export async function createDesktopRuntimeHostCandidate(
{
client,
emitSessionsChanged,
resolveImportWorkspace: () => deps.resolveExternalSessionImportWorkspace(target),
},
ipc,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
ExternalSessionImportResult,
ExternalSessionSourceQueryResult,
SessionCatalogProjection,
WorkspaceTarget,
} from '@maka/runtime-host/protocol';
import {
decodeExternalSessionCatalogQueryInput,
Expand All @@ -52,12 +53,15 @@ type ExternalSessionClient = {
importExternalSession(input: {
readonly adapterId: string;
readonly sourceSessionId: string;
readonly workspace?: WorkspaceTarget;
}): Promise<ExternalSessionImportResult<SessionCatalogProjection>>;
};

export interface RuntimeHostExternalSessionsIpcDeps {
readonly client: ExternalSessionClient;
readonly emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void;
/** Resolves the Desktop-selected workspace on the target Host. */
readonly resolveImportWorkspace: () => Promise<WorkspaceTarget>;
}

export function registerRuntimeHostExternalSessionsIpc(
Expand All @@ -80,8 +84,12 @@ export function registerRuntimeHostExternalSessionsIpc(
});
ipcMain.handle('external-sessions:import', async (_event, input: unknown) => {
const request = decodeExternalSessionImportInput(input);
const workspace = await deps.resolveImportWorkspace();
try {
const result = await deps.client.importExternalSession(request);
const result = await deps.client.importExternalSession({
...request,
workspace,
});
if (result.kind === 'source_limit_exceeded') {
return {
ok: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type ExternalSessionCatalogPageQuery,
type ExternalSessionSummary,
} from '@maka/core/external-session';
import type { WorkspaceTarget } from '../protocol/index.js';
import { type SessionHeader } from '@maka/core/session';
import { headerToSummary } from '@maka/runtime/session-manager';
import type { SessionCatalogRecord } from '@maka/storage/execution-stores';
Expand Down Expand Up @@ -673,6 +674,69 @@ test('coalesces a repeat import issued while the first is still running', async
assert.equal(fixture.creates.length, 2);
});

for (const workspace of [
{ kind: 'host_path', path: '/workspace/A' },
{ kind: 'project', projectId: 'project-A' },
] satisfies WorkspaceTarget[]) {
test(`coalesces concurrent imports into the same ${workspace.kind} destination`, async () => {
const fixture = coordinatorFixture([adapterFixture()]);
const request = { adapterId: 'codex', sourceSessionId: 'source-0', workspace };
const [first, second] = await Promise.all([
fixture.coordinator.importSession(request),
fixture.coordinator.importSession({ ...request, workspace: { ...workspace } }),
]);
assert.equal(first.ok, true);
assert.deepEqual(second, first);
assert.equal(fixture.creates.length, 1);
});
}

for (const [firstWorkspace, secondWorkspace] of [
[
{ kind: 'host_path', path: '/workspace/A' },
{ kind: 'host_path', path: '/workspace/B' },
],
[
{ kind: 'project', projectId: 'project-A' },
{ kind: 'project', projectId: 'project-B' },
],
[undefined, { kind: 'host_path', path: '/workspace/B' }],
] satisfies Array<[WorkspaceTarget | undefined, WorkspaceTarget]>) {
test(`rejects a conflicting import destination (${firstWorkspace?.kind ?? 'source cwd'})`, async () => {
const fixture = coordinatorFixture([adapterFixture()]);
const source = { adapterId: 'codex', sourceSessionId: 'source-0' };
const [first, second] = await Promise.all([
fixture.coordinator.importSession({ ...source, workspace: firstWorkspace }),
fixture.coordinator.importSession({ ...source, workspace: secondWorkspace }),
]);
assert.equal(first.ok, true);
assert.deepEqual(second, {
ok: false,
error: {
code: 'operation_conflict',
message: 'This source is already being imported into a different workspace',
},
});
assert.equal(fixture.creates.length, 1);
assert.equal(fixture.drainRequests(), 0);

// A conflict is scoped to the running import, not a permanent ban on
// making a second copy in the independently chosen destination.
const later = await fixture.coordinator.importSession({
...source,
workspace: secondWorkspace,
});
assert.equal(later.ok, true);
assert.equal(fixture.creates.length, 2);
assert.equal(
secondWorkspace.kind === 'project'
? fixture.creates[1]?.input.projectId
: fixture.creates[1]?.input.cwd,
secondWorkspace.kind === 'project' ? secondWorkspace.projectId : secondWorkspace.path,
);
});
}

test('reports conversion errors before persistence and store uncertainty after entry', async () => {
let createAttempts = 0;
const conversionFailure = coordinatorFixture(
Expand Down Expand Up @@ -917,6 +981,32 @@ test('does not classify untyped source errors or errors after persistence as sou
assert.equal(committed.drainRequests(), 1);
});

test('uses the Host-resolved workspace as the imported Session cwd', async () => {
const fixture = coordinatorFixture([adapterFixture()]);

const outcome = await fixture.coordinator.handlers['external-session.import'](
{
adapterId: 'codex',
sourceSessionId: 'source-0',
workspace: { kind: 'project', projectId: 'project-1' },
},
context,
);

assert.equal(outcome.ok, true);
assert.deepEqual(fixture.creates[0]?.input, {
backend: 'ai-sdk',
cwd: '/resolved-project',
projectId: 'project-1',
llmConnectionSlug: 'default',
model: 'gpt-5',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
name: 'Source 0',
});
});

test('reports a model-target failure before any commit is attempted', async () => {
let createAttempts = 0;
const fixture = coordinatorFixture([adapterFixture()], {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,20 @@ describe('external Session protocol', () => {
decodeClientFrame({
requestId: 'request-import',
operation: 'external-session.import',
input: { adapterId: 'codex', sourceSessionId: 'source-session-1' },
input: {
adapterId: 'codex',
sourceSessionId: 'source-session-1',
workspace: { kind: 'project', projectId: 'project-1' },
},
}),
{
requestId: 'request-import',
operation: 'external-session.import',
input: { adapterId: 'codex', sourceSessionId: 'source-session-1' },
input: {
adapterId: 'codex',
sourceSessionId: 'source-session-1',
workspace: { kind: 'project', projectId: 'project-1' },
},
},
);
});
Expand Down
Loading