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 @@ -9,8 +9,9 @@ import {
import { trpcClient } from '@/lib/trpc';

/**
* Build the `RemoteAttachmentPart[]` payload for a CAPABLE remote CLI
* session (the active CLI advertised `capabilities.attachments: true`).
* Build the `RemoteAttachmentPart[]` payload for a remote CLI session the CLI
* has not reported as incapable (an unknown `capabilities.attachments` is
* optimistic; only an explicit `false` hides the paperclip).
* For each `file` in the composer's submission payload this mints a
* presigned GET via `trpcClient.cloudAgentNext.getAttachmentDownloadUrl`
* and returns the wire part the SDK appends to `send_message.parts`
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/components/agents/mobile-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ export function createMobileAgentSessionManager({
// answer: the open is stalled, not failed. The manager keeps the skeleton
// (then the slow-load state with Retry) instead of a premature error
// screen. Unwrapped from the TRPCClientError tRPC layers over it.
// The composer materializes presigned GET parts and sends them as
// `attachmentParts`, so the `supportsAttachments` gate may report a remote
// session supported before its CLI advertises the capability. Consumers
// that know only the cloud-only `attachments` field (web) omit this and
// keep remote sessions unsupported.
supportsRemoteAttachmentParts: true,
isStalledTransportError,
onToolAttachment: (partId, attachment) => {
cacheToolAttachment(partId, attachment);
Expand Down
18 changes: 12 additions & 6 deletions apps/mobile/src/components/agents/new-session-screen-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -650,10 +650,15 @@ export function NewSessionScreenBody() {
);

const isRemoteTargetSelected = runOnInstance !== null;
const instanceHasSessionClone = runOnInstance?.capabilities?.sessionClone === true;
// Clone entry: an incapable CLI shows the inline "cannot continue" reason
// immediately; a delivered clone/import failure overrides it after a Start
// attempt. Both clear when Run-on changes (see handleRunOnChange).
// Optimistic CLI-capability gate: an instance that has not advertised
// `sessionClone` is treated as capable; only an explicit
// `sessionClone: false` marks it incapable.
const instanceHasSessionClone =
runOnInstance !== null && runOnInstance.capabilities?.sessionClone !== false;
// Clone entry: a CLI the picker reported as incapable shows the inline
// "cannot continue" reason immediately; a delivered clone/import failure
// overrides it after a Start attempt. Both clear when Run-on changes (see
// handleRunOnChange).
const incapableCliSelected = isCloneEntry && runOnInstance !== null && !instanceHasSessionClone;
let runOnInlineNote: string | null = null;
if (incapableCliSelected) {
Expand Down Expand Up @@ -707,8 +712,9 @@ export function NewSessionScreenBody() {
const handleStartSession = useCallback(() => {
if (isCloneEntry) {
if (runOnInstance !== null) {
// Live CLI import: the dispatch carries the clone source id only when
// the instance advertises `sessionClone` (fail-closed otherwise).
// Live CLI import: the dispatch carries the clone source id unless the
// instance explicitly reported `sessionClone: false` (unknown is
// treated as capable).
remoteSpawn.onStart();
return;
}
Expand Down
105 changes: 102 additions & 3 deletions apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,11 +470,12 @@ describe('useRemoteSpawnDispatch spawn input chain', () => {
expect(onSpawnAdmitted).toHaveBeenCalledTimes(1);
});

it('does not call the admitted callback when admission denies files', () => {
it('does not call the admitted callback when the instance reported attachments false', () => {
const onSpawnAdmitted = vi.fn();
const { onStart } = runHook({
organizationId: 'org-xyz',
getSubmitPayload: () => filesPayload,
runOnInstance: { ...INSTANCE, capabilities: { attachments: false } },
onSpawnAdmitted: () => {
onSpawnAdmitted();
},
Expand All @@ -486,6 +487,21 @@ describe('useRemoteSpawnDispatch spawn input chain', () => {
expect(toastErrorMock).toHaveBeenCalledWith(remoteSpawnFilesNotSupportedToast());
});

it('admits files when the instance capability is unknown (optimistic default)', async () => {
const onSpawnAdmitted = vi.fn();
const { onStart } = runHook({
organizationId: 'org-xyz',
getSubmitPayload: () => filesPayload,
onSpawnAdmitted: () => {
onSpawnAdmitted();
},
});

await captureSpawnCall(onStart);
expect(onSpawnAdmitted).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();
});

it('does not call the admitted callback without a target instance', () => {
const onSpawnAdmitted = vi.fn();
const { onStart } = runHook({
Expand Down Expand Up @@ -522,13 +538,13 @@ describe('useRemoteSpawnDispatch spawn input chain', () => {
]);
});

it('does not spawn when a clone source is set but the instance lacks sessionClone', () => {
it('does not spawn when a clone source is set but the instance reported sessionClone false', () => {
const onCloneImportFailure = vi.fn();
const onSpawnAdmitted = vi.fn();
const { onStart } = runHook({
organizationId: 'org-xyz',
cloneFromKiloSessionId: 'ses_source',
runOnInstance: INSTANCE,
runOnInstance: { ...INSTANCE, capabilities: { sessionClone: false } },
onSpawnAdmitted: () => {
onSpawnAdmitted();
},
Expand All @@ -543,6 +559,89 @@ describe('useRemoteSpawnDispatch spawn input chain', () => {
expect(spawnMock).not.toHaveBeenCalled();
});

it('does not spawn when the refreshed instance reports attachments false', async () => {
const onSpawnAdmitted = vi.fn();
const onSpawnFailed = vi.fn();
// The refreshed list holds the same host on a new connectionId, this time
// reporting an explicit refusal of the file payload.
const liveInstances = [
{ ...INSTANCE, connectionId: 'conn-live', capabilities: { attachments: false } },
];
const { onStart } = runHook({
organizationId: 'org-xyz',
getSubmitPayload: () => filesPayload,
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
refetchInstances: () => Promise.resolve({ data: { instances: liveInstances } }),
onSpawnAdmitted: () => {
onSpawnAdmitted();
},
onSpawnFailed: () => {
onSpawnFailed();
},
});

onStart();
await vi.waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(remoteSpawnFilesNotSupportedToast());
});
// The press-time row admitted the file payload because its capability was
// unknown, so the refreshed row's explicit refusal has to stop the spawn
// that was already admitted and re-arm the abandon guard.
expect(onSpawnAdmitted).toHaveBeenCalledTimes(1);
expect(onSpawnFailed).toHaveBeenCalledTimes(1);
expect(spawnMock).not.toHaveBeenCalled();
});

it('does not spawn when the refreshed instance reports sessionClone false', async () => {
const onCloneImportFailure = vi.fn();
const onSpawnFailed = vi.fn();
// The refreshed list holds the same host on a new connectionId, this time
// reporting an explicit refusal of the clone source.
const liveInstances = [
{ ...INSTANCE, connectionId: 'conn-live', capabilities: { sessionClone: false } },
];
const { onStart } = runHook({
organizationId: 'org-xyz',
cloneFromKiloSessionId: 'ses_source',
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
refetchInstances: () => Promise.resolve({ data: { instances: liveInstances } }),
onCloneImportFailure: key => {
onCloneImportFailure(key);
},
onSpawnFailed: () => {
onSpawnFailed();
},
});

onStart();
await vi.waitFor(() => {
expect(onCloneImportFailure).toHaveBeenCalledWith('agentChat.newSession.cliCannotContinue');
});
expect(onSpawnFailed).toHaveBeenCalledTimes(1);
expect(spawnMock).not.toHaveBeenCalled();
});

it('spawns a clone when the instance capability is unknown (optimistic default)', async () => {
const { onStart } = runHook({
organizationId: 'org-xyz',
mode: 'code',
selection: { model: { providerID: 'anthropic', modelID: 'claude-x' } },
cloneFromKiloSessionId: 'ses_source',
runOnInstance: INSTANCE,
});

expect(await captureSpawnCall(onStart)).toEqual([
'conn-abc',
{
agent: 'code',
model: { providerID: 'anthropic', modelID: 'claude-x' },
orgId: 'org-xyz',
cloneFromKiloSessionId: 'ses_source',
},
{ operationKey: expect.any(String) },
]);
});

it('a clone entry navigates with no shareId (payload is null)', async () => {
const { onStart } = runHook({
organizationId: 'org-xyz',
Expand Down
31 changes: 26 additions & 5 deletions apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,12 @@ export function useRemoteSpawnDispatch({
toast.error(admission.toast);
return;
}
// Clone entry: the selected instance must advertise `sessionClone` before
// we send the source id. Fail before spawn (and before admitting the
// attempt) when the flag is missing, so the route shows the inline
// "cannot continue" note instead of firing a spawn that the CLI rejects.
if (fields.cloneFromKiloSessionId && runOnInstance.capabilities?.sessionClone !== true) {
// Clone entry: only an instance the picker reported as explicitly
// incapable (`sessionClone: false`) is refused before we send the source
// id; an unknown capability is optimistic. Fail before spawn (and before
// admitting the attempt) so the route shows the inline "cannot continue"
// note instead of firing a spawn the CLI rejects.
if (fields.cloneFromKiloSessionId && runOnInstance.capabilities?.sessionClone === false) {
Comment thread
iscekic marked this conversation as resolved.
onCloneImportFailureRef.current?.('agentChat.newSession.cliCannotContinue');
return;
}
Expand Down Expand Up @@ -301,6 +302,26 @@ export function useRemoteSpawnDispatch({
onSpawnFailedRef.current?.();
return;
}
// The live row can differ from the press-time row (a rebooted host
// comes back on a new connectionId), and it can report an explicit
// refusal the press-time row did not. Re-run both admission checks
// against it, so a file payload or a clone source is never spawned on
// an instance that now refuses it. The attempt was already admitted at
// press time, so a refusal here fails it and re-arms the abandon guard.
const liveAdmission = resolveRemoteSpawnAdmission({
instance: live,
payload: submitPayload,
});
if (!liveAdmission.allowed) {
toast.error(liveAdmission.toast);
onSpawnFailedRef.current?.();
return;
}
if (fields.cloneFromKiloSessionId && live.capabilities?.sessionClone === false) {
onCloneImportFailureRef.current?.('agentChat.newSession.cliCannotContinue');
onSpawnFailedRef.current?.();
return;
}
const selectedConnectionId = live.connectionId;
// A rebooted host advertises the same name + projectName on a new
// connectionId. Remap the selection to the live row so the spawn and
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/components/share/share-cli-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,9 @@ describe('resolveShareDestinationAdmission', () => {
).toEqual({ ok: true });
});

it('rejects cli + live + capabilities-absent (attachmentsCapable false) + files', () => {
// Absent capabilities map to attachmentsCapable: false at the call site.
it('rejects cli + live + incapable (attachments:false) + files', () => {
// Call sites pass `attachmentsCapable: false` only on an explicit
// `capabilities.attachments === false`; unknown capabilities are capable.
expect(
resolveShareDestinationAdmission({
createdOnPlatform: 'cli',
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/components/share/share-cli-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,17 @@ export function resolveShareHasFiles(
/**
* Decide whether a share payload may be committed to a destination row.
* Non-CLI platforms pass through; CLI rows require a live session, and
* file payloads additionally require `capabilities.attachments`.
* file payloads additionally require the CLI not to have denied the
* `attachments` capability. Call sites pass an optimistic value: an unknown
* capability counts as capable; only an explicit `attachments: false` is
* incapable.
*/
export function resolveShareDestinationAdmission(input: {
/** `created_on_platform` of the stored row. */
createdOnPlatform: string | null;
/** True when the row's session id is in the active-sessions set. */
live: boolean;
/** `capabilities.attachments === true` for the live row; false otherwise. */
/** False only when the live row reported `capabilities.attachments: false`. */
attachmentsCapable: boolean;
/** True when the share payload carries at least one file. */
hasFiles: boolean;
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/components/share/share-gate-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ export function ShareGateSheet({ shareId }: Readonly<ShareGateSheetProps>) {
const attachmentsCapableBySessionId = useMemo(() => {
const map = new Map<string, boolean>();
for (const session of sessions.activeSessions) {
map.set(session.id, session.capabilities?.attachments === true);
// Optimistic: only an explicit `attachments: false` marks the row
// incapable; an unknown capability stays capable.
map.set(session.id, session.capabilities?.attachments !== false);
}
return map;
}, [sessions.activeSessions]);
Expand Down Expand Up @@ -280,7 +282,9 @@ export function ShareGateSheet({ shareId }: Readonly<ShareGateSheetProps>) {
const admission: ShareDestinationAdmission = resolveShareDestinationAdmission({
createdOnPlatform: row.created_on_platform,
live: row.live,
attachmentsCapable: attachmentsCapableBySessionId.get(row.session_id) ?? false,
// Optimistic fallback: a live row missing from the map is unknown, not
// explicitly incapable.
attachmentsCapable: attachmentsCapableBySessionId.get(row.session_id) ?? true,
hasFiles: resolveShareHasFiles(validation, payload?.files.length ?? 0),
});
if (!admission.ok) {
Expand All @@ -305,7 +309,8 @@ export function ShareGateSheet({ shareId }: Readonly<ShareGateSheetProps>) {
const admission = resolveShareDestinationAdmission({
createdOnPlatform: 'cli',
live: true,
attachmentsCapable: instance.capabilities?.attachments === true,
// Optimistic: only an explicit `attachments: false` blocks files.
attachmentsCapable: instance.capabilities?.attachments !== false,
hasFiles: resolveShareHasFiles(validation, payload?.files.length ?? 0),
});
if (!admission.ok) {
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/lib/composer-auto-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
* `shareDelivered` is the ordering gate: text lands before files, so a rule
* built on `hasText` alone would send the text and drop the files.
*
* `attachmentsEnabled` keeps the one-shot latch unspent while a freshly
* spawned remote session has not yet advertised `capabilities.attachments`.
* Sending early would hit the composer's "can't receive files" refusal and
* burn the latch for a session that becomes capable a moment later.
* `attachmentsEnabled` keeps the one-shot latch unspent when the destination
* has explicitly reported it cannot receive files
* (`capabilities.attachments === false`). Sending early would hit the
* composer's "can't receive files" refusal and burn the latch for a session
* the CLI may later report as capable.
*/
export function shouldAutoSendPrefilledShare(input: {
autoSend: boolean;
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/lib/remote-spawn-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ describe('resolveRemoteSpawnAdmission', () => {
).toEqual({ allowed: false, toast: remoteSpawnFilesNotSupportedToast() });
});

it('rejects files when capability is absent', () => {
it('admits files when the capability is unknown (optimistic default)', () => {
expect(resolveRemoteSpawnAdmission({ instance, payload: filesPayload })).toEqual({
allowed: false,
toast: remoteSpawnFilesNotSupportedToast(),
allowed: true,
});
});

Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/remote-spawn-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export function resolveRemoteSpawnAdmission(input: {
if (payload === null) {
return { allowed: true };
}
if (payload.files.length > 0 && instance.capabilities?.attachments !== true) {
// Optimistic CLI-capability gate: an instance whose `attachments`
// capability is still unknown (`undefined`) may receive files; only an
// explicit `attachments: false` blocks the file payload.
if (payload.files.length > 0 && instance.capabilities?.attachments === false) {
return { allowed: false, toast: remoteSpawnFilesNotSupportedToast() };
}

Expand Down
19 changes: 11 additions & 8 deletions packages/cloud-agent-sdk/src/cli-live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ type CliLiveTransportConfig = {
* CLI in `sessions.heartbeat` / `sessions.list` change (upgrade, downgrade,
* reconnect, or absent). The payload is the latest capabilities — `undefined`
* means the CLI has not reported any (older CLIs, mid-reconnect, or a CLI
* whose session list dropped this session). The session manager uses this
* to recompute the `supportsAttachments` gate.
* whose session list dropped this session), which the session manager's
* gate treats as supported. The session manager uses this to recompute the
* `supportsAttachments` gate.
*/
onCapabilitiesChange?:
| ((capabilities: { attachments?: boolean | undefined } | undefined) => void)
Expand Down Expand Up @@ -241,9 +242,10 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor
});
}
// A CLI handoff or a permanent drop invalidates whatever the prior
// owner reported — the new owner has to re-advertise before any
// capability re-enables. Empty currentCapabilities also drives the
// existing 'idle' reset on the consumer side.
// owner reported. The session manager's gate is optimistic, so it keeps
// the feature available until the new owner's next heartbeat /
// sessions.list reports an explicit negative. Empty currentCapabilities
// also drives the existing 'idle' reset on the consumer side.
publishCapabilities(undefined);
config.onCapabilityChange?.();

Expand Down Expand Up @@ -837,9 +839,10 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor
handleSystemMessage(msg.event, msg.data);
});
const offReconnect = config.userWebConnection.onReconnect(() => {
// Recompute the capability gate fail-closed immediately. The prior
// owner may have been attachment-capable, but after a reconnect we
// must wait for the next heartbeat / sessions.list to re-advertise.
// Publish the absent capabilities immediately. The session manager's
// gate is optimistic, so it keeps reporting supported until the next
// heartbeat / sessions.list either re-advertises the capability or
// explicitly denies it.
publishCapabilities(undefined);
replayCurrentSnapshot(false);
// The snapshot store lags the live stream, and the CLI only forwards
Expand Down
Loading
Loading