Skip to content
Open
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
4 changes: 2 additions & 2 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2956,7 +2956,7 @@ fn effective_prompt_capabilities(
capabilities: &sacp::schema::PromptCapabilities,
) -> PromptCapabilitiesInfo {
PromptCapabilitiesInfo {
image: capabilities.image || agent_type == AgentType::Grok,
image: capabilities.image || agent_type.uses_grok_image_sidecar(),
audio: capabilities.audio,
embedded_context: capabilities.embedded_context,
}
Expand Down Expand Up @@ -7298,7 +7298,7 @@ async fn run_conversation_loop<'a>(
// sidecar runs), the rest back as resource blobs. The last
// point that sees the blocks, so every producer (composer,
// queued draft, work task, delegation) is covered at once.
let blocks = if agent_type == AgentType::Grok {
let blocks = if agent_type.uses_grok_image_sidecar() {
normalize_grok_image_blocks(blocks)
} else {
blocks
Expand Down
32 changes: 32 additions & 0 deletions src-tauri/src/models/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ impl AgentType {
matches!(self, AgentType::Custom(_))
}

/// Grok's initialize still advertises `image: false`. Native Image
/// blocks run its describe sidecar; a Resource blob does not. Built-in
/// Grok and extra isolated Grok slots (`custom:grok-2`, …) share that
/// sidecar. Other custom agents are left on the advertised bit.
pub fn uses_grok_image_sidecar(&self) -> bool {
match self {
AgentType::Grok => true,
AgentType::Custom(id) => {
let id = id.to_ascii_lowercase();
id == "grok" || id.starts_with("grok-") || id.starts_with("grok_")
}
_ => false,
}
}

/// The custom agent's registry id, or `None` for a built-in.
pub fn custom_id(&self) -> Option<&'static str> {
match self {
Expand Down Expand Up @@ -269,6 +284,23 @@ mod tests {
}
}

#[test]
fn grok_image_sidecar_covers_builtin_and_extra_slots() {
assert!(AgentType::Grok.uses_grok_image_sidecar());
assert!(
AgentType::custom("grok-2")
.unwrap()
.uses_grok_image_sidecar()
);
assert!(
AgentType::custom("grok_work")
.unwrap()
.uses_grok_image_sidecar()
);
assert!(!AgentType::ClaudeCode.uses_grok_image_sidecar());
assert!(!AgentType::custom("goose").unwrap().uses_grok_image_sidecar());
}

#[test]
fn unknown_wire_forms_are_rejected() {
assert_eq!(AgentType::from_wire("nope"), None);
Expand Down
12 changes: 9 additions & 3 deletions src/components/chat/composer/use-composer-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type { PromptCapabilitiesInfo, PromptInputBlock } from "@/lib/types"
import type { Editor } from "@tiptap/core"

import {
agentUsesGrokImageSidecar,
imageAttachmentToPromptBlock,
type ImageInputAttachment,
type InputAttachment,
Expand Down Expand Up @@ -106,6 +107,8 @@ export interface ComposerAttachmentsOptions {
disabled?: boolean
/** Decides whether images may be attached at all, and how they are encoded. */
promptCapabilities: Pick<PromptCapabilitiesInfo, "image" | "embedded_context">
/** Used to force Grok Image blocks even when initialize still says image:false. */
agentType?: string | null
/** Groups uploads with the session/tab that owns them (quota + cleanup). */
attachmentTabId?: string | null
/** Start directory for the native picker and the server file browser. */
Expand Down Expand Up @@ -178,6 +181,7 @@ export function useComposerAttachments({
containerRef,
disabled = false,
promptCapabilities,
agentType = null,
attachmentTabId = null,
defaultPath = null,
logLabel = "Composer",
Expand All @@ -203,7 +207,9 @@ export function useComposerAttachments({
// (`image`) or as an embedded resource blob (`embedded_context`, what an
// agent that advertises `image: false` but `embeddedContext: true` takes).
const canAttachImages =
promptCapabilities.image || promptCapabilities.embedded_context
promptCapabilities.image ||
promptCapabilities.embedded_context ||
agentUsesGrokImageSidecar(agentType)

const [attachments, setAttachments] = useState<InputAttachment[]>([])
const embeddedPayloadsRef = useRef<Map<string, PromptInputBlock>>(new Map())
Expand Down Expand Up @@ -1335,9 +1341,9 @@ export function useComposerAttachments({
const imagePromptBlocks = useCallback(
(): PromptInputBlock[] =>
imageAttachments.map((attachment) =>
imageAttachmentToPromptBlock(attachment, promptCapabilities)
imageAttachmentToPromptBlock(attachment, promptCapabilities, agentType)
),
[imageAttachments, promptCapabilities]
[agentType, imageAttachments, promptCapabilities]
)

return {
Expand Down
18 changes: 14 additions & 4 deletions src/components/chat/message-input-attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,20 @@ describe("imageAttachmentToPromptBlock", () => {
expect(block).toMatchObject({ type: "image", uri: null })
})

it("emits an embedded resource blob when the agent only accepts embedded context (Grok)", () => {
// Grok advertises image:false, embeddedContext:true — the image rides along
// as an embedded resource blob (same bytes, image mime) rather than an
// unsupported native image block.
it("still emits a native image block for Grok when initialize lies (image:false)", () => {
const block = imageAttachmentToPromptBlock(
image(),
{ image: false, embedded_context: true },
"grok"
)
expect(block).toMatchObject({
type: "image",
mime_type: "image/png",
data: "QkFTRTY0",
})
})

it("emits an embedded resource blob when a non-Grok agent only accepts embedded context", () => {
const block = imageAttachmentToPromptBlock(image(), {
image: false,
embedded_context: true,
Expand Down
13 changes: 11 additions & 2 deletions src/components/chat/message-input-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,20 @@ export type InputAttachment = ResourceInputAttachment | ImageInputAttachment
* `clipboard://` identifier derived from its name + id, so the emitted block is
* reproducible without a random source (and unit-testable).
*/
/** Built-in `grok` plus extra isolated slots (`custom:grok-2`, …). */
export function agentUsesGrokImageSidecar(agentType?: string | null): boolean {
if (!agentType) return false
const raw = agentType.toLowerCase()
const id = raw.startsWith("custom:") ? raw.slice("custom:".length) : raw
return id === "grok" || id.startsWith("grok-") || id.startsWith("grok_")
}

export function imageAttachmentToPromptBlock(
attachment: ImageInputAttachment,
caps: Pick<PromptCapabilitiesInfo, "image" | "embedded_context">
caps: Pick<PromptCapabilitiesInfo, "image" | "embedded_context">,
agentType?: string | null
): PromptInputBlock {
if (caps.image) {
if (caps.image || agentUsesGrokImageSidecar(agentType)) {
return {
type: "image",
data: attachment.data,
Expand Down
1 change: 1 addition & 0 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ export function MessageInput({
containerRef,
disabled,
promptCapabilities,
agentType,
attachmentTabId,
defaultPath,
logLabel: "MessageInput",
Expand Down
1 change: 1 addition & 0 deletions src/components/tasks/task-message-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export function TaskMessageComposer({
editorRef,
containerRef,
promptCapabilities,
agentType,
defaultPath: folderPath,
logLabel: "TaskComposer",
})
Expand Down
Loading