Files
PaperClipAI/packages/adapters/grok-local/src/ui/parse-stdout.test.ts
T
Devin FoleyandPaperclip 573e9ec909 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>
2026-05-16 11:48:51 -07:00

71 lines
2.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createGrokStdoutParser, parseGrokStdoutLine } from "./parse-stdout.js";
describe("parseGrokStdoutLine", () => {
const ts = "2026-05-15T00:00:00.000Z";
it("maps thought/text/end events into transcript entries", () => {
expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([
{ kind: "thinking", ts, text: "Plan first.", delta: true },
]);
expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([
{ kind: "assistant", ts, text: "hello", delta: true },
]);
expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([
{ kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" },
]);
});
it("surfaces structured Grok error payload text", () => {
expect(parseGrokStdoutLine(JSON.stringify({
type: "error",
error: { message: "Authentication required" },
}), ts)).toEqual([
{ kind: "stderr", ts, text: "Authentication required" },
]);
});
});
describe("createGrokStdoutParser", () => {
const ts = "2026-05-15T00:00:00.000Z";
function thoughtTexts(chunks: string[]): string {
const parser = createGrokStdoutParser();
return chunks
.map((data) => parser.parseLine(JSON.stringify({ type: "thought", data }), ts))
.flat()
.map((entry) => entry.kind === "thinking" ? entry.text : "")
.join("");
}
it("inserts a newline between reasoning turns that grok streaming-json glues together", () => {
// Reproduces PAPA-349: token stream "...using `ls`" then a new turn "The `ls` command returned"
expect(thoughtTexts(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
.toBe("The user uses `ls`\nThe `ls` returned");
});
it("inserts a newline when a turn ends with a colon and the next turn starts capitalized", () => {
expect(thoughtTexts(["returned", ":", "Confirmed", ":", " 4 files"]))
.toBe("returned:\nConfirmed: 4 files");
});
it("resets state between independent transcript builds", () => {
const parser = createGrokStdoutParser();
parser.parseLine(JSON.stringify({ type: "thought", data: "first:" }), ts);
parser.reset();
expect(parser.parseLine(JSON.stringify({ type: "thought", data: "Second" }), ts)).toEqual([
{ kind: "thinking", ts, text: "Second", delta: true },
]);
});
it("does not modify assistant `text` chunks", () => {
// PAPA-349 review feedback: keep final assistant text streaming verbatim;
// the boundary heuristic is scoped to reasoning.
const parser = createGrokStdoutParser();
parser.parseLine(JSON.stringify({ type: "text", data: "Done." }), ts);
expect(parser.parseLine(JSON.stringify({ type: "text", data: "Next" }), ts)).toEqual([
{ kind: "assistant", ts, text: "Next", delta: true },
]);
});
});