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
8 changes: 8 additions & 0 deletions ds4.c
Original file line number Diff line number Diff line change
Expand Up @@ -64144,6 +64144,14 @@ int ds4_chat_append_multimodal_message(
}
const bool tool = !strcmp(role, "tool") || !strcmp(role, "function");
const bool user = !strcmp(role, "user");
/* A message without images is an ordinary chat message. Keep the native
* per-family rendering so a text-only tool observation never depends on
* vision support: DeepSeek text models render <tool_result> under the
* user role, not the observation tags used for image messages below. */
if (image_count == 0 && (tool || user)) {
ds4_chat_append_message(e, tokens, role, text_parts[0]);
return 1;
}
if ((DS4_MODEL_FAMILY != DS4_MODEL_FAMILY_GLM_DSA &&
e->vision_kind != DS4_VISION_DEEPSEEK4) || (!tool && !user)) {
if (error && error_cap)
Expand Down
33 changes: 33 additions & 0 deletions tests/ds4_test.c
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,38 @@ static void test_close_engine(bool quality) {
*slot = NULL;
}

/* A tool observation without images must render exactly like the plain chat
* path: the shortcut in ds4_chat_append_multimodal_message never inspects
* model family or vision_kind, so the guarantee holds regardless of build
* configuration, but this test only exercises it on the default (no vision
* loaded) engine. The agent sends every tool result through the multimodal
* entry point, so a text-only rejection breaks all tool calls on DeepSeek
* text models. */
static void test_chat_append_multimodal_text_only(void) {
ds4_engine *engine = test_get_engine(false);
if (!engine) return;

static const char *const roles[] = {"tool", "user"};
const char *text = ".:\nd 96 src/\n- 1204 package.json\n";
const char *const parts[] = {text};
for (size_t r = 0; r < sizeof(roles) / sizeof(roles[0]); r++) {
ds4_tokens plain = {0};
ds4_tokens multimodal = {0};
char err[160] = {0};

ds4_chat_append_message(engine, &plain, roles[r], text);
int ok = ds4_chat_append_multimodal_message(engine, &multimodal,
roles[r], parts, NULL, 0,
NULL, err, sizeof(err));
TEST_ASSERT(ok == 1);
TEST_ASSERT(plain.len > 0 && multimodal.len == plain.len &&
memcmp(multimodal.v, plain.v,
(size_t)plain.len * sizeof(plain.v[0])) == 0);
ds4_tokens_free(&plain);
ds4_tokens_free(&multimodal);
}
}

static void test_session_snapshot_roundtrip(void) {
ds4_engine *engine = test_get_engine(false);
if (!engine) return;
Expand Down Expand Up @@ -6796,6 +6828,7 @@ typedef struct {
static const ds4_test_entry test_entries[] = {
#ifndef DS4_NO_GPU
{"--session-snapshot", "session-snapshot", "session snapshot and recurrent-state round trip", test_session_snapshot_roundtrip},
{"--chat-multimodal-text-only", "chat-multimodal-text-only", "text-only multimodal message renders like the plain chat path", test_chat_append_multimodal_text_only},
{"--long-context", "long-context", "long-context story fact-recall regression", test_long_story_fact_recall},
{"--tool-call-quality", "tool-call-quality", "model tool call and post-result stop regression", test_tool_call_quality},
{"--think-tool-recovery", "think-tool-recovery", "recover a complete tool call emitted inside unclosed reasoning", test_think_tool_recovery},
Expand Down