diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 20:40:19 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 20:40:19 +0900 |
| commit | b24ed99e89bc657e8c98c7cef8608e0c0b7594da (patch) | |
| tree | 668051260825dd49fcdca33062be8d54d60d7bd6 | |
| parent | 2e741d1c1ac309327aff4fed0e248bc5baa342d4 (diff) | |
| download | dispatch-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.ts | 4 | ||||
| -rw-r--r-- | packages/vision-handoff/src/pure.test.ts | 23 | ||||
| -rw-r--r-- | packages/vision-handoff/src/pure.ts | 26 | ||||
| -rw-r--r-- | packages/vision-handoff/src/service.test.ts | 46 | ||||
| -rw-r--r-- | packages/vision-handoff/src/service.ts | 25 |
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 { |
