summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 20:40:19 +0900
committerAdam Malczewski <[email protected]>2026-06-27 20:40:19 +0900
commitb24ed99e89bc657e8c98c7cef8608e0c0b7594da (patch)
tree668051260825dd49fcdca33062be8d54d60d7bd6
parent2e741d1c1ac309327aff4fed0e248bc5baa342d4 (diff)
downloaddispatch-b24ed99e89bc657e8c98c7cef8608e0c0b7594da.tar.gz
dispatch-b24ed99e89bc657e8c98c7cef8608e0c0b7594da.zip
feat(vision): prefix consultation tab titles with 'IMAGE - '
When a non-vision model (e.g. GLM) calls consult_vision, the new Kimi consultation tab now shows 'IMAGE - <question>' instead of the bare question-derived title, making image-consultation tabs visually distinguishable from normal conversation tabs. - Add formatConsultationTitle(question) pure helper (pure.ts): prefixes 'IMAGE - ' and truncates the question to 80 chars (matching the conversation store's TITLE_MAX) with an ellipsis. - Add setConversationTitle dep to VisionHandoffDeps, wired in the extension to the conversation store's setConversationTitle. - Call it in consultVision BEFORE the turn starts so the title is correct from the first moment (the store keeps a non-'Untitled' title on first message append). Best-effort: a title-write failure logs a warning but does not break the consultation. - Tests: 4 pure + 2 service (title set + optional-dep graceful).
-rw-r--r--packages/vision-handoff/src/extension.ts4
-rw-r--r--packages/vision-handoff/src/pure.test.ts23
-rw-r--r--packages/vision-handoff/src/pure.ts26
-rw-r--r--packages/vision-handoff/src/service.test.ts46
-rw-r--r--packages/vision-handoff/src/service.ts25
5 files changed, 124 insertions, 0 deletions
diff --git a/packages/vision-handoff/src/extension.ts b/packages/vision-handoff/src/extension.ts
index faf4621..08fddca 100644
--- a/packages/vision-handoff/src/extension.ts
+++ b/packages/vision-handoff/src/extension.ts
@@ -183,6 +183,10 @@ export async function activate(host: HostAPI): Promise<void> {
const store = host.getService(conversationStoreHandle);
await store.setImageTranscription(conversationId, url, text);
},
+ setConversationTitle: async (conversationId: string, title: string) => {
+ const store = host.getService(conversationStoreHandle);
+ await store.setConversationTitle(conversationId, title);
+ },
logger: host.logger.child({ extensionId: "vision-handoff" }),
});
diff --git a/packages/vision-handoff/src/pure.test.ts b/packages/vision-handoff/src/pure.test.ts
index e3fcf58..21b1224 100644
--- a/packages/vision-handoff/src/pure.test.ts
+++ b/packages/vision-handoff/src/pure.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
collectTextFromStream,
findVisionModelName,
+ formatConsultationTitle,
formatConsultResult,
formatImagePlaceholder,
formatNoVisionPlaceholder,
@@ -155,3 +156,25 @@ describe("formatConsultResult", () => {
expect(result).not.toContain("spaced ");
});
});
+
+describe("formatConsultationTitle", () => {
+ it("prefixes the question with 'IMAGE - '", () => {
+ expect(formatConsultationTitle("What error is shown?")).toBe("IMAGE - What error is shown?");
+ });
+
+ it("truncates long questions to 80 chars with an ellipsis (matching the store's TITLE_MAX)", () => {
+ const long = "x".repeat(100);
+ const title = formatConsultationTitle(long);
+ expect(title).toBe(`IMAGE - ${"x".repeat(80)}…`);
+ expect(title.length).toBe("IMAGE - ".length + 80 + 1); // prefix + 80 + ellipsis
+ });
+
+ it("does not truncate questions at or under 80 chars", () => {
+ expect(formatConsultationTitle("x".repeat(80))).toBe(`IMAGE - ${"x".repeat(80)}`);
+ expect(formatConsultationTitle("x".repeat(79))).toBe(`IMAGE - ${"x".repeat(79)}`);
+ });
+
+ it("handles an empty question", () => {
+ expect(formatConsultationTitle("")).toBe("IMAGE - ");
+ });
+});
diff --git a/packages/vision-handoff/src/pure.ts b/packages/vision-handoff/src/pure.ts
index 5eeb1a3..af3476f 100644
--- a/packages/vision-handoff/src/pure.ts
+++ b/packages/vision-handoff/src/pure.ts
@@ -109,6 +109,32 @@ export function formatNoVisionPlaceholder(): string {
}
/**
+ * Maximum length of the consultation title body (matching the conversation
+ * store's `TITLE_MAX`). The question is truncated to this before the
+ * `"IMAGE - "` prefix is applied so the consultation tab's title stays in line
+ * with the store's own title-derivation limit.
+ */
+const CONSULTATION_TITLE_MAX = 80;
+
+/**
+ * Format the title for a vision consultation conversation tab. The title is
+ * `"IMAGE - "` prefixed to the (truncated) question so the tab is visually
+ * distinguishable from normal conversation tabs. The question is truncated to
+ * match the conversation store's title-derivation limit (`TITLE_MAX = 80`).
+ *
+ * Pure.
+ *
+ * @param question The question the model asked the vision model.
+ */
+export function formatConsultationTitle(question: string): string {
+ const body =
+ question.length > CONSULTATION_TITLE_MAX
+ ? `${question.slice(0, CONSULTATION_TITLE_MAX)}…`
+ : question;
+ return `IMAGE - ${body}`;
+}
+
+/**
* Format the `consult_vision` tool's result string. Returns the conversation ID
* (so the model / user can continue the vision consultation), the vision model's
* response, and a note that follow-up questions use the dispatch CLI (the model
diff --git a/packages/vision-handoff/src/service.test.ts b/packages/vision-handoff/src/service.test.ts
index 4667dbc..8c4117e 100644
--- a/packages/vision-handoff/src/service.test.ts
+++ b/packages/vision-handoff/src/service.test.ts
@@ -59,6 +59,7 @@ function makeDeps(overrides: Partial<VisionHandoffDeps> = {}): VisionHandoffDeps
: undefined,
),
readFileAsDataUrl: vi.fn(async (path: string) => `data:image/png;base64,FILE(${path})`),
+ setConversationTitle: vi.fn(async (_conversationId: string, _title: string) => {}),
...overrides,
};
}
@@ -257,6 +258,51 @@ describe("VisionHandoffService.consultVision", () => {
expect(call.images?.[0]?.url).toBe("data:image/png;base64,img1");
});
+ it("labels the consultation tab with an 'IMAGE - ' prefixed title", async () => {
+ const deps = makeDeps();
+ const { orchestrator } = makeOrchestratorDouble("The error is on line 12.");
+ deps.resolveOrchestrator = () => orchestrator;
+ const svc = createVisionHandoffService(deps);
+
+ // Register an image first (as prepareForProvider would).
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,img1" }] },
+ ];
+ await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { conversationId: "conv-1" });
+
+ const result = await svc.consultVision("What error is shown?", {
+ conversationId: "conv-1",
+ imageIds: [1],
+ });
+
+ expect("error" in result).toBe(false);
+ // The title was set with the IMAGE - prefix + the question.
+ expect(deps.setConversationTitle).toHaveBeenCalledOnce();
+ const [titleConvId, title] = (deps.setConversationTitle as ReturnType<typeof vi.fn>).mock
+ .calls[0];
+ expect(titleConvId).toBe((result as { conversationId: string }).conversationId);
+ expect(title).toBe("IMAGE - What error is shown?");
+ });
+
+ it("does not call setConversationTitle when it is not provided", async () => {
+ const deps = makeDeps({ setConversationTitle: undefined });
+ const { orchestrator } = makeOrchestratorDouble("response");
+ deps.resolveOrchestrator = () => orchestrator;
+ const svc = createVisionHandoffService(deps);
+
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,img1" }] },
+ ];
+ await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { conversationId: "conv-1" });
+
+ // Should NOT throw — setConversationTitle is optional.
+ const result = await svc.consultVision("What?", {
+ conversationId: "conv-1",
+ imageIds: [1],
+ });
+ expect("error" in result).toBe(false);
+ });
+
it("opens a consultation with a file path image", async () => {
const deps = makeDeps();
const { orchestrator } = makeOrchestratorDouble("It's a diagram.");
diff --git a/packages/vision-handoff/src/service.ts b/packages/vision-handoff/src/service.ts
index cc13d93..01245df 100644
--- a/packages/vision-handoff/src/service.ts
+++ b/packages/vision-handoff/src/service.ts
@@ -34,6 +34,7 @@ import { defineService, type ServiceHandle } from "@dispatch/kernel";
import {
collectTextFromStream,
findVisionModelName,
+ formatConsultationTitle,
formatConsultResult,
formatImagePlaceholder,
formatNoVisionPlaceholder,
@@ -146,6 +147,14 @@ export interface VisionHandoffDeps {
* Best-effort.
*/
readonly deleteConversationImages?: (conversationId: string) => Promise<void>;
+ /**
+ * Set the human-readable title of a conversation. Used to label vision
+ * consultation tabs with an `"IMAGE - "` prefix so they're visually
+ * distinguishable from normal conversation tabs. Backed by the conversation
+ * store's `setConversationTitle`. Optional — when absent, consultation tabs
+ * keep their default (question-derived) title.
+ */
+ readonly setConversationTitle?: (conversationId: string, title: string) => Promise<void>;
/** Generate a new conversation ID for a consultation. Defaults to crypto.randomUUID. */
readonly generateId?: () => string;
readonly logger?: Logger;
@@ -622,6 +631,22 @@ export function createVisionHandoffService(deps: VisionHandoffDeps): VisionHando
fromConversation: opts.conversationId,
});
+ // Label the consultation tab with an "IMAGE - " prefix so it's visually
+ // distinguishable from normal conversation tabs. Set BEFORE the turn
+ // starts so the tab shows the correct title from the first moment (the
+ // store keeps a non-"Untitled" title on first message append).
+ if (deps.setConversationTitle !== undefined) {
+ try {
+ await deps.setConversationTitle(consultationId, formatConsultationTitle(question));
+ } catch (err) {
+ // Best-effort — don't let a title-write failure break the consultation.
+ log?.warn("vision-handoff: failed to set consultation title", {
+ consultationId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
let responseText = "";
let errorMessage = "";
try {