Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 895173b176 | |||
| 45d5ae1601 | |||
| ec30e6c08a | |||
| 438ef091f9 | |||
| c39fab70c1 |
@@ -72,7 +72,10 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
|||||||
|
|
||||||
### 4. Push and PR to develop
|
### 4. Push and PR to develop
|
||||||
|
|
||||||
PRs always target `develop`. The `claude_code` password lives in the macOS
|
PRs always target `develop`. Two different mechanisms are involved: **pushing
|
||||||
|
commits is git-native** (the Gitea MCP cannot push local git history, so the
|
||||||
|
branch is still pushed with `git push`), while **the PR itself is opened through
|
||||||
|
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
|
||||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||||
conflict with the owner's account in the git credential helper):
|
conflict with the owner's account in the git credential helper):
|
||||||
@@ -94,18 +97,24 @@ git remote set-url gitea "$ORIG_URL"
|
|||||||
unset AGENT_PASS SAFE_PASS
|
unset AGENT_PASS SAFE_PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea` —
|
||||||
|
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
|
||||||
|
Call `pull_request_write` with:
|
||||||
|
|
||||||
```bash
|
- `method: "create"`
|
||||||
curl -s -X POST \
|
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||||
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
|
- `base: "develop"`, `head: "<branch>"`
|
||||||
-H "Content-Type: application/json" \
|
- `title`, `body` — in the body: what was done, what is out of scope,
|
||||||
-d @pr_body.json \
|
verification results (tsc/lint/tests).
|
||||||
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
Manage and read PRs through the same server: `list_pull_requests`,
|
||||||
of scope, verification results (tsc/lint/tests).
|
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
|
||||||
|
`pull_request_review_write`.
|
||||||
|
|
||||||
|
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
|
||||||
|
with `get_me`), a different account from the `claude_code` used for git
|
||||||
|
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
|
||||||
|
the MCP account; the commits themselves stay authored as `claude_code`.
|
||||||
|
|
||||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||||
@@ -152,23 +161,25 @@ below.
|
|||||||
| Agent user (Gitea/git) | `claude_code` |
|
| Agent user (Gitea/git) | `claude_code` |
|
||||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||||
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
| Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
|
||||||
| Base branch | `develop` |
|
| Base branch | `develop` |
|
||||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||||
| `upstream` | The original Docmost — **never push** |
|
| `upstream` | The original Docmost — **never push** |
|
||||||
|
|
||||||
## Creating issues (Gitea `tea` CLI)
|
## Creating issues (Gitea MCP)
|
||||||
|
|
||||||
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
|
||||||
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
`issue_write` with:
|
||||||
|
|
||||||
```bash
|
- `method: "create"`
|
||||||
tea issues create --repo vvzvlad/gitmost --labels feature \
|
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||||
--title '<title>' --description "$(cat body.md)"
|
- `title`, `body`
|
||||||
```
|
- `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
|
||||||
|
such as `feature` to its id first with `label_read` (`method: "list"`), then
|
||||||
|
pass e.g. `labels: [<id>]`.
|
||||||
|
|
||||||
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
|
||||||
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { normalizeTableColumnWidths } from "./markdown-clipboard";
|
||||||
import {
|
|
||||||
normalizeTableColumnWidths,
|
|
||||||
classifyClipboardSelection,
|
|
||||||
} from "./markdown-clipboard";
|
|
||||||
|
|
||||||
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
||||||
function root(html: string): HTMLElement {
|
function root(html: string): HTMLElement {
|
||||||
@@ -128,171 +124,3 @@ describe("normalizeTableColumnWidths", () => {
|
|||||||
).toEqual([null, null]);
|
).toEqual([null, null]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("classifyClipboardSelection", () => {
|
|
||||||
it("serializes a list of 2+ items as markdown", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a single-item list as plain text", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a whole table without wrapping bare rows", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "table", childCount: 3 }]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a partial cell selection (bare rows) and flags wrapping", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves plain paragraphs as plain text", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wrap when rows are mixed with other block types", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
{ name: "paragraph", childCount: 1 },
|
|
||||||
]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Output-level tests for the table clipboard regression: copying a table must
|
|
||||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
|
||||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
|
||||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
|
||||||
// shape that the classifier-flag tests above do not cover.
|
|
||||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|
||||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
|
||||||
function lines(md: string): string[] {
|
|
||||||
return md
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
|
||||||
// of the padding turndown emits.
|
|
||||||
function isSeparatorRow(line: string): boolean {
|
|
||||||
const compact = line.replace(/\s+/g, "");
|
|
||||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split a pipe-delimited row into trimmed cell values.
|
|
||||||
function cells(line: string): string[] {
|
|
||||||
return line
|
|
||||||
.replace(/^\|/, "")
|
|
||||||
.replace(/\|$/, "")
|
|
||||||
.split("|")
|
|
||||||
.map((c) => c.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
|
||||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
|
||||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
|
||||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
|
||||||
// const table = document.createElement("table");
|
|
||||||
// const tbody = document.createElement("tbody");
|
|
||||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
|
||||||
// div.appendChild(table);
|
|
||||||
// return htmlToMarkdown(div.innerHTML);
|
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [c1, c2] of [
|
|
||||||
["a", "b"],
|
|
||||||
["c", "d"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = c1;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = c2;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
|
||||||
|
|
||||||
// Valid GFM: a header/data separator row is present (an empty header is
|
|
||||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
|
||||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
|
||||||
// and no line is a bare cell value on its own.
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
|
||||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
|
||||||
// The cell values land in real pipe-delimited data rows.
|
|
||||||
const dataRows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
|
||||||
expect(dataRows).toContainEqual(["a", "b"]);
|
|
||||||
expect(dataRows).toContainEqual(["c", "d"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
|
||||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
|
||||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
|
|
||||||
const thead = document.createElement("thead");
|
|
||||||
const headerRow = document.createElement("tr");
|
|
||||||
for (const h of ["Name", "Age"]) {
|
|
||||||
const th = document.createElement("th");
|
|
||||||
th.textContent = h;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
}
|
|
||||||
thead.appendChild(headerRow);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [name, age] of [
|
|
||||||
["Alice", "30"],
|
|
||||||
["Bob", "25"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = name;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = age;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
|
||||||
|
|
||||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
|
||||||
|
|
||||||
const rows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
|
||||||
// Header row comes first, followed by both data rows.
|
|
||||||
expect(rows[0]).toEqual(["Name", "Age"]);
|
|
||||||
expect(rows).toContainEqual(["Alice", "30"]);
|
|
||||||
expect(rows).toContainEqual(["Bob", "25"]);
|
|
||||||
// Headline regression: the table is NOT concatenated one-value-per-line.
|
|
||||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -27,36 +27,24 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
key: new PluginKey("markdownClipboard"),
|
key: new PluginKey("markdownClipboard"),
|
||||||
props: {
|
props: {
|
||||||
clipboardTextSerializer: (slice) => {
|
clipboardTextSerializer: (slice) => {
|
||||||
const topLevelNodes: { name: string; childCount: number }[] = [];
|
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||||
|
let topLevelCount = 0;
|
||||||
|
let hasList = false;
|
||||||
slice.content.forEach((node) => {
|
slice.content.forEach((node) => {
|
||||||
topLevelNodes.push({
|
if (listTypes.includes(node.type.name)) {
|
||||||
name: node.type.name,
|
hasList = true;
|
||||||
childCount: node.childCount,
|
topLevelCount += node.childCount;
|
||||||
});
|
} else {
|
||||||
|
topLevelCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { asMarkdown, wrapBareRows } =
|
if (!hasList || topLevelCount < 2) return null;
|
||||||
classifyClipboardSelection(topLevelNodes);
|
|
||||||
if (!asMarkdown) return null;
|
|
||||||
|
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||||
const fragment = serializer.serializeFragment(slice.content);
|
const fragment = serializer.serializeFragment(slice.content);
|
||||||
|
div.appendChild(fragment);
|
||||||
if (wrapBareRows) {
|
|
||||||
// A partial table cell-selection serializes to bare <tr> nodes
|
|
||||||
// (prosemirror-tables returns the whole `table` node only when the
|
|
||||||
// entire table is selected). Bare <tr> would be foster-parented
|
|
||||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
|
||||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
tbody.appendChild(fragment);
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
} else {
|
|
||||||
div.appendChild(fragment);
|
|
||||||
}
|
|
||||||
return htmlToMarkdown(div.innerHTML);
|
return htmlToMarkdown(div.innerHTML);
|
||||||
},
|
},
|
||||||
handlePaste: (view, event, slice) => {
|
handlePaste: (view, event, slice) => {
|
||||||
@@ -165,55 +153,6 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a copied slice's plain-text clipboard payload should be
|
|
||||||
* serialized as Markdown (instead of ProseMirror's default text serializer,
|
|
||||||
* which joins block leaves with newlines — the "one value per line" bug for
|
|
||||||
* tables).
|
|
||||||
*
|
|
||||||
* Serialize as Markdown for structured content:
|
|
||||||
* - lists with 2+ total items (a single copied bullet stays literal text);
|
|
||||||
* - a whole table (top-level `table` node);
|
|
||||||
* - a partial table cell-selection, which prosemirror-tables copies as bare
|
|
||||||
* `tableRow` nodes (only a full-table selection yields a `table` node).
|
|
||||||
*
|
|
||||||
* `wrapBareRows` flags the bare-rows case so the caller wraps the serialized
|
|
||||||
* <tr> nodes in <table><tbody> before the HTML->Markdown step. Plain paragraphs
|
|
||||||
* return asMarkdown=false so a simple text copy stays literal, and internal
|
|
||||||
* copy/paste keeps using the richer text/html clipboard payload.
|
|
||||||
*/
|
|
||||||
export function classifyClipboardSelection(
|
|
||||||
nodes: { name: string; childCount: number }[],
|
|
||||||
): { asMarkdown: boolean; wrapBareRows: boolean } {
|
|
||||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
|
||||||
let topLevelCount = 0;
|
|
||||||
let hasList = false;
|
|
||||||
let hasTable = false;
|
|
||||||
let tableRowCount = 0;
|
|
||||||
let nonRowCount = 0;
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (listTypes.includes(node.name)) {
|
|
||||||
hasList = true;
|
|
||||||
topLevelCount += node.childCount;
|
|
||||||
nonRowCount++;
|
|
||||||
} else {
|
|
||||||
if (node.name === "table") hasTable = true;
|
|
||||||
if (node.name === "tableRow") tableRowCount++;
|
|
||||||
else nonRowCount++;
|
|
||||||
topLevelCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bare tableRow nodes at the top level only occur for a partial cell
|
|
||||||
// selection; a slice never mixes bare rows with other block types, so
|
|
||||||
// "every top-level node is a row" is a safe signal to wrap-and-serialize.
|
|
||||||
const wrapBareRows = tableRowCount > 0 && nonRowCount === 0;
|
|
||||||
const asMarkdown =
|
|
||||||
(hasList && topLevelCount >= 2) || hasTable || wrapBareRows;
|
|
||||||
return { asMarkdown, wrapBareRows };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
|||||||
expect(prompt).toContain(NOTE_MARKER);
|
expect(prompt).toContain(NOTE_MARKER);
|
||||||
expect(prompt).toContain('-old line');
|
expect(prompt).toContain('-old line');
|
||||||
expect(prompt).toContain('+new line');
|
expect(prompt).toContain('+new line');
|
||||||
|
// Strengthened note (#274): instructs a fresh re-read via getPage and steers
|
||||||
|
// the agent toward small, targeted edits instead of a full-page overwrite.
|
||||||
|
expect(prompt).toContain('getPage');
|
||||||
|
expect(prompt.toLowerCase()).toContain('targeted');
|
||||||
|
expect(prompt).toContain('editPageText');
|
||||||
// Inside the safety sandwich: the trailing SAFETY block follows the note.
|
// Inside the safety sandwich: the trailing SAFETY block follows the note.
|
||||||
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
||||||
prompt.indexOf(NOTE_MARKER),
|
prompt.indexOf(NOTE_MARKER),
|
||||||
|
|||||||
@@ -85,11 +85,17 @@ const INTERRUPT_NOTE =
|
|||||||
const PAGE_CHANGED_NOTE =
|
const PAGE_CHANGED_NOTE =
|
||||||
'NOTE: The user edited the open page AFTER your last response in this ' +
|
'NOTE: The user edited the open page AFTER your last response in this ' +
|
||||||
'conversation, so any copy of that page you produced or remember from earlier ' +
|
'conversation, so any copy of that page you produced or remember from earlier ' +
|
||||||
'is now STALE. The unified diff below shows exactly what changed since you last ' +
|
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
|
||||||
'spoke (lines starting with "-" were removed, "+" were added) and is the source ' +
|
're-read its current content with the getPage tool and base your work on that ' +
|
||||||
'of truth. Preserve the user\'s edits: build on the current page, do not revert ' +
|
'live version — never on your earlier copy or on the transcript. The unified ' +
|
||||||
'or overwrite their changes. If you need the full up-to-date page, re-read it ' +
|
'diff below shows exactly what the user changed since you last spoke (lines ' +
|
||||||
'with the getPage tool before editing.';
|
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
|
||||||
|
'Preserve every one of the user\'s edits: make the smallest change that ' +
|
||||||
|
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
|
||||||
|
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
|
||||||
|
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
|
||||||
|
'truly unavoidable, start from the current getPage content and carry over all ' +
|
||||||
|
'of the user\'s edits.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
|
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
|
||||||
|
|||||||
@@ -356,6 +356,32 @@ describe('flushAssistant', () => {
|
|||||||
expect(flushed.toolCalls).not.toBeNull();
|
expect(flushed.toolCalls).not.toBeNull();
|
||||||
expect(flushed.metadata.error).toBe('boom');
|
expect(flushed.metadata.error).toBe('boom');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #274 observability: the page-change diff the agent saw this turn is persisted
|
||||||
|
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
|
||||||
|
// the diff is empty/whitespace or the arg is not supplied.
|
||||||
|
it('persists metadata.pageChanged when a non-empty diff was injected', () => {
|
||||||
|
const f = flushAssistant([], '', 'completed', {
|
||||||
|
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
});
|
||||||
|
expect(f.metadata.pageChanged).toEqual({
|
||||||
|
title: 'Doc',
|
||||||
|
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits metadata.pageChanged for an empty/whitespace diff or a missing arg', () => {
|
||||||
|
const whitespace = flushAssistant([], '', 'completed', {
|
||||||
|
pageChanged: { title: 'Doc', diff: ' \n ' },
|
||||||
|
});
|
||||||
|
expect('pageChanged' in whitespace.metadata).toBe(false);
|
||||||
|
|
||||||
|
const nullArg = flushAssistant([], '', 'completed', { pageChanged: null });
|
||||||
|
expect('pageChanged' in nullArg.metadata).toBe(false);
|
||||||
|
|
||||||
|
const omitted = flushAssistant([], '', 'streaming');
|
||||||
|
expect('pageChanged' in omitted.metadata).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// no-op (guarded below) so the turn still streams to the user.
|
// no-op (guarded below) so the turn still streams to the user.
|
||||||
let assistantId: string | undefined;
|
let assistantId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const seed = flushAssistant([], '', 'streaming');
|
const seed = flushAssistant([], '', 'streaming', { pageChanged });
|
||||||
const seeded = await this.aiChatMessageRepo.insert({
|
const seeded = await this.aiChatMessageRepo.insert({
|
||||||
chatId,
|
chatId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
@@ -720,7 +720,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
await this.aiChatMessageRepo.update(
|
await this.aiChatMessageRepo.update(
|
||||||
assistantId,
|
assistantId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
flushAssistant(capturedSteps, '', 'streaming'),
|
flushAssistant(capturedSteps, '', 'streaming', { pageChanged }),
|
||||||
{ onlyIfStreaming: true },
|
{ onlyIfStreaming: true },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -860,6 +860,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// resolved from the admin-configured provider settings (in
|
// resolved from the admin-configured provider settings (in
|
||||||
// closure scope here). Omitted/0 = no limit.
|
// closure scope here). Omitted/0 = no limit.
|
||||||
maxContextTokens: resolved?.chatContextWindow,
|
maxContextTokens: resolved?.chatContextWindow,
|
||||||
|
pageChanged,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Lifecycle: release the external MCP clients leased for this turn.
|
// Lifecycle: release the external MCP clients leased for this turn.
|
||||||
@@ -911,6 +912,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
await finalizeAssistant(
|
await finalizeAssistant(
|
||||||
flushAssistant(capturedSteps, inProgressText, 'error', {
|
flushAssistant(capturedSteps, inProgressText, 'error', {
|
||||||
error: errorText,
|
error: errorText,
|
||||||
|
pageChanged,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
@@ -940,7 +942,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
`steps=${steps.length}`,
|
`steps=${steps.length}`,
|
||||||
);
|
);
|
||||||
await finalizeAssistant(
|
await finalizeAssistant(
|
||||||
flushAssistant(capturedSteps, inProgressText, 'aborted'),
|
flushAssistant(capturedSteps, inProgressText, 'aborted', {
|
||||||
|
pageChanged,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
// Advance the page snapshot even on abort (#274): an agent edit that
|
// Advance the page snapshot even on abort (#274): an agent edit that
|
||||||
@@ -1506,6 +1510,7 @@ export function flushAssistant(
|
|||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
maxContextTokens?: number;
|
maxContextTokens?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
pageChanged?: { title: string; diff: string } | null;
|
||||||
},
|
},
|
||||||
): AssistantFlush {
|
): AssistantFlush {
|
||||||
const finished = capturedSteps ?? [];
|
const finished = capturedSteps ?? [];
|
||||||
@@ -1538,6 +1543,15 @@ export function flushAssistant(
|
|||||||
if (extra?.maxContextTokens)
|
if (extra?.maxContextTokens)
|
||||||
metadata.maxContextTokens = extra.maxContextTokens;
|
metadata.maxContextTokens = extra.maxContextTokens;
|
||||||
if (extra?.error) metadata.error = extra.error;
|
if (extra?.error) metadata.error = extra.error;
|
||||||
|
// Persist the page-change diff the agent saw this turn (#274 observability),
|
||||||
|
// so history / the Markdown export can show what the user changed. Only when
|
||||||
|
// a non-empty diff was actually injected into the prompt this turn.
|
||||||
|
if (extra?.pageChanged && extra.pageChanged.diff?.trim().length) {
|
||||||
|
metadata.pageChanged = {
|
||||||
|
title: extra.pageChanged.title,
|
||||||
|
diff: extra.pageChanged.diff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: stepsText + trailing,
|
content: stepsText + trailing,
|
||||||
|
|||||||
@@ -269,6 +269,168 @@ describe('buildChatMarkdown (server) — structure', () => {
|
|||||||
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #274 observability: an assistant row whose turn started with a user edit to
|
||||||
|
// the open page carries metadata.pageChanged = { title, diff }; the export
|
||||||
|
// renders the diff the agent saw, before the message body.
|
||||||
|
it('renders the persisted page-change diff block for an assistant row', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(md).toContain(
|
||||||
|
'The user edited this page before this turn; the diff the agent saw:',
|
||||||
|
);
|
||||||
|
expect(md).toContain('("Doc")');
|
||||||
|
expect(md).toContain('-old');
|
||||||
|
expect(md).toContain('+new');
|
||||||
|
// The diff sits before the message body (chronological: change, then reply).
|
||||||
|
expect(md.indexOf('-old')).toBeLessThan(md.indexOf('answer'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the page-change block when metadata.pageChanged is absent', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [row({ role: 'assistant', content: 'answer' })],
|
||||||
|
});
|
||||||
|
expect(md).not.toContain(
|
||||||
|
'The user edited this page before this turn; the diff the agent saw:',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 F1/F2: an empty page title must render the BARE heading with no
|
||||||
|
// `("…")` suffix (the `pc.title ? … : …` false branch).
|
||||||
|
it('renders the page-change heading with no title suffix when title is empty', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: { title: '', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Bare heading, single line, no parenthesized title.
|
||||||
|
expect(md).toContain(
|
||||||
|
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
||||||
|
);
|
||||||
|
expect(md).not.toContain('("');
|
||||||
|
expect(md).toContain('-old');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 F1: the page title is UNTRUSTED cross-user data, so a title carrying a
|
||||||
|
// newline / backtick / `"` / `<`/`>` must be neutralized by escapeAttr before
|
||||||
|
// it is interpolated into the `> **…**` blockquote heading — otherwise it
|
||||||
|
// could break the blockquote onto multiple lines or inject markup/HTML into
|
||||||
|
// the downloaded .md. escapeAttr strips `<>"` and collapses whitespace runs to
|
||||||
|
// a single space, so `Ev"il\n> `x` <b>` becomes ``Evil `x` b``.
|
||||||
|
it('escapes an untrusted page title in the page-change heading', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: {
|
||||||
|
title: 'Ev"il\n> `x` <b>',
|
||||||
|
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The heading stays a single blockquote line with the escaped title.
|
||||||
|
expect(md).toContain(
|
||||||
|
'> **📝 The user edited this page before this turn; the diff the agent saw: ("Evil `x` b")**',
|
||||||
|
);
|
||||||
|
// No raw attribute/markup breakers survived from the title.
|
||||||
|
expect(md).not.toContain('Ev"il');
|
||||||
|
expect(md).not.toContain('<b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 review F1: escapeAttr ALONE is insufficient for this MARKDOWN sink —
|
||||||
|
// link/image syntax survives it. A cross-user title with `` /
|
||||||
|
// `[phish](url)` must NOT become a working remote image or clickable link in
|
||||||
|
// the downloaded .md; markdownHeadingSafe backslash-escapes `[`/`]` so both are
|
||||||
|
// inert. (Non-vacuous: fails against the escapeAttr-only version, which left
|
||||||
|
// `](https://` intact.)
|
||||||
|
it('neutralizes markdown link/image syntax in an untrusted page title', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: {
|
||||||
|
title:
|
||||||
|
' and [click](https://phish.example)',
|
||||||
|
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// No WORKING image/link syntax survives — the `[…]` sits escaped as `\[…\]`,
|
||||||
|
// so the unescaped ``: after escaping the
|
||||||
|
// literal `\](https://` still contains `](https://` as a raw substring — that
|
||||||
|
// check would false-fail even though the link is inert.)
|
||||||
|
expect(md).not.toContain(';
|
||||||
|
expect(md).not.toContain('[click](');
|
||||||
|
// The brackets are backslash-escaped, so `[text](url)`/`` are inert.
|
||||||
|
expect(md).toContain('\\[');
|
||||||
|
expect(md).toContain('\\]');
|
||||||
|
// The heading stays a SINGLE blockquote line (no newline injected).
|
||||||
|
const headingLine = md
|
||||||
|
.split('\n')
|
||||||
|
.find((l) => l.includes('the diff the agent saw:'));
|
||||||
|
expect(headingLine).toBeDefined();
|
||||||
|
expect(headingLine).toContain('\\[x\\]');
|
||||||
|
expect(headingLine).toContain('\\[click\\]');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 internal review Finding 2: a NON-empty title made up entirely of
|
||||||
|
// escapeAttr breakers (`<>"`) escapes to '' — the ternary must then fall to the
|
||||||
|
// BARE heading with NO `("…")` suffix. Locks the ternary-on-escaped-value
|
||||||
|
// behavior (distinct from the empty-string input test above).
|
||||||
|
it('renders the bare heading for a title that escapes to empty', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: { title: '<>"', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(md).toContain(
|
||||||
|
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
||||||
|
);
|
||||||
|
expect(md).not.toContain('("');
|
||||||
|
expect(md).toContain('-old');
|
||||||
|
});
|
||||||
|
|
||||||
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
||||||
const md = buildChatMarkdown({
|
const md = buildChatMarkdown({
|
||||||
title: 'T',
|
title: 'T',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||||
|
import { escapeAttr } from './ai-chat.prompt';
|
||||||
|
|
||||||
/** Supported export label languages. Defaults to English. */
|
/** Supported export label languages. Defaults to English. */
|
||||||
export type ExportLang = 'en' | 'ru';
|
export type ExportLang = 'en' | 'ru';
|
||||||
@@ -63,6 +64,7 @@ const LABELS: Record<
|
|||||||
tools: Record<string, string>;
|
tools: Record<string, string>;
|
||||||
ranTool: (name: string) => string;
|
ranTool: (name: string) => string;
|
||||||
stillGenerating: string;
|
stillGenerating: string;
|
||||||
|
pageEditedByUser: string;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
en: {
|
en: {
|
||||||
@@ -83,6 +85,8 @@ const LABELS: Record<
|
|||||||
ranTool: (name) => `Ran tool ${name}`,
|
ranTool: (name) => `Ran tool ${name}`,
|
||||||
stillGenerating:
|
stillGenerating:
|
||||||
'This message is still being generated — the export captured a partial, in-progress response.',
|
'This message is still being generated — the export captured a partial, in-progress response.',
|
||||||
|
pageEditedByUser:
|
||||||
|
'The user edited this page before this turn; the diff the agent saw:',
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
untitled: 'Без названия',
|
untitled: 'Без названия',
|
||||||
@@ -102,9 +106,29 @@ const LABELS: Record<
|
|||||||
ranTool: (name) => `Выполнил инструмент ${name}`,
|
ranTool: (name) => `Выполнил инструмент ${name}`,
|
||||||
stillGenerating:
|
stillGenerating:
|
||||||
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
|
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
|
||||||
|
pageEditedByUser:
|
||||||
|
'Пользователь изменил страницу перед этим ходом; дифф, который видел агент:',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an untrusted title safe to interpolate into a Markdown blockquote
|
||||||
|
* HEADING. escapeAttr() neutralizes the XML/HTML breakers (`<` `>` `"`) and
|
||||||
|
* collapses whitespace for the PROMPT sink (`page="…"`), but this export sink is
|
||||||
|
* MARKDOWN — link/image syntax survives escapeAttr. So additionally backslash-
|
||||||
|
* escape `[` and `]`: that disables both `[text](url)` links and ``
|
||||||
|
* images, so a cross-user title like `` or `[phish](http://evil)`
|
||||||
|
* cannot inject a remote (auto-loading) image or a clickable link into the
|
||||||
|
* downloaded .md disguised as a trusted system annotation. A bare `(url)` with no
|
||||||
|
* preceding `[]` is inert Markdown, so brackets are the only security-critical
|
||||||
|
* characters here. (We leave backticks to escapeAttr's whitespace pass — a title
|
||||||
|
* shown as inline code cannot escape the blockquote line or load a resource, so
|
||||||
|
* it is not a security concern for this sink.)
|
||||||
|
*/
|
||||||
|
function markdownHeadingSafe(title: string): string {
|
||||||
|
return escapeAttr(title).replace(/[[\]]/g, (m) => `\\${m}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||||
function isToolPart(type: string): boolean {
|
function isToolPart(type: string): boolean {
|
||||||
return type.startsWith('tool-') || type === 'dynamic-tool';
|
return type.startsWith('tool-') || type === 'dynamic-tool';
|
||||||
@@ -208,6 +232,23 @@ function rowParts(row: AiChatMessage): ExportPart[] {
|
|||||||
: [{ type: 'text', text: row.content ?? '' }];
|
: [{ type: 'text', text: row.content ?? '' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The persisted page-change diff the agent saw this turn (#274), when any. */
|
||||||
|
function pageChangedOf(
|
||||||
|
row: AiChatMessage,
|
||||||
|
): { title: string; diff: string } | undefined {
|
||||||
|
const meta = (row.metadata ?? {}) as {
|
||||||
|
pageChanged?: { title?: string; diff?: string };
|
||||||
|
};
|
||||||
|
const pc = meta.pageChanged;
|
||||||
|
if (pc && typeof pc.diff === 'string' && pc.diff.trim().length > 0) {
|
||||||
|
return {
|
||||||
|
title: typeof pc.title === 'string' ? pc.title : '',
|
||||||
|
diff: pc.diff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
|
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
|
||||||
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
|
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
|
||||||
@@ -266,6 +307,26 @@ export function buildChatMarkdown(args: {
|
|||||||
blocks.push(`<!-- ${iso} -->`);
|
blocks.push(`<!-- ${iso} -->`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Page-change observability (#274): show the diff the agent saw at the start
|
||||||
|
// of this turn, before its response, so the export reflects the stale-page
|
||||||
|
// warning the model received.
|
||||||
|
const pc = pageChangedOf(row);
|
||||||
|
if (pc) {
|
||||||
|
// The page title is UNTRUSTED cross-user data (a collaborative page's title
|
||||||
|
// controllable by another user). escapeAttr() alone (the prompt sink) is
|
||||||
|
// INSUFFICIENT here: this is a MARKDOWN sink, so we neutralize link/image
|
||||||
|
// syntax too (backslash-escaping `[`/`]`) before interpolating it into this
|
||||||
|
// `> **…**` blockquote heading — otherwise `` / `[phish](url)` would
|
||||||
|
// inject a remote image or clickable link into the downloaded .md. An
|
||||||
|
// all-`<>"` title escapes to empty and correctly falls to the bare heading.
|
||||||
|
// The diff body is already safe via fence(). (#288 review F1.)
|
||||||
|
const safeTitle = markdownHeadingSafe(pc.title);
|
||||||
|
const heading = safeTitle
|
||||||
|
? `${L.pageEditedByUser} ("${safeTitle}")`
|
||||||
|
: L.pageEditedByUser;
|
||||||
|
blocks.push(`> **📝 ${heading}**\n\n${fence(pc.diff, 'diff')}`);
|
||||||
|
}
|
||||||
|
|
||||||
blocks.push(...renderMessageParts(rowParts(row), lang));
|
blocks.push(...renderMessageParts(rowParts(row), lang));
|
||||||
|
|
||||||
// A still-'streaming' row is an interrupted/in-progress turn captured by the
|
// A still-'streaming' row is an interrupted/in-progress turn captured by the
|
||||||
|
|||||||
Reference in New Issue
Block a user