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
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,17 @@ type BotClient = RuntimeHostBotSessionAdapterDeps['client'];

test('creates an explore Session through the Host-owned default model route', async () => {
const creates: unknown[] = [];
const updates: unknown[] = [];
const changes: unknown[] = [];
const client = botClient({
createSession: async (input) => {
creates.push(input);
return session(input.sessionId, { permissionMode: 'explore' });
// Host default is ask; Bot adapter must pin explore after create.
return session(input.sessionId, { permissionMode: 'ask' });
},
updateSessionConfiguration: async (sessionId, patch) => {
updates.push({ sessionId, patch });
return session(sessionId, { permissionMode: 'explore' });
},
});
const adapter = createRuntimeHostBotSessionAdapter({
Expand All @@ -68,9 +74,11 @@ test('creates an explore Session through the Host-owned default model route', as
name: 'Telegram conversation',
labels: ['bot', 'telegram'],
modelTarget: { kind: 'default' },
permissionMode: 'explore',
},
]);
assert.deepEqual(updates, [
{ sessionId: 'bot-session-1', patch: { permissionMode: 'explore' } },
]);
assert.deepEqual(changes, [
{ reason: 'created', sessionId: 'bot-session-1', extra: undefined },
]);
Expand Down Expand Up @@ -163,6 +171,105 @@ test('reconciles an uncertain Host Session create with its stable Session identi
);
});

test('verifies an uncertain explore pin against the Host before binding the stable Session id', async () => {
const lifecycle: unknown[] = [];
const changes: unknown[] = [];
const adapter = createRuntimeHostBotSessionAdapter({
client: botClient({
createSession: async (input) => session(input.sessionId, { permissionMode: 'ask' }),
updateSessionConfiguration: async () => {
throw new RuntimeHostOperationError(
'session.configuration.update',
'commit_outcome_unknown',
'response lost',
);
},
// The Host committed explore even though the update response was lost.
getSession: async (sessionId) => session(sessionId, { permissionMode: 'explore' }),
setSessionLifecycle: async (sessionId, state) => {
lifecycle.push([sessionId, state]);
return session(sessionId);
},
}),
resolveCreateTarget: hostPathCreateTarget,
emitSessionsChanged: (reason, sessionId, extra) =>
changes.push({ reason, sessionId, extra }),
newId: () => 'stable-session-id',
});

assert.equal(
await adapter.createSession({ name: 'Bot conversation', labels: ['bot'] }),
'stable-session-id',
);
assert.deepEqual(lifecycle, [], 'a verified explore Session must not be archived');
assert.deepEqual(changes, [
{ reason: 'created', sessionId: 'stable-session-id', extra: undefined },
]);
});

test('archives the orphaned Session when the explore pin definitively fails', async () => {
const lifecycle: unknown[] = [];
const changes: unknown[] = [];
const adapter = createRuntimeHostBotSessionAdapter({
client: botClient({
createSession: async (input) => session(input.sessionId, { permissionMode: 'ask' }),
updateSessionConfiguration: async () => {
throw new RuntimeHostOperationError(
'session.configuration.update',
'invalid_request',
'explore refused',
);
},
setSessionLifecycle: async (sessionId, state) => {
lifecycle.push([sessionId, state]);
return session(sessionId);
},
}),
resolveCreateTarget: hostPathCreateTarget,
emitSessionsChanged: (reason, sessionId, extra) =>
changes.push({ reason, sessionId, extra }),
newId: () => 'stable-session-id',
});

await assert.rejects(
adapter.createSession({ name: 'Bot conversation', labels: ['bot'] }),
RuntimeHostOperationError,
);
assert.deepEqual(lifecycle, [['stable-session-id', 'archived']]);
assert.deepEqual(changes, []);
});

test('archives the orphaned Session when an uncertain explore pin cannot be verified as committed', async () => {
const lifecycle: unknown[] = [];
const adapter = createRuntimeHostBotSessionAdapter({
client: botClient({
createSession: async (input) => session(input.sessionId, { permissionMode: 'ask' }),
updateSessionConfiguration: async () => {
throw new RuntimeHostOperationError(
'session.configuration.update',
'commit_outcome_unknown',
'response lost',
);
},
// The update never committed; the Session is still in ask mode.
getSession: async (sessionId) => session(sessionId, { permissionMode: 'ask' }),
setSessionLifecycle: async (sessionId, state) => {
lifecycle.push([sessionId, state]);
return session(sessionId);
},
}),
resolveCreateTarget: hostPathCreateTarget,
emitSessionsChanged() {},
newId: () => 'stable-session-id',
});

await assert.rejects(
adapter.createSession({ name: 'Bot conversation', labels: ['bot'] }),
RuntimeHostOperationError,
);
assert.deepEqual(lifecycle, [['stable-session-id', 'archived']]);
});

test('subscribes before Turn start and settles a fast Host reply without losing text', async () => {
const events = new AsyncFrameQueue();
const changes: unknown[] = [];
Expand Down Expand Up @@ -399,6 +506,7 @@ function botClient(overrides: Partial<BotClient>): BotClient {
createSession: unexpected,
getSession: unexpected,
openSession: unexpected,
setSessionLifecycle: unexpected,
startTurn: unexpected,
updateSessionConfiguration: unexpected,
...overrides,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,18 @@ function connectionHarness(
if (operation === 'session.create') {
return session((input as { sessionId: string }).sessionId);
}
if (operation === 'session.configuration.update') {
// The Bot adapter pins explore after create; commit it like the
// real Host would instead of falling through to the unexpected-
// operation guard below.
return {
kind: 'committed',
session: {
...session((input as { sessionId: string }).sessionId),
permissionMode: 'explore',
},
};
}
if (operation === 'external-session.source.query') {
return { adapterIds: ['codex'] };
}
Expand Down
62 changes: 61 additions & 1 deletion apps/desktop/src/main/runtime-host-bot-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type RuntimeHostBotSessionClient = Pick<
| 'createSession'
| 'getSession'
| 'openSession'
| 'setSessionLifecycle'
| 'startTurn'
| 'updateSessionConfiguration'
>;
Expand Down Expand Up @@ -70,13 +71,17 @@ export function createRuntimeHostBotSessionAdapter(
const sessionId = newId();
let session: SessionCatalogProjection;
try {
// Do not pass permissionMode: 'explore' on create. Runtime Host
// requires a declared SessionStartMode (e.g. deep_research) when
// explore is requested at create time; bot conversations are not
// that product mode. Create with the Host default, then pin explore
// via the same configuration update prepareSession uses.
session = await deps.client.createSession({
sessionId,
workspace: target.workspace,
name: input.name,
labels: [...input.labels],
modelTarget: { kind: 'default' },
permissionMode: 'explore',
});
} catch (error) {
if (
Expand All @@ -89,6 +94,35 @@ export function createRuntimeHostBotSessionAdapter(
if (!reconciled) throw error;
session = reconciled;
}
if (session.permissionMode !== 'explore') {
try {
session = await deps.client.updateSessionConfiguration(session.id, {
permissionMode: 'explore',
});
} catch (error) {
throwUnavailable(error, session.id);
// session.configuration.update carries its own post-commit
// uncertainty: a lost response may still have pinned explore. The
// caller binds nothing unless createSession resolves, so verify the
// stable id before deciding the Session is unusable — otherwise the
// next inbound message forks another conversation.
const reconciled = isCommitOutcomeUnknown(error)
? await deps.client.getSession(session.id).catch(() => null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This is the right pin-unknown pattern. The create-unknown getSession(sessionId) a few lines above does not mirror it (no .catch, no abandon). After this PR that create is Host-default ask, so a catalog-read throw leaks an unbound ask row and the next inbound IM mints a sibling. Please use getSession(...).catch(() => null) and abandon if it is not verified explore, then rethrow the original error.

: null;
if (isVerifiedExploreSession(reconciled)) {
session = reconciled;
} else {
await abandonUnusableBotSession(deps.client, session.id);
throw error;
}
}
if (session.isArchived || session.permissionMode !== 'explore') {
await abandonUnusableBotSession(deps.client, session.id);
throw new Error(
`Bot Session could not enter explore mode: ${session.id}`,
);
}
}
deps.emitSessionsChanged('created', session.id);
return session.id;
},
Expand Down Expand Up @@ -226,6 +260,32 @@ async function collectRuntimeHostBotTurn(
throw new Error('Runtime Host Bot Session subscription ended before the Turn settled');
}

function isCommitOutcomeUnknown(error: unknown): boolean {
return (
error instanceof RuntimeHostOperationError &&
error.code === 'commit_outcome_unknown'
);
}

function isVerifiedExploreSession(
session: SessionCatalogProjection | null,
): session is SessionCatalogProjection {
return session !== null && !session.isArchived && session.permissionMode === 'explore';
}

// A Session that reached the Host but never got bound to a conversation is a
// leak: the desktop session list shows it and the next inbound message
// creates a sibling instead of reusing it. Archiving (rather than removing)
// keeps any committed state inspectable while taking the Session out of the
// active conversation flow. Best-effort by design — a cleanup failure must
// not mask the original create/pin error.
async function abandonUnusableBotSession(
client: RuntimeHostBotSessionClient,
sessionId: string,
): Promise<void> {
await client.setSessionLifecycle(sessionId, 'archived').catch(() => undefined);
}

function throwUnavailable(error: unknown, sessionId: string): void {
if (
(error instanceof RuntimeHostOperationError &&
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.