Skip to content
Merged
20 changes: 13 additions & 7 deletions assistant/src/daemon/conversation-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export function formatCompactResult(result: ContextWindowResult): string {
if (!result.compacted) {
return `Context compaction skipped — ${result.reason ?? "nothing to compact"}.`;
}
const saved = result.previousEstimatedInputTokens - result.estimatedInputTokens;
const saved =
result.previousEstimatedInputTokens - result.estimatedInputTokens;
return [
"Context Compacted\n",
`Tokens: ${fmt(result.previousEstimatedInputTokens)} → ${fmt(result.estimatedInputTokens)} (${fmt(saved)} saved)`,
Expand Down Expand Up @@ -295,13 +296,13 @@ export async function drainQueue(
if (next.isInteractive === false) {
conversation.clearProxyAvailability();
} else {
// Restore proxy availability only for desktop-originating turns (macos/ios)
// Restore proxy availability only for desktop-originating turns (macos)
// in case a prior non-interactive drain disabled it. Non-desktop interactive
// interfaces (CLI, Vellum) should not re-enable desktop host proxies.
const interfaceCtx =
queuedInterfaceCtx ?? conversation.getTurnInterfaceContext();
const sourceInterface = interfaceCtx?.userMessageInterface;
if (sourceInterface === "macos" || sourceInterface === "ios") {
if (sourceInterface === "macos") {
conversation.restoreProxyAvailability();
conversation.addPreactivatedSkillId("computer-use");
}
Expand All @@ -310,7 +311,8 @@ export async function drainQueue(
// Snapshot persona context at turn start so later tool turns can't pick up
// a different actor's context if a concurrent request mutates the live fields.
conversation.currentTurnTrustContext = conversation.trustContext;
conversation.currentTurnChannelCapabilities = conversation.channelCapabilities;
conversation.currentTurnChannelCapabilities =
conversation.channelCapabilities;

// Resolve slash commands for queued messages
const slashResult = await resolveSlash(
Expand Down Expand Up @@ -671,7 +673,8 @@ export async function processMessage(
// Snapshot persona context at turn start so later tool turns can't pick up
// a different actor's context if a concurrent request mutates the live fields.
conversation.currentTurnTrustContext = conversation.trustContext;
conversation.currentTurnChannelCapabilities = conversation.channelCapabilities;
conversation.currentTurnChannelCapabilities =
conversation.channelCapabilities;
conversation.currentActiveSurfaceId = activeSurfaceId;
conversation.currentPage = currentPage;
const trimmedContent = content.trim();
Expand Down Expand Up @@ -880,7 +883,9 @@ export async function processMessage(
try {
const pmTurnCtx = conversation.getTurnChannelContext();
const pmInterfaceCtx = conversation.getTurnInterfaceContext();
const pmProvenance = provenanceFromTrustContext(conversation.trustContext);
const pmProvenance = provenanceFromTrustContext(
conversation.trustContext,
);
const pmChannelMeta = {
...pmProvenance,
...(pmTurnCtx
Expand All @@ -892,7 +897,8 @@ export async function processMessage(
...(pmInterfaceCtx
? {
userMessageInterface: pmInterfaceCtx.userMessageInterface,
assistantMessageInterface: pmInterfaceCtx.assistantMessageInterface,
assistantMessageInterface:
pmInterfaceCtx.assistantMessageInterface,
}
: {}),
};
Expand Down
11 changes: 4 additions & 7 deletions assistant/src/daemon/handlers/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,10 @@ export function makeEventSender(params: {
guardianPrincipalId: trustContext?.guardianPrincipalId ?? undefined,
toolName: event.toolName,
commandPreview:
redactSecrets(
summarizeToolInput(event.toolName, inputRecord),
) || undefined,
redactSecrets(summarizeToolInput(event.toolName, inputRecord)) ||
undefined,
riskLevel: event.riskLevel,
activityText: activityRaw
? redactSecrets(activityRaw)
: undefined,
activityText: activityRaw ? redactSecrets(activityRaw) : undefined,
executionTarget: event.executionTarget,
status: "pending",
requestCode: generateCanonicalRequestCode(),
Expand Down Expand Up @@ -304,7 +301,7 @@ export async function handleConversationCreate(
// Only create the host bash proxy for desktop client interfaces that can
// execute commands on the user's machine. Set before updateClient so
// updateClient's call to hostBashProxy.updateSender targets the new proxy.
if (transportInterface === "macos" || transportInterface === "ios") {
if (transportInterface === "macos") {
const proxy = new HostBashProxy(sendEvent, (requestId) => {
pendingInteractions.resolve(requestId);
});
Expand Down
27 changes: 23 additions & 4 deletions assistant/src/daemon/message-types/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@ export interface ConversationListRequest {
limit?: number;
}

/** Lightweight conversation transport metadata for channel identity and natural-language guidance. */
export interface ConversationTransportMetadata {
/** Shared fields for all transport metadata variants. */
interface BaseTransportMetadata {
/** Logical channel identifier (e.g. "desktop", "telegram", "mobile"). */
channelId: ChannelId;
/** Interface identifier for this transport (e.g. "macos", "ios", "cli"). */
interfaceId?: InterfaceId;
/** Optional natural-language hints for channel-specific UX behavior. */
hints?: string[];
/** Optional concise UX brief for this channel. */
Expand All @@ -28,6 +26,27 @@ export interface ConversationTransportMetadata {
chatType?: string;
}

/** Transport metadata for macOS desktop clients, including host environment fields. */
export interface MacosTransportMetadata extends BaseTransportMetadata {
/** Interface identifier for macOS transport. */
interfaceId: "macos";
/** Home directory of the host macOS user. */
hostHomeDir?: string;
/** Username of the host macOS user. */
hostUsername?: string;
}

/** Transport metadata for non-macOS transports. */
export interface NonMacosTransportMetadata extends BaseTransportMetadata {
/** Interface identifier for this transport (e.g. "ios", "cli"). */
interfaceId?: Exclude<InterfaceId, "macos">;
}

/** Lightweight conversation transport metadata for channel identity and natural-language guidance. */
export type ConversationTransportMetadata =
| MacosTransportMetadata
| NonMacosTransportMetadata;

export interface ConversationCreateRequest {
type: "conversation_create";
title?: string;
Expand Down
25 changes: 23 additions & 2 deletions assistant/src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,28 @@ export class DaemonServer {
{ channelId: transport.channelId },
"Transport metadata received",
);
conversation.setTransportHints(transport.hints);

// Build enriched hints: interface ID first, then host environment (macOS
// only), then any client-provided hints.
const enrichedHints: string[] = [];

const interfaceLabel = parseInterfaceId(transport.interfaceId) ?? "vellum";
enrichedHints.push(`User is messaging from interface: ${interfaceLabel}`);

if (transport.interfaceId === "macos") {
if (transport.hostHomeDir) {
enrichedHints.push(`Host home directory: ${transport.hostHomeDir}`);
}
if (transport.hostUsername) {
enrichedHints.push(`Host username: ${transport.hostUsername}`);
}
}

if (transport.hints) {
enrichedHints.push(...transport.hints);
}

conversation.setTransportHints(enrichedHints);
Comment on lines +379 to +397
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.

🚩 Transport hints are now always injected for conversations with transport metadata

Previously at assistant/src/daemon/server.ts:376, setTransportHints was called with transport.hints directly — which could be undefined, resulting in no hints injection. Now, enrichedHints always contains at least one element ("User is messaging from interface: ...") before client-provided hints are appended. This means transport hints will now be injected into every LLM request for conversations created with transport metadata, even if the client provided no hints. This adds a small token overhead to every request and subtly changes the assistant's context. The change appears intentional (enriching context with interface identity), but reviewers should confirm this always-on injection is desired for all interface types (e.g., CLI, Vellum web).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

constructor() {
Expand Down Expand Up @@ -1070,7 +1091,7 @@ export class DaemonServer {
// Guard: don't replace an active proxy during concurrent turn races —
// another request may have started processing between the isProcessing()
// check above and the await on ensureActorScopedHistory().
if (resolvedInterface === "macos" || resolvedInterface === "ios") {
if (resolvedInterface === "macos") {
if (!conversation.isProcessing() || !conversation.hostBashProxy) {
conversation.setHostBashProxy(
new HostBashProxy(conversation.getCurrentSender(), (requestId) => {
Expand Down
10 changes: 5 additions & 5 deletions assistant/src/runtime/routes/conversation-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,9 @@ function isToolResultType(type: string): boolean {
function isSystemNoticeText(block: Record<string, unknown>): boolean {
if (block.type !== "text") return false;
const text = typeof block.text === "string" ? block.text : "";
return text.startsWith("<system_notice>") && text.endsWith("</system_notice>");
return (
text.startsWith("<system_notice>") && text.endsWith("</system_notice>")
);
}

/**
Expand Down Expand Up @@ -1115,7 +1117,7 @@ export async function handleSendMessage(
// channels, headless) fall back to local execution.
// Set the proxy BEFORE updateClient so updateClient's call to
// hostBashProxy.updateSender targets the correct (new) proxy.
if (sourceInterface === "macos" || sourceInterface === "ios") {
if (sourceInterface === "macos") {
// Reuse the existing proxy if the conversation is actively processing a
// host bash request to avoid orphaning in-flight requests.
if (!conversation.isProcessing() || !conversation.hostBashProxy) {
Expand Down Expand Up @@ -1152,9 +1154,7 @@ export async function handleSendMessage(
// When proxies are preserved during an active turn (non-desktop request while
// processing), skip updating proxy senders to avoid degrading them.
const preservingProxies =
conversation.isProcessing() &&
sourceInterface !== "macos" &&
sourceInterface !== "ios";
conversation.isProcessing() && sourceInterface !== "macos";
conversation.updateClient(onEvent, !isInteractive, {
skipProxySenderUpdate: preservingProxies,
});
Expand Down
8 changes: 4 additions & 4 deletions cli/src/commands/teleport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ async function importViaHttp(
"Content-Type": "application/octet-stream",
},
body: new Blob([bundleData]),
signal: AbortSignal.timeout(120_000),
signal: AbortSignal.timeout(300_000),
});

// Retry once with a fresh token on 401
Expand All @@ -446,13 +446,13 @@ async function importViaHttp(
"Content-Type": "application/octet-stream",
},
body: new Blob([bundleData]),
signal: AbortSignal.timeout(120_000),
signal: AbortSignal.timeout(300_000),
});
}
}
} catch (err) {
if (err instanceof Error && err.name === "TimeoutError") {
console.error("Error: Import request timed out after 2 minutes.");
console.error("Error: Import request timed out after 5 minutes.");
process.exit(1);
}
const msg = err instanceof Error ? err.message : String(err);
Expand Down Expand Up @@ -706,7 +706,7 @@ async function importToAssistant(
: await platformImportBundle(bundleData, token, entry.runtimeUrl);
} catch (err) {
if (err instanceof Error && err.name === "TimeoutError") {
console.error("Error: Import request timed out after 2 minutes.");
console.error("Error: Import request timed out after 5 minutes.");
process.exit(1);
}
throw err;
Expand Down
4 changes: 2 additions & 2 deletions cli/src/lib/platform-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ export async function platformImportBundle(
"Content-Type": "application/octet-stream",
},
body: new Blob([bundleData]),
signal: AbortSignal.timeout(120_000),
signal: AbortSignal.timeout(300_000),
});

const body = (await response.json().catch(() => ({}))) as Record<
Expand Down Expand Up @@ -529,7 +529,7 @@ export async function platformImportBundleFromGcs(
method: "POST",
headers: await authHeaders(token, platformUrl),
body: JSON.stringify({ bundle_key: bundleKey }),
signal: AbortSignal.timeout(120_000),
signal: AbortSignal.timeout(300_000),
},
);

Expand Down
2 changes: 1 addition & 1 deletion clients/ios/Views/InputBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ struct InputBarView: View {
VButton(
label: "Stop generation",
iconOnly: VIcon.square.rawValue,
style: .contrast,
style: .primary,
action: onStop
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ struct ComposerView: View {
VButton(
label: "Stop generation",
iconOnly: VIcon.square.rawValue,
style: .contrast,
style: .primary,
iconSize: composerActionButtonSize,
action: onStop
)
Expand Down Expand Up @@ -612,7 +612,7 @@ VStreamingWaveform(
VButton(
label: manager.state == .listening ? "Mute" : "Unmute",
iconOnly: manager.state == .listening ? VIcon.mic.rawValue : VIcon.micOff.rawValue,
style: .contrast,
style: .primary,
iconSize: composerActionButtonSize,
action: { manager.toggleListening() }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,7 @@ struct ThinkingBlockView: View {
let content: String
let isStreaming: Bool

@State private var isExpanded: Bool
@State private var userHasToggled: Bool = false

init(content: String, isStreaming: Bool) {
self.content = content
self.isStreaming = isStreaming
_isExpanded = State(initialValue: isStreaming)
}
@State private var isExpanded: Bool = false

var body: some View {
VStack(alignment: .leading, spacing: 0) {
Expand Down Expand Up @@ -52,20 +45,6 @@ struct ThinkingBlockView: View {
#endif
}
}
.onChange(of: isStreaming) { _, newValue in
Task { @MainActor in
if newValue {
userHasToggled = false
withAnimation(VAnimation.fast) {
isExpanded = true
}
} else if !userHasToggled {
withAnimation(VAnimation.fast) {
isExpanded = false
}
}
}
}
.background(VColor.surfaceOverlay)
.clipShape(RoundedRectangle(cornerRadius: VRadius.md))
}
Expand All @@ -74,7 +53,6 @@ struct ThinkingBlockView: View {

private var headerRow: some View {
Button(action: {
userHasToggled = true
withAnimation(VAnimation.fast) {
isExpanded.toggle()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ struct SettingsBillingReferralCard: View {
// MARK: - Has Code State

private func hasCodeState(_ code: ReferralCodeResponse) -> some View {
SettingsCard(title: "Referrals", subtitle: "Share your link and earn credits") {
SettingsCard(title: "Referrals", subtitle: "Share your referral link to earn up to 100 free credits") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep referral cap text driven by API value

This change hardcodes the referral limit to 100 in the card subtitle while also removing the previous earning_cap rendering, so the UI can now show incorrect billing terms whenever the backend cap differs by user, plan, or campaign. Because ReferralCodeResponse still provides earning_cap, this should stay data-driven (or avoid a numeric claim entirely) to prevent misleading users.

Useful? React with 👍 / 👎.

VStack(alignment: .leading, spacing: VSpacing.lg) {
// Referral URL row
HStack(spacing: VSpacing.sm) {
Expand Down Expand Up @@ -104,10 +104,6 @@ struct SettingsBillingReferralCard: View {
}
}

// Earning cap note
Text("Earn up to \(code.earning_cap.replacingOccurrences(of: ".00", with: "")) referral credits")
.font(VFont.bodySmallDefault)
.foregroundStyle(VColor.contentTertiary)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,25 @@ struct SettingsBillingTab: View {
// MARK: - Add Credits Card

private var addFundsCard: some View {
SettingsCard(title: "Add Credits") {
SettingsCard(title: "Add Credits", subtitle: addCreditsSubtitle) {
topUpContent
}
}

private var addCreditsSubtitle: String? {
guard let summary else { return nil }
let maxFormatted: String = {
let value = Int(Double(summary.maximum_balance) ?? 0)
if value > 0 {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: value)) ?? summary.maximum_balance
}
return summary.maximum_balance
}()
return "Credits cost $1 each, with a maximum balance of \(maxFormatted). Unused credits expire 12 months after purchase."
}

@ViewBuilder
private var topUpContent: some View {
VStack(alignment: .leading, spacing: VSpacing.md) {
Expand All @@ -196,20 +210,6 @@ struct SettingsBillingTab: View {
}
)
.frame(maxWidth: 200)
if let summary {
let maxFormatted: String = {
let value = Int(Double(summary.maximum_balance) ?? 0)
if value > 0 {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: value)) ?? summary.maximum_balance
}
return summary.maximum_balance
}()
Text("1 credit = $1 USD. \(maxFormatted) max credit balance. Credits expire 12 months after purchase.")
.font(VFont.bodySmallDefault)
.foregroundStyle(VColor.contentTertiary)
}
}

VButton(
Expand Down
Loading
Loading