Skip to content

fix(openclaw-plugin): capture user turn at assistant boundary#1516

Open
yeyitech wants to merge 1 commit intovolcengine:mainfrom
yeyitech:fix/issue-1248-afterturn-user-capture
Open

fix(openclaw-plugin): capture user turn at assistant boundary#1516
yeyitech wants to merge 1 commit intovolcengine:mainfrom
yeyitech:fix/issue-1248-afterturn-user-capture

Conversation

@yeyitech
Copy link
Copy Markdown
Contributor

Summary

  • backtrack the effective afterTurn capture window when prePromptMessageCount starts on the assistant side of the turn
  • keep user-only <relevant-memories> stripping while preserving assistant text
  • add regression coverage for assistant-boundary and grouped-user cases

Closes #1248

Testing

  • pnpm vitest run tests/ut/context-engine-afterTurn.test.ts

@github-actions
Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis ✅

1248 - PR Code Verified

Compliant requirements:

  • Fix afterTurn auto-capture to include user message
  • Capture both user and assistant messages from current turn

Requires further human verification:

  • Ensure VLM extractor receives full turn (user + assistant)
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🏅 Score: 80
🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Unused Counter Variable

The count variable in extractNewTurnMessages is declared but never incremented, so newCount is always 0. This affects diagnostics and runLocalPrecheck which rely on accurate new message count.

let count = 0;
const effectiveStartIndex = findEffectiveTurnStartIndex(messages, startIndex);

// First pass: collect toolUse inputs indexed by toolCallId/toolUseId
// Scan all messages (including after startIndex) to find toolUse before each toolResult
const toolUseInputs: Record<string, Record<string, unknown>> = {};
for (let i = 0; i < messages.length; i++) {
  const msg = messages[i] as Record<string, unknown>;
  if (!msg || typeof msg !== "object") continue;
  const role = msg.role as string;
  if (role === "assistant") {
    const content = msg.content;
    if (Array.isArray(content)) {
      for (const block of content) {
        const b = block as Record<string, unknown>;
        // Handle toolCall, toolUse, tool_call types
        if (b?.type === "toolCall" || b?.type === "toolUse" || b?.type === "tool_call") {
          const id = (b.id as string) || (b.toolUseId as string) || (b.toolCallId as string);
          // Try multiple field names for tool input: arguments, input, toolInput
          const input = b.arguments ?? b.input ?? b.toolInput;
          if (id && input && typeof input === "object") {
            toolUseInputs[id] = input as Record<string, unknown>;
          }
        }
      }
    }
  }
}

for (let i = effectiveStartIndex; i < messages.length; i++) {
  const msg = messages[i] as Record<string, unknown>;
  if (!msg || typeof msg !== "object") continue;

  const role = msg.role as string;
  if (!role || role === "system") continue;

  count++;

  // toolResult -> type: "tool"
  if (role === "toolResult") {
    const toolName = typeof msg.toolName === "string" ? msg.toolName : "tool";
    const output = formatToolResultContent(msg.content) || "";
    // Try multiple field names for tool call ID
    const toolCallId = (msg.toolCallId as string) || (msg.toolUseId as string) || (msg.tool_call_id as string);
    const toolInput = toolCallId && toolUseInputs[toolCallId]
      ? toolUseInputs[toolCallId]
      : (typeof msg.toolInput === "object" && msg.toolInput !== null
        ? msg.toolInput as Record<string, unknown>
        : undefined);
    if (output) {
      pushExtractedPart(result, "user", {
        type: "tool",
        toolCallId: toolCallId || undefined,
        toolName,
        toolInput,
        toolOutput: `[${toolName} result]: ${output}`,
        toolStatus: "completed",
      });
    }
    continue;
  }

  // user/assistant -> type: "text"
  const text = extractSingleMessageText(msg);

  if (text) {
    const cleanedText =
      role === "assistant"
        ? (HEARTBEAT_RE.test(text) ? "" : text.trim())
        : sanitizeUserTextForCapture(text);
    if (cleanedText) {
      const ovRole: "user" | "assistant" = role === "assistant" ? "assistant" : "user";
      pushExtractedPart(result, ovRole, {
        type: "text",
        text: cleanedText,
      });
    }
  }
}

return { messages: result, newCount: count, effectiveStartIndex };
Unintended Tool Output Change

Tool result output is now wrapped in [${toolName} result]: ${output} without mention in PR summary. This may break existing extraction logic that expects raw tool output.

pushExtractedPart(result, "user", {
  type: "tool",
  toolCallId: toolCallId || undefined,
  toolName,
  toolInput,
  toolOutput: `[${toolName} result]: ${output}`,
  toolStatus: "completed",
});

@github-actions
Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Stop backward scan at previous turn

Break the backward loop when encountering a non-user, non-system role (e.g.,
"assistant", "toolResult") to prevent capturing user messages from earlier turns.
This ensures we only look back within the current turn boundary.

examples/openclaw-plugin/text-utils.ts [524-545]

   for (let i = firstRelevantIndex - 1; i >= 0; i -= 1) {
     const msg = messages[i] as Record<string, unknown>;
     const role = typeof msg?.role === "string" ? msg.role : "";
     if (!role || role === "system") {
       continue;
     }
     if (role === "user") {
       let earliestUserIndex = i;
       for (let j = i - 1; j >= 0; j -= 1) {
         const prev = messages[j] as Record<string, unknown>;
         const prevRole = typeof prev?.role === "string" ? prev.role : "";
         if (!prevRole || prevRole === "system") {
           continue;
         }
         if (prevRole !== "user") {
           break;
         }
         earliestUserIndex = j;
       }
       return earliestUserIndex;
     }
+    // Stop at the first non-user, non-system message (previous turn boundary)
+    break;
   }
Suggestion importance[1-10]: 7

__

Why: Adding a break when encountering a non-user, non-system role prevents capturing user messages from earlier turns, ensuring correct turn boundary detection.

Medium

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

[Bug]: afterTurn auto-capture only captures assistant message, user message is lost, leading to 0 memories extracted

1 participant