fix(sdk-java): normalize lone surrogates before the digest and the insert, and keep protocol messages' field paths (#13803)

Two digest-honesty findings from the real stack and the reviews:

- The canonical encoder escapes an unpaired surrogate in the admission
  digest, while no store column keeps the character; re-encoding the
  read-back input then never matches the payloadDigest, and the Turn
  retries forever (payloadDigest does not match promptContent). Input
  admission now trades unpaired surrogates for U+FFFD before the payload
  digest, the replay digest and the insert, on the API path and on the
  child-creation path, so all three describe one representation.
- Four DaemonProtocolException messages that named only the context lost
  their field path in this PR's digest work; they name the field again,
  pinned by a witness that reads all four texts.

Witnesses: the child-creation and user-text admissions normalize before
the captured digest and insert, with valid pairs and non-strings left
untouched; the helper-rejection tests pass unchanged.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Shaojin Wen 2026-10-11 23:54:11 +08:00
parent b8f921f105
commit 52958aade4
4 changed files with 167 additions and 9 deletions

View file

@ -53,6 +53,7 @@ import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -307,6 +308,7 @@ public class ManagedAgentService {
private CommandAdmission insertChildSession(String tenantId,
String parentSessionId, String childRunId, String title,
List<Map<String, Object>> input, boolean isolated) {
List<Map<String, Object>> normalizedInput = normalizeInput(input);
SessionRecord parent = store.requireSession(tenantId, parentSessionId);
if (parent.workspace() == null || !"ACTIVE".equals(parent.status())) {
throw new ApiException(HttpStatus.CONFLICT,
@ -314,7 +316,7 @@ public class ManagedAgentService {
"The parent Session cannot admit a child.");
}
String creationKey = childCreationKey(parentSessionId, childRunId);
if (!input.isEmpty() && !harness.isWorkspaceFilesAvailable()) {
if (!normalizedInput.isEmpty() && !harness.isWorkspaceFilesAvailable()) {
throw new ApiException(HttpStatus.CONFLICT,
"workspace_unavailable",
"Hosted Workspace execution is not available.");
@ -340,7 +342,7 @@ public class ManagedAgentService {
Map<String, Object> semantic = new LinkedHashMap<>();
semantic.put("child", childRunId);
semantic.put("title", title);
semantic.put("input", input);
semantic.put("input", normalizedInput);
if (childCwd != null) {
semantic.put("workspace", childCwd);
}
@ -350,11 +352,13 @@ public class ManagedAgentService {
admission = childCwd == null
? store.insertChildSessionCommand(tenantId,
parentSessionId, creationKey, requestDigest, title,
input, SubmitHarnessTurn.computePayloadDigest(input),
normalizedInput,
SubmitHarnessTurn.computePayloadDigest(normalizedInput),
lineage)
: store.insertChildSessionCommand(tenantId,
parentSessionId, creationKey, requestDigest, title,
input, SubmitHarnessTurn.computePayloadDigest(input),
normalizedInput,
SubmitHarnessTurn.computePayloadDigest(normalizedInput),
lineage, childCwd);
} catch (DuplicateKeyException error) {
admission = store.replayChildSessionCommand(tenantId,
@ -1353,11 +1357,72 @@ public class ManagedAgentService {
"unsupported_input",
"Phase 1 accepts non-empty text input only.");
}
result.add(Map.of("type", "text", "text", block.text()));
result.add(Map.of("type", "text", "text",
replaceLoneSurrogates(block.text())));
}
return List.copyOf(result);
}
/** The input the digest and the column both describe: an unpaired
* surrogate, which no store column keeps, trades for U+FFFD at
* admission — before the payload digest, the replay digest and the
* insert — so all three name one representation. Well-formed
* surrogate pairs and every non-string value pass through. */
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> normalizeInput(
List<Map<String, Object>> input) {
List<Map<String, Object>> result = new ArrayList<>(input.size());
for (Map<String, Object> block : input)
result.add((Map<String, Object>) normalizeValue(block));
return List.copyOf(result);
}
private static Object normalizeValue(Object value) {
if (value instanceof String text) {
return replaceLoneSurrogates(text);
}
if (value instanceof Map<?, ?> map) {
Map<String, Object> copy = new LinkedHashMap<>(map.size());
for (Map.Entry<?, ?> entry : map.entrySet())
copy.put(replaceLoneSurrogates((String) entry.getKey()),
normalizeValue(entry.getValue()));
return copy;
}
if (value instanceof List<?> list) {
List<Object> copy = new ArrayList<>(list.size());
for (Object item : list) copy.add(normalizeValue(item));
return Collections.unmodifiableList(copy);
}
return value;
}
static String replaceLoneSurrogates(String text) {
StringBuilder out = null;
int written = 0;
for (int i = 0; i < text.length(); i++) {
char current = text.charAt(i);
if (Character.isHighSurrogate(current)) {
if (i + 1 < text.length()
&& Character.isLowSurrogate(text.charAt(i + 1))) {
i++; // a well-formed pair passes through unchanged
continue;
}
} else if (!Character.isLowSurrogate(current)) {
continue;
}
if (out == null) {
out = new StringBuilder(text.length());
}
out.append(text, written, i).append('<27>');
written = i + 1;
}
if (out == null) {
return text;
}
out.append(text, written, text.length());
return out.toString();
}
private static String metadataTitle(String explicitTitle,
Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {

View file

@ -9,6 +9,8 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
import com.alibaba.qwen.code.managedagent.api.ApiModels;
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
import com.alibaba.qwen.code.managedagent.store.StoreModels;
@ -114,4 +116,65 @@ class ManagedAgentServiceWorkflowChildTest {
.isEqualTo(ManagedAgentService.childCreationKey(PARENT, RUN));
assertThat(replayed.getValue()).isEqualTo(attempted.getValue());
}
@Test
void loneSurrogatesTradeForReplacementBeforeTheDigestAndTheInsert() {
String raw = "x surrogate " + (char) 0xD800 + " here";
assertThat(service.createWorkflowChildSession(TENANT, PARENT, RUN,
"workflow/audit", 1L, DIGEST, raw, Map.of("note", raw))
.sessionId()).isEqualTo("workflow-child");
ArgumentCaptor<List<Map<String, Object>>> input =
ArgumentCaptor.forClass(List.class);
ArgumentCaptor<String> digest = ArgumentCaptor.forClass(String.class);
verify(store).insertChildSessionCommand(eq(TENANT), eq(PARENT),
anyString(), anyString(), anyString(), input.capture(),
digest.capture(), any(StoreModels.SessionLineage.class));
Map<String, Object> block = input.getValue().get(0);
String normalized = raw.replace((char) 0xD800, '<27>');
assertThat(block.get("script")).isEqualTo(normalized);
assertThat(block.get("args")).isEqualTo(Map.of("note", normalized));
// The digest names the representation the column keeps: recomputing
// it from the persisted input round-trips instead of wedging the Turn.
assertThat(digest.getValue()).isEqualTo(
SubmitHarnessTurn.computePayloadDigest(input.getValue()));
}
@Test
void userTextWithALoneSurrogateIsNormalizedTheSameWay() {
when(harness.isAvailable()).thenReturn(true);
when(store.insertSessionCommand(anyString(), anyString(), anyString(),
anyString(), anyString(), any(), any(), any(), anyList(),
anyString())).thenReturn(new StoreModels.Admission(
"session-new", null, false, true));
String raw = "x surrogate " + (char) 0xD800 + " here";
service.createSession(TENANT, "actor", "key-1", null, null, null,
null, List.of(new ApiModels.InputBlock("text", raw)));
ArgumentCaptor<List<Map<String, Object>>> input =
ArgumentCaptor.forClass(List.class);
ArgumentCaptor<String> digest = ArgumentCaptor.forClass(String.class);
verify(store).insertSessionCommand(anyString(), anyString(),
anyString(), anyString(), anyString(), any(), any(), any(),
input.capture(), digest.capture());
List<Map<String, Object>> inserted = input.getValue();
String normalized = raw.replace((char) 0xD800, '<27>');
assertThat(inserted).containsExactly(
Map.of("type", "text", "text", normalized));
assertThat(digest.getValue()).isEqualTo(
SubmitHarnessTurn.computePayloadDigest(inserted));
}
@Test
void replaceLoneSurrogatesTradesOnlyTheUnpaired() {
assertThat(ManagedAgentService.replaceLoneSurrogates(
"x" + (char) 0xD800)).isEqualTo("x" + '<27>');
assertThat(ManagedAgentService.replaceLoneSurrogates(
"x" + (char) 0xDFFF + "y")).isEqualTo("x" + '<27>' + "y");
assertThat(ManagedAgentService.replaceLoneSurrogates("café 中 😀"))
.isEqualTo("café 中 😀");
assertThat(ManagedAgentService.replaceLoneSurrogates("plain"))
.isEqualTo("plain");
}
}

View file

@ -370,7 +370,8 @@ final class JsonSupport {
Object value = object.get(field);
long result = exactLong(value, context + "." + field);
if (result < 0) {
throw new DaemonProtocolException(context + " must be non-negative");
throw new DaemonProtocolException(context + "." + field
+ " must be non-negative");
}
return result;
}
@ -383,7 +384,8 @@ final class JsonSupport {
}
long result = exactLong(value, context + "." + field);
if (result <= 0) {
throw new DaemonProtocolException(context + " must be positive");
throw new DaemonProtocolException(context + "." + field
+ " must be positive");
}
return result;
}
@ -392,7 +394,7 @@ final class JsonSupport {
String context) {
long value = exactLong(object.get(field), context + "." + field);
if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
throw new DaemonProtocolException(context
throw new DaemonProtocolException(context + "." + field
+ " is outside the integer range");
}
return (int) value;
@ -402,7 +404,8 @@ final class JsonSupport {
String field, String context) {
Map<String, Object> value = optionalObject(object, field);
if (value == null) {
throw new DaemonProtocolException(field + " must be an object");
throw new DaemonProtocolException(context + "." + field
+ " must be an object");
}
return value;
}

View file

@ -168,5 +168,32 @@ class JsonSupportTest {
assertEquals("{\"a\":1,\"b\":[1,2,0.0001],\"c\":{\"x\":true,\"y\":{}},\"d\":null}",
JsonSupport.encode(value));
}
@Test
void protocolHelperMessagesKeepTheirFieldPath() {
Map<String, Object> block = new LinkedHashMap<>();
block.put("count", -1L);
assertEquals("ctx.count must be non-negative",
assertThrows(DaemonProtocolException.class,
() -> JsonSupport.requiredNonNegativeLong(block,
"count", "ctx")).getMessage());
block.put("count", 0L);
assertEquals("ctx.count must be positive",
assertThrows(DaemonProtocolException.class,
() -> JsonSupport.optionalPositiveLong(block, "count",
"ctx")).getMessage());
block.put("count", (long) Integer.MAX_VALUE + 1L);
assertEquals("ctx.count is outside the integer range",
assertThrows(DaemonProtocolException.class,
() -> JsonSupport.requiredInt(block, "count", "ctx"))
.getMessage());
assertEquals("ctx.inner must be an object",
assertThrows(DaemonProtocolException.class,
() -> JsonSupport.requiredObject(Map.of(), "inner",
"ctx")).getMessage());
}
}