summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 20:48:24 +0900
committerAdam Malczewski <[email protected]>2026-06-27 20:48:24 +0900
commit04356c8678ae8dd1d7ddca2d0460b514116adc2e (patch)
tree6c81894ef02d062570b12f4d3a871e58600dcb9c /packages/session-orchestrator/src
parent3184b10e614ce6249c83aa111368e98f6689f456 (diff)
parentb24ed99e89bc657e8c98c7cef8608e0c0b7594da (diff)
downloaddispatch-04356c8678ae8dd1d7ddca2d0460b514116adc2e.tar.gz
dispatch-04356c8678ae8dd1d7ddca2d0460b514116adc2e.zip
Merge branch 'feature/vision-handoff' into dev
# Conflicts: # packages/session-orchestrator/src/extension.ts # packages/session-orchestrator/src/orchestrator.ts
Diffstat (limited to 'packages/session-orchestrator/src')
-rw-r--r--packages/session-orchestrator/src/extension.ts15
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts132
-rw-r--r--packages/session-orchestrator/src/pure.test.ts33
-rw-r--r--packages/session-orchestrator/src/pure.ts32
4 files changed, 207 insertions, 5 deletions
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index 0cd83ef..783d894 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -12,6 +12,7 @@ import {
createSessionOrchestrator,
createWarmService,
sessionOrchestratorHandle,
+ visionHandoffLocalHandle,
} from "./orchestrator.js";
import { selectFirstProvider } from "./pure.js";
import { filterRemoteIncompatibleTools, toolsFilter } from "./tools-filter.js";
@@ -107,6 +108,20 @@ export function activate(host: HostAPI): void {
return undefined;
}
},
+ resolveVisionHandoff: () => {
+ // Lazily resolve the vision-handoff service. Returns undefined when the
+ // vision-handoff extension isn't loaded (images pass through unchanged —
+ // correct for vision-capable models; the feature degrades off cleanly for
+ // text-only turns). Lazy so activation order doesn't matter; the
+ // activated-manifests guard avoids a getService throw when absent.
+ const loaded = host.getExtensions().some((m) => m.id === "vision-handoff");
+ if (!loaded) return undefined;
+ try {
+ return host.getService(visionHandoffLocalHandle);
+ } catch {
+ return undefined;
+ }
+ },
});
host.provideService(sessionOrchestratorHandle, orchestrator);
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 617c079..5c36922 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -5,6 +5,7 @@ import type {
CompactionResult,
ConversationStatus,
EventHookDescriptor,
+ ImageInput,
Logger,
ModelInfo,
ProviderContract,
@@ -34,11 +35,71 @@ import {
} from "./pure.js";
import type { ToolAssembly } from "./tools-filter.js";
+// --- Vision handoff (lazy, optional) ---
+
+/**
+ * Minimal contract the vision-handoff service satisfies. Defined here (not
+ * imported from the vision-handoff package) so the orchestrator has NO
+ * compile-time dependency on it — the service is resolved lazily at runtime
+ * (like the message-queue / system-prompt services), and the feature degrades
+ * off cleanly when the extension isn't loaded (images pass through unchanged,
+ * which is correct for vision-capable models and a no-op for text-only turns).
+ *
+ * `prepareForProvider` transforms a message list for the provider: if the
+ * active model is vision-capable, messages pass through unchanged; otherwise
+ * image chunks are replaced with numbered placeholders (telling the model to
+ * call `consult_vision`) and the images are registered for tool access.
+ */
+export interface VisionHandoffService {
+ /**
+ * Store images to tmp files and return compact URLs. Each input image's data
+ * URL is saved to a tmp file and replaced with a compact HTTP path so the
+ * persisted conversation store holds a tiny string, not megabytes of base64.
+ * When `saveImageToTmp` is not configured, data URLs pass through unchanged.
+ */
+ readonly storeImages: (
+ conversationId: string,
+ images: readonly ImageInput[],
+ ) => Promise<readonly ImageInput[]>;
+
+ /** Delete all tmp images for a conversation (on close). Best-effort. */
+ readonly purgeConversationImages: (conversationId: string) => Promise<void>;
+
+ readonly prepareForProvider: (
+ messages: readonly ChatMessage[],
+ currentModelName: string | undefined,
+ opts?: {
+ readonly conversationId?: string;
+ readonly imageLimit?: number;
+ readonly signal?: AbortSignal;
+ readonly logger?: Logger;
+ },
+ ) => Promise<readonly ChatMessage[]>;
+}
+
+/**
+ * Local handle for the vision-handoff service, keyed by the same ID the
+ * vision-handoff extension registers under (`"vision-handoff/service"`). Defined
+ * locally (not imported) so the orchestrator has no compile-time dependency on
+ * the vision-handoff package — the service is resolved lazily at runtime, and
+ * the feature degrades off cleanly when the extension isn't loaded.
+ */
+export const visionHandoffLocalHandle: ServiceHandle<VisionHandoffService> =
+ defineService<VisionHandoffService>("vision-handoff/service");
+
// --- Broadcast hub types ---
export interface StartTurnInput {
readonly conversationId: string;
readonly text: string;
+ /**
+ * Images attached to this turn (e.g. user-pasted screenshots). Each is
+ * appended as an `image` chunk on the persisted user message. For a
+ * vision-capable model the images pass through to the provider natively; for
+ * a non-vision model the vision handoff transcribes them to text first.
+ * Optional — omit for a text-only turn.
+ */
+ readonly images?: readonly ImageInput[];
readonly modelName?: string;
readonly cwd?: string;
/**
@@ -77,6 +138,12 @@ export type StartTurnResult =
export interface EnqueueInput {
readonly conversationId: string;
readonly text: string;
+ /**
+ * Images attached (the steering / opening message analog of
+ * `StartTurnInput.images`). Threaded to `startTurn` when the conversation is
+ * idle (the message starts a turn). Additive optional.
+ */
+ readonly images?: readonly ImageInput[];
/** Workspace to stamp on a new conversation. Defaults to `"default"`. */
readonly workspaceId?: string;
/**
@@ -291,6 +358,8 @@ export interface SessionOrchestrator {
workspaceId?: string;
/** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */
systemPrompt?: string;
+ /** Images attached to this turn — see {@link StartTurnInput.images}. */
+ images?: readonly ImageInput[];
}): Promise<void>;
}
@@ -345,6 +414,17 @@ export interface SessionOrchestratorDeps {
* when the stream completes. Lazy so activation order doesn't matter.
*/
readonly resolveConcurrencyLimiter?: () => ConcurrencyLimiter | undefined;
+ /**
+ * Lazily resolves the vision-handoff service, or `undefined` when the
+ * vision-handoff extension isn't loaded. Used to transcribe image chunks to
+ * text for non-vision models before they reach the provider (so a text-only
+ * model can still reason about pasted/code images). When `undefined`, images
+ * pass through unchanged (correct for vision-capable models; a text-only model
+ * would then receive image content its API may reject — the feature degrades
+ * off cleanly for text-only turns since there are no images). Lazy so
+ * activation order doesn't matter; called per-turn.
+ */
+ readonly resolveVisionHandoff?: () => VisionHandoffService | undefined;
/** Apply the per-turn tools filter chain. Injected for testability. */
readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>;
/** Base logger (auto-scoped to this extension); childed per turn for span capture. */
@@ -447,6 +527,7 @@ export function createSessionOrchestrator(
reasoningEffortOverride: ReasoningEffort | undefined,
workspaceId: string,
systemPromptOverride: string | undefined,
+ images: readonly ImageInput[] | undefined,
): void {
const turnId = generateTurnId();
const promptStartedAt = deps.now?.() ?? Date.now();
@@ -569,7 +650,18 @@ export function createSessionOrchestrator(
const effectiveModelName = resolveModelName(modelName, storedModel);
const history = await deps.conversationStore.load(conversationId);
- const userMsg = buildUserMessage(text);
+
+ // Store images to tmp files (compact URLs) BEFORE building the user
+ // message so the persisted chunks hold tiny URL references, not
+ // megabytes of base64 data URLs. When the vision-handoff service isn't
+ // loaded, images pass through unchanged (backward compatible).
+ const visionHandoffForStore = deps.resolveVisionHandoff?.();
+ const storedImages =
+ visionHandoffForStore !== undefined && images !== undefined
+ ? await visionHandoffForStore.storeImages(conversationId, images)
+ : images;
+
+ const userMsg = buildUserMessage(text, storedImages);
// Workspace assignment for new conversations happens BEFORE
// effective-cwd resolution (see workspaceSetupPromise above) so
@@ -744,9 +836,35 @@ export function createSessionOrchestrator(
return [{ role: "user", chunks: [{ type: "text", text: steerText }] }];
};
+ // Vision handoff: transform the message list for the provider. When the
+ // active model is vision-capable, images pass through natively (no-op).
+ // When it is NOT vision-capable, image chunks are transcribed to text
+ // descriptions via a vision-capable model — so a text-only model can
+ // still reason about images. The PERSISTED user message keeps the
+ // original image chunks (appended below); only the provider's view is
+ // transcribed. When the vision-handoff service isn't loaded, images pass
+ // through unchanged (correct for vision models; text-only models would
+ // then receive image content their API may reject — degrades off cleanly
+ // for text-only turns with no images).
+ const visionHandoff = deps.resolveVisionHandoff?.();
+ let providerMessages: readonly ChatMessage[] = [...history, userMsg];
+ if (visionHandoff !== undefined) {
+ const visionSettings = await deps.conversationStore.getVisionSettings();
+ providerMessages = await visionHandoff.prepareForProvider(
+ providerMessages,
+ effectiveModelName,
+ {
+ conversationId,
+ imageLimit: visionSettings.imageLimit,
+ signal: controller.signal,
+ ...(turnLogger !== undefined ? { logger: turnLogger } : {}),
+ },
+ );
+ }
+
const opts: RunTurnInput = {
provider,
- messages: [...history, userMsg],
+ messages: providerMessages,
tools: assembled.tools,
dispatch,
emit: emitAndAccumulate,
@@ -852,6 +970,7 @@ export function createSessionOrchestrator(
reasoningEffort,
workspaceId,
systemPrompt,
+ images,
}) {
if (activeTurns.has(conversationId)) {
return { started: false, reason: "already-active" };
@@ -865,18 +984,20 @@ export function createSessionOrchestrator(
reasoningEffort,
workspaceId ?? "default",
systemPrompt,
+ images,
);
const turn = activeTurns.get(conversationId);
const turnId = turn !== undefined ? turn.turnId : "";
return { started: true, turnId };
},
- enqueue({ conversationId, text, workspaceId, computerId }) {
+ enqueue({ conversationId, text, workspaceId, computerId, images }) {
const result = orchestrator.startTurn({
conversationId,
text,
...(workspaceId !== undefined ? { workspaceId } : {}),
...(computerId !== undefined ? { computerId } : {}),
+ ...(images !== undefined ? { images } : {}),
});
if (result.started) {
return { startedTurn: true, queue: [] };
@@ -939,6 +1060,9 @@ export function createSessionOrchestrator(
});
});
void deps.conversationStore.setConversationStatus(conversationId, "closed");
+ // Purge tmp images for this conversation (best-effort, fire-and-forget).
+ const vh = deps.resolveVisionHandoff?.();
+ if (vh !== undefined) void vh.purgeConversationImages(conversationId);
return { abortedTurn };
},
@@ -961,6 +1085,7 @@ export function createSessionOrchestrator(
reasoningEffort,
workspaceId,
systemPrompt,
+ images,
}) {
const turnInput: StartTurnInput = {
conversationId,
@@ -971,6 +1096,7 @@ export function createSessionOrchestrator(
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
...(systemPrompt !== undefined ? { systemPrompt } : {}),
+ ...(images !== undefined ? { images } : {}),
};
const result = orchestrator.startTurn(turnInput);
if (!result.started) {
diff --git a/packages/session-orchestrator/src/pure.test.ts b/packages/session-orchestrator/src/pure.test.ts
index c75cb82..7a574f1 100644
--- a/packages/session-orchestrator/src/pure.test.ts
+++ b/packages/session-orchestrator/src/pure.test.ts
@@ -26,6 +26,39 @@ describe("buildUserMessage", () => {
expect(msg.role).toBe("user");
expect(msg.chunks[0]).toEqual({ type: "text", text: "" });
});
+
+ it("appends image chunks after the text chunk when images are given", () => {
+ const msg = buildUserMessage("look at this", [
+ { url: "data:image/png;base64,aaa" },
+ { url: "data:image/jpeg;base64,bbb", mimeType: "image/jpeg" },
+ ]);
+ expect(msg.chunks).toHaveLength(3);
+ expect(msg.chunks[0]).toEqual({ type: "text", text: "look at this" });
+ expect(msg.chunks[1]).toEqual({ type: "image", url: "data:image/png;base64,aaa" });
+ expect(msg.chunks[2]).toEqual({
+ type: "image",
+ url: "data:image/jpeg;base64,bbb",
+ mimeType: "image/jpeg",
+ });
+ });
+
+ it("builds an image-only message when text is empty", () => {
+ const msg = buildUserMessage("", [{ url: "data:image/png;base64,zzz" }]);
+ expect(msg.chunks).toHaveLength(1);
+ expect(msg.chunks[0]).toEqual({ type: "image", url: "data:image/png;base64,zzz" });
+ });
+
+ it("includes mimeType when provided", () => {
+ const msg = buildUserMessage("hi", [
+ { url: "data:image/webp;base64,x", mimeType: "image/webp" },
+ ]);
+ expect((msg.chunks[1] as { mimeType?: string }).mimeType).toBe("image/webp");
+ });
+
+ it("omits mimeType when not provided", () => {
+ const msg = buildUserMessage("hi", [{ url: "https://example.com/x.png" }]);
+ expect((msg.chunks[1] as { mimeType?: string }).mimeType).toBeUndefined();
+ });
});
describe("selectFirstProvider", () => {
diff --git a/packages/session-orchestrator/src/pure.ts b/packages/session-orchestrator/src/pure.ts
index 2208e8f..0d2068f 100644
--- a/packages/session-orchestrator/src/pure.ts
+++ b/packages/session-orchestrator/src/pure.ts
@@ -1,12 +1,40 @@
import type {
ChatMessage,
+ Chunk,
+ ImageInput,
ProviderContract,
ReasoningEffort,
ToolDispatchPolicy,
} from "@dispatch/kernel";
-export function buildUserMessage(text: string): ChatMessage {
- return { role: "user", chunks: [{ type: "text", text }] };
+/**
+ * Build the persisted user message for a turn. When `images` are provided, each
+ * is appended as an `image` chunk AFTER the text chunk, so the persisted message
+ * carries both the prompt text and the attached images (the frontend renders
+ * the images; vision-capable providers receive them natively; non-vision
+ * providers have them transcribed by the vision handoff before streaming).
+ *
+ * Pure: inputs → a ChatMessage, no I/O.
+ */
+export function buildUserMessage(text: string, images?: readonly ImageInput[]): ChatMessage {
+ const chunks: Chunk[] = [];
+ if (text.length > 0) {
+ chunks.push({ type: "text", text });
+ }
+ if (images !== undefined) {
+ for (const img of images) {
+ chunks.push({
+ type: "image",
+ url: img.url,
+ ...(img.mimeType !== undefined ? { mimeType: img.mimeType } : {}),
+ });
+ }
+ }
+ // An image-only message (empty text) is valid.
+ if (chunks.length === 0) {
+ chunks.push({ type: "text", text: "" });
+ }
+ return { role: "user", chunks };
}
// ── Provider-error retry backoff schedule ───────────────────────────────────