mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
fix(grok-local): restore turn boundaries in streaming reasoning text (#6142)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The `grok-local` adapter streams reasoning text to the issue "Working..." panel as the grok CLI runs > - The `grok` CLI's `--output-format streaming-json` mode silently drops the `\n` separator between reasoning turns around tool calls > - Consecutive `thought` chunks (e.g. `` "`" `` followed by `"The"`) arrive with no intervening whitespace event, so the UI's `delta: true` concatenator merged them into run-on text like `"…planningGreat, now I have the issue descriptionThe only co"` > - This PR adds a small turn-boundary helper that detects sentence boundaries in the upstream `thought` stream and inserts a single `\n` only when the previous chunk ended with sentence punctuation (or a balanced closing backtick) AND the next chunk begins a new uppercase sentence > - The benefit is readable streaming reasoning in the UI without changing how completed messages are stored ## What Changed - Added `packages/adapters/grok-local/src/shared/turn-boundary.ts` with per-stream state (last chunk + backtick parity) and a `restoreTurnBoundary()` helper that inserts `\n` only between balanced, sentence-terminated `thought` chunks - Wired the helper into `parseGrokJsonl` (server) and added a new `createGrokStdoutParser` factory used by `grokLocalUIAdapter` for the live "Working..." panel - Added focused tests in `shared/turn-boundary.test.ts`, plus regression assertions in `server/parse.test.ts` and `ui/parse-stdout.test.ts` ## Verification - `pnpm --filter @paperclip/grok-local test` — 23/23 adapter tests pass - `pnpm --filter @paperclip/grok-local typecheck` and UI typecheck — clean - Replayed an actual broken `grok 0.1.210` stream from the report; previously-merged boundaries (`` `ls`The ``, `returned:Confirmed`) now render with a separating newline; chunks inside un-closed backtick spans are left alone ## Risks - Low risk. Boundary insertion only fires when prev ends with `.`/`!`/`?`/balanced `` ` `` and next begins with an uppercase ≥2-char word, with no whitespace on either side. Worst case: a rare missed split or a misplaced newline inside reasoning — both purely cosmetic and confined to the live streaming panel. ## Model Used - Claude Opus 4.7 (claude-opus-4-7), Anthropic, extended thinking + tool use via Claude Code ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
81d18f2d77
commit
573e9ec909
8 changed files with 215 additions and 7 deletions
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { applyTurnBoundary, createTurnBoundaryState } from "./turn-boundary.js";
|
||||
|
||||
function run(chunks: string[]): string {
|
||||
const state = createTurnBoundaryState();
|
||||
return chunks.map((chunk) => applyTurnBoundary(state, chunk)).join("");
|
||||
}
|
||||
|
||||
describe("applyTurnBoundary", () => {
|
||||
it("inserts a newline when a closing backtick is followed by a new capitalized turn", () => {
|
||||
expect(run(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
|
||||
.toBe("The user uses `ls`\nThe `ls` returned");
|
||||
});
|
||||
|
||||
it("inserts a newline after sentence-ending punctuation glued to a capitalized word", () => {
|
||||
expect(run(["returned", ":", "Confirmed", ":", " 4 files"]))
|
||||
.toBe("returned:\nConfirmed: 4 files");
|
||||
});
|
||||
|
||||
it("does not break apart backtick-wrapped CamelCase identifiers within a turn", () => {
|
||||
expect(run(["render `", "React", "` then "]))
|
||||
.toBe("render `React` then ");
|
||||
});
|
||||
|
||||
it("leaves natural token streams with proper whitespace alone", () => {
|
||||
expect(run(["The", " user", " wants", " me", " to", ":\n", "1", ".", " List"]))
|
||||
.toBe("The user wants me to:\n1. List");
|
||||
});
|
||||
|
||||
it("does not insert a separator when the next chunk starts with whitespace", () => {
|
||||
expect(run(["function", ".", " They"]))
|
||||
.toBe("function. They");
|
||||
});
|
||||
|
||||
it("does not insert a separator when the next chunk starts lowercase", () => {
|
||||
expect(run(["`", "ls", "`"]))
|
||||
.toBe("`ls`");
|
||||
});
|
||||
|
||||
it("does not insert a separator when the next chunk is a single character", () => {
|
||||
expect(run([":", "A"]))
|
||||
.toBe(":A");
|
||||
});
|
||||
|
||||
it("does not insert a separator after a self-contained backtick span in a single chunk", () => {
|
||||
// Greptile review: a chunk like "`ls`" is a balanced span; the following
|
||||
// capitalized word should be treated as a continuation, not a new turn.
|
||||
expect(run(["`ls`", "Then"]))
|
||||
.toBe("`ls`Then");
|
||||
});
|
||||
});
|
||||
54
packages/adapters/grok-local/src/shared/turn-boundary.ts
Normal file
54
packages/adapters/grok-local/src/shared/turn-boundary.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Grok's `--output-format streaming-json` mode emits `thought` and `text` events
|
||||
// token-by-token. Between reasoning turns (around tool calls) it drops the `\n`
|
||||
// separator that the non-streaming `--output-format json` mode includes in the
|
||||
// aggregated `thought` field. This helper inserts a single `\n` when a new chunk
|
||||
// would otherwise glue two turns together (e.g. ``"`"`` then `"The"` => `` `The``).
|
||||
|
||||
export interface TurnBoundaryState {
|
||||
lastChunk: string;
|
||||
backtickParity: 0 | 1;
|
||||
}
|
||||
|
||||
export function createTurnBoundaryState(): TurnBoundaryState {
|
||||
return { lastChunk: "", backtickParity: 0 };
|
||||
}
|
||||
|
||||
function countBackticks(text: string): number {
|
||||
let count = 0;
|
||||
for (const ch of text) if (ch === "`") count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
function endsWithSentenceClose(ch: string): boolean {
|
||||
return ch === "." || ch === "?" || ch === "!" || ch === ":" || ch === ";";
|
||||
}
|
||||
|
||||
export function applyTurnBoundary(state: TurnBoundaryState, incoming: string): string {
|
||||
if (!incoming) return incoming;
|
||||
|
||||
let output = incoming;
|
||||
const prev = state.lastChunk;
|
||||
if (
|
||||
prev &&
|
||||
!/\s$/.test(prev) &&
|
||||
!/^\s/.test(incoming) &&
|
||||
/^[A-Z]/.test(incoming) &&
|
||||
incoming.length >= 2
|
||||
) {
|
||||
const lastChar = prev[prev.length - 1]!;
|
||||
// Narrow the backtick trigger to a lone closing-backtick chunk (e.g. the
|
||||
// stream "...`", "ls", "`" then "The"). A compound chunk like "`ls`" is a
|
||||
// self-contained span and the following capitalized word is a continuation,
|
||||
// not a new turn.
|
||||
const closingLoneBacktick =
|
||||
prev === "`" && state.backtickParity === 0;
|
||||
const looksLikeNewTurn = endsWithSentenceClose(lastChar) || closingLoneBacktick;
|
||||
if (looksLikeNewTurn) {
|
||||
output = `\n${incoming}`;
|
||||
}
|
||||
}
|
||||
|
||||
state.lastChunk = incoming;
|
||||
state.backtickParity = ((state.backtickParity + countBackticks(incoming)) % 2) as 0 | 1;
|
||||
return output;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue