Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/render-cross-client-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@smooai/smooth': patch
---

th-1fca98: render images another client attached, and bump the engine to persist them.

A photo sent from the iOS app showed only as text in the desktop app's view of the same conversation. The engine now persists a user turn's images as `image` content items (smooth-operator #564), and `smooth-web` renders them from history: history parsing moved to a pure, unit-tested `history.ts` that turns persisted `image` items into renderable attachments (the composer's live-send path already showed them).

Engine bump: `smooth-operator-server`/`svc` git rev `b6c6b84` → `9b30ed7b` (includes the image-persistence fix), which moves core `1.7.10` → `1.10.0`. That core carries three additive `AgentEvent::Completed` fields (spend taint flags + response id, th-126fe6) and makes `Session.agent_id` optional; the few construction sites were updated to the serde defaults older output already produced (no behavior change).
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,9 @@ prost-types = "0.13"
# released rather than pinning a side branch — the branch takes the core version
# hostage with it. The `th-daemon-memory-seam` and `multimodal-content-model`
# branches no longer anchor anything.
smooth-operator = { version = "1.7.10", package = "smooai-smooth-operator-core" }
smooth-operator-server = { git = "https://github.com/SmooAI/smooth-operator.git", rev = "b6c6b84", package = "smooai-smooth-operator-server", default-features = false }
smooth-operator-svc = { git = "https://github.com/SmooAI/smooth-operator.git", rev = "b6c6b84", package = "smooai-smooth-operator", default-features = false }
smooth-operator = { version = "=1.10.0", package = "smooai-smooth-operator-core" }
smooth-operator-server = { git = "https://github.com/SmooAI/smooth-operator.git", rev = "9b30ed7b", package = "smooai-smooth-operator-server", default-features = false }
smooth-operator-svc = { git = "https://github.com/SmooAI/smooth-operator.git", rev = "9b30ed7b", package = "smooai-smooth-operator", default-features = false }
# smooth-owned coding-harness extensions to the generic engine (re-homed from
# the engine when it went generic at 0.14.0). See crates/smooth-cast.
smooth-cast = { version = "0.41.4", path = "crates/smooth-cast", package = "smooai-smooth-cast" }
Expand Down
7 changes: 7 additions & 0 deletions crates/smooth-cast/src/coding_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,13 @@ pub async fn run_coding_workflow(cfg: CodingWorkflowConfig) -> anyhow::Result<f6
prompt_tokens: total_prompt_tokens,
completion_tokens: total_completion_tokens,
cached_tokens: total_cached_tokens,
// New in core 1.10 (th-126fe6): spend-attribution taint flags + response
// id. The coding workflow doesn't track per-call estimation, so these take
// the serde defaults older runner output already produced — no behavior
// change. Thread real taint through if this event ever feeds a billed surface.
cost_estimated: false,
usage_estimated: false,
response_id: None,
});

Ok(total_cost_usd)
Expand Down
6 changes: 6 additions & 0 deletions crates/smooth-code/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1787,6 +1787,12 @@ async fn run_agent_streaming(
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
cached_tokens: 0,
// New in core 1.10 (th-126fe6): spend taint flags + response id.
// This task-usage summary carries no estimation signal, so take
// the serde defaults older output already produced — no change.
cost_estimated: false,
usage_estimated: false,
response_id: None,
});
break;
}
Expand Down
2 changes: 1 addition & 1 deletion crates/smooth-daemon/src/operator_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ mod tests {
session_id: id.into(),
conversation_id: conv.into(),
organization_id: "org-1".into(),
agent_id: "a".into(),
agent_id: Some("a".into()),
agent_name: "S".into(),
user_participant_id: "u".into(),
agent_participant_id: "ag".into(),
Expand Down
41 changes: 41 additions & 0 deletions crates/smooth-web/web/src/history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { historyImages, type HistoryMessage } from './history.ts';

// th-1fca98: a history user turn now carries persisted `image` content items so
// this client re-renders images another client attached. historyImages turns
// those into renderable attachments (App.tsx renders mime.startsWith('image/')).

test('historyImages extracts image items with the mime from a data: URL', () => {
const m: HistoryMessage = {
direction: 'inbound',
content: {
items: [
{ type: 'text', text: 'look at this' },
{ type: 'image', url: 'data:image/png;base64,AAAA' },
],
},
};
const imgs = historyImages(m);
assert.equal(imgs.length, 1);
assert.equal(imgs[0].mime, 'image/png');
assert.equal(imgs[0].dataUrl, 'data:image/png;base64,AAAA');
assert.ok(imgs[0].mime.startsWith('image/')); // the App.tsx render gate
});

test('historyImages defaults https image URLs to image/* so they still render', () => {
const m: HistoryMessage = {
content: { items: [{ type: 'image', url: 'https://cdn/x.png' }] },
};
const imgs = historyImages(m);
assert.equal(imgs.length, 1);
assert.equal(imgs[0].mime, 'image/*');
assert.ok(imgs[0].mime.startsWith('image/'));
});

test('historyImages is empty for text-only turns and old daemons (no image items)', () => {
assert.deepEqual(historyImages({ content: { items: [{ type: 'text', text: 'hi' }] } }), []);
assert.deepEqual(historyImages({ content: 'plain string' }), []);
assert.deepEqual(historyImages({ text: 'alias' }), []);
});
61 changes: 61 additions & 0 deletions crates/smooth-web/web/src/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Pure parsing of server conversation history into the shapes the UI renders.
// Kept in its own module (no runtime imports) so it's unit-testable under node
// — operator.ts uses Vite-only extensionless imports and can't be loaded by the
// test runner. th-1fca98.

import type { Attachment } from './operator';

/** A raw history message from `get_conversation_messages`. The server returns
* the stored domain `Message`: `direction` ('inbound' = user, 'outbound' = agent)
* + `content: { items: [{ type:'text', text } | { type:'image', url }] }`.
* Fallbacks (`role`, string content, `text`) tolerate other shapes. Past tool
* calls are NOT reconstructed; only the text + images of each turn are rendered. */
export interface HistoryContentItem {
type?: string;
text?: string;
/** Set on `type:'image'` items — a `data:`/`https` image URL the engine now
* persists on the user turn (th-1fca98), so history re-renders images another
* client attached. */
url?: string;
}

export interface HistoryMessage {
direction?: string;
content?: { items?: HistoryContentItem[] } | string;
role?: string;
text?: string;
/** ISO-8601 send time — used to render history oldest-first (the server
* returns newest-first). */
createdAt?: string;
}

/** Flatten a history message's content to text (real shape = content.items[] of
* text parts; tolerate a bare string or a `text` alias). */
export function historyText(m: HistoryMessage): string {
const c = m.content;
if (c && typeof c === 'object' && Array.isArray(c.items)) {
return c.items
.filter((i) => (i.type ?? 'text') === 'text')
.map((i) => i.text ?? '')
.join('');
}
if (typeof c === 'string') return c;
return m.text ?? '';
}

/** The images persisted on a history message (`content.items[]` of `type:'image'`),
* as renderable attachments. Empty for text-only turns and old daemons that never
* stored images. mime comes from a `data:` URL prefix; an `https` URL defaults to
* `image/*` (these items are always images). th-1fca98. */
export function historyImages(m: HistoryMessage): Attachment[] {
const c = m.content;
if (!c || typeof c !== 'object' || !Array.isArray(c.items)) return [];
const out: Attachment[] = [];
for (const i of c.items) {
if (i.type === 'image' && i.url) {
const mime = i.url.match(/^data:([^;,]+)/)?.[1] ?? 'image/*';
out.push({ name: 'image', mime, dataUrl: i.url });
}
}
return out;
}
36 changes: 3 additions & 33 deletions crates/smooth-web/web/src/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import { historyImages, historyText, type HistoryMessage } from './history';
import { DEFAULT_MODE_ID, modeById, type ModelCosts, type SmoothMode } from './modes';
import { normalizeTodos, type TodoItem } from './todos';

Expand Down Expand Up @@ -90,39 +91,6 @@ export interface ConversationSummary {
messageCount: number;
}

/** A raw history message from `get_conversation_messages`. The server returns
* the stored domain `Message`: `direction` ('inbound' = user, 'outbound' = agent)
* + `content: { items: [{ type:'text', text }] }`. Fallbacks (`role`, string
* content, `text`) tolerate other shapes. Past tool calls are NOT reconstructed;
* only the text of each turn is rendered. */
interface HistoryContentItem {
type?: string;
text?: string;
}
interface HistoryMessage {
direction?: string;
content?: { items?: HistoryContentItem[] } | string;
role?: string;
text?: string;
/** ISO-8601 send time — used to render history oldest-first (the server
* returns newest-first). */
createdAt?: string;
}

/** Flatten a history message's content to text (real shape = content.items[] of
* text parts; tolerate a bare string or a `text` alias). */
function historyText(m: HistoryMessage): string {
const c = m.content;
if (c && typeof c === 'object' && Array.isArray(c.items)) {
return c.items
.filter((i) => (i.type ?? 'text') === 'text')
.map((i) => i.text ?? '')
.join('');
}
if (typeof c === 'string') return c;
return m.text ?? '';
}

/** Render server history into our ChatMessage model: `inbound` = user turn,
* `outbound`/other = assistant (or system). Assistant text becomes one text block.
* The server returns messages newest-first, so we sort ascending by `createdAt`
Expand All @@ -133,6 +101,7 @@ function renderHistory(raw: HistoryMessage[]): ChatMessage[] {
const content = historyText(m);
const isUser = m.direction === 'inbound' || m.role === 'user';
const role: ChatMessage['role'] = isUser ? 'user' : m.role === 'system' ? 'system' : 'assistant';
const attachments = isUser ? historyImages(m) : [];
return {
id: nextId('h'),
role,
Expand All @@ -141,6 +110,7 @@ function renderHistory(raw: HistoryMessage[]): ChatMessage[] {
tools: [],
blocks: role === 'assistant' ? [{ kind: 'text', text: content }] : [],
streaming: false,
attachments: attachments.length ? attachments : undefined,
};
});
}
Expand Down
Loading