Skip to content

Commit e81be1d

Browse files
committed
fix(agent): harden cancellation, provider handling and service worker updates
Guard native spreadsheet writes and SUM against delayed callbacks after cancellation, preserve undo ownership, and verify normalized numeric values. Cancel stale endpoint connections, preserve provider credentials, propagate abort reasons, and reject truncated Gemini responses. Retain assets needed by live pages, align silent-update regression tests with production builds, and remove redundant code and temporary artifacts.
1 parent b2e8f02 commit e81be1d

29 files changed

Lines changed: 1029 additions & 187 deletions

‎.gitignore‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ dist
1717
# test artifacts
1818
coverage/
1919
playwright-report/
20-
playwright-report-docker/
20+
playwright-report-*/
2121
test-results/
2222
public/fonts/__fallback__/
2323

@@ -37,11 +37,6 @@ corpus/
3737
corpus-src/
3838
dist-e2e-*/
3939
test-results-*/
40-
playwright-report-browsers/
41-
test-results-prod/
42-
playwright-report-prod/
43-
test-results-pages/
44-
playwright-report-pages/
4540
.wrangler/
4641

4742
# Pages rendered from markdown at build/dev time by bin/build-pages.mjs (vite
@@ -238,3 +233,6 @@ public/pt/index.html
238233

239234
# Locally mirrored model artifacts (deploy separately, keep out of Git).
240235
/public/models/
236+
237+
# Temporary ignore lists used by archive-aware quality checks.
238+
/.archive-quality-*.ignore

‎.scratch/sheetjs-upload-test.xlsx‎

-15.5 KB
Binary file not shown.

‎index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ if ('serviceWorker' in navigator) {
301301
void healStaleController({
302302
registration,
303303
waiting,
304+
ownScriptURL,
304305
controller: controllerAtBoot,
305306
controllerVersion: controllerVersionAtBoot,
306307
hasUnsavedChanges,

‎lib/agent-plugin/document-tool-action.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,16 @@ interface SheetModel {
2727
ranges: Array<{ c1: number; c2: number; r1: number; r2: number }>;
2828
activeCell: { col: number; row: number };
2929
};
30-
getRange3(r1: number, c1: number, r2: number, c2: number): { getValue(): string };
30+
getRange3(
31+
r1: number,
32+
c1: number,
33+
r2: number,
34+
c2: number,
35+
): {
36+
getValue(): string;
37+
getNumberValue?(): number | null;
38+
getValueData?(): { value: { type: number } } | null;
39+
};
3140
}
3241
interface ScopeApi extends EditorApi {
3342
isViewMode?: boolean;
@@ -177,7 +186,19 @@ export function captureDocumentToolTarget(): DocumentToolTarget {
177186
return true;
178187
if (cellEdit) {
179188
const cell = parseOfficeRange(String(plan.input.cell));
180-
if (model!.getRange3(cell.r1, cell.c1, cell.r2, cell.c2).getValue() === plan.input.value) return true;
189+
const stored = model!.getRange3(cell.r1, cell.c1, cell.r2, cell.c2);
190+
if (stored.getValue() === plan.input.value) return true;
191+
// Auto entry parses numbers; native storage normalizes 001, 1.0 and -0.
192+
// Literal text must retain every character and never use numeric equivalence.
193+
const value = String(plan.input.value);
194+
if (
195+
plan.input.valueType !== 'text' &&
196+
/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) &&
197+
Number.isFinite(Number(value)) &&
198+
stored.getValueData?.()?.value.type === 0 &&
199+
stored.getNumberValue?.() === Number(value)
200+
)
201+
return true;
181202
}
182203
if (attempt < 30) await new Promise((resolve) => setTimeout(resolve, 50));
183204
}

‎lib/agent-plugin/excel-cell-write.ts‎

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@ import { getEditorContext, requireEditorContext } from './editor-bridge';
22
import { getReadonlyMode } from '../onlyoffice/readonly';
33
import { parseOfficeRange } from './office-tools';
44
import { assertExcelTextWritable } from './excel-text-preflight';
5-
import { writeExcelLiteralText, type ExcelTextWriteScope } from './excel-text-write';
5+
import { writeExcelLiteralText, writeExcelNativeText, type ExcelTextWriteScope } from './excel-text-write';
66

77
/** Capture all native objects before selecting or changing the target. */
8-
export async function writeExcelCellText(address: string, text: string, signal?: AbortSignal): Promise<void> {
8+
export function writeExcelCellText(address: string, text: string, signal?: AbortSignal): Promise<void> {
9+
return writeExcelCellValue(address, text, signal, undefined, true);
10+
}
11+
12+
export async function writeExcelCellValue(
13+
address: string,
14+
text: string,
15+
signal?: AbortSignal,
16+
verify?: () => boolean,
17+
literal = false,
18+
): Promise<void> {
919
signal?.throwIfAborted();
1020
const bounds = parseOfficeRange(address);
1121
if (bounds.c1 !== bounds.c2 || bounds.r1 !== bounds.r2) throw new Error('officeInvalidRange');
@@ -84,42 +94,39 @@ export async function writeExcelCellText(address: string, text: string, signal?:
8494
);
8595
};
8696
api.asc_findCell(address.toUpperCase());
87-
await writeExcelLiteralText(
88-
{
89-
api: nativeApi,
90-
view: view!,
91-
model,
92-
cell,
93-
history: history as ExcelTextWriteScope['history'],
94-
nativeRange,
95-
readonly: getReadonlyMode(),
96-
codes: { BlockInteraction: type, ApplyChanges: action },
97-
clipboardFormat,
98-
closedGroupDescription: closed,
99-
isDocumentCurrent: documentCurrent,
100-
isCurrent: () => {
101-
const selection = model.selectionRange;
102-
return (
103-
documentCurrent() &&
104-
!getReadonlyMode() &&
105-
wb!.getWorksheet() === view &&
106-
getEditorContext()?.AscCommon?.g_specialPasteHelper === paste &&
107-
paste.Api === api &&
108-
selection.activeCell.row === bounds.r1 &&
109-
selection.activeCell.col === bounds.c1 &&
110-
selection.ranges.length === 1 &&
111-
selection.ranges.every(
112-
(range) =>
113-
range.r1 === bounds.r1 && range.r2 === bounds.r2 && range.c1 === bounds.c1 && range.c2 === bounds.c2,
114-
)
115-
);
116-
},
117-
endPaste: () => {
118-
if (getEditorContext()?.AscCommon?.g_specialPasteHelper === paste && paste.Api === api)
119-
paste.Paste_Process_End();
120-
},
97+
const scope: ExcelTextWriteScope = {
98+
api: nativeApi,
99+
view: view!,
100+
model,
101+
cell,
102+
history: history as ExcelTextWriteScope['history'],
103+
nativeRange,
104+
readonly: getReadonlyMode(),
105+
codes: { BlockInteraction: type, ApplyChanges: action },
106+
clipboardFormat,
107+
closedGroupDescription: closed,
108+
isDocumentCurrent: documentCurrent,
109+
isCurrent: () => {
110+
const selection = model.selectionRange;
111+
return (
112+
documentCurrent() &&
113+
!getReadonlyMode() &&
114+
wb!.getWorksheet() === view &&
115+
getEditorContext()?.AscCommon?.g_specialPasteHelper === paste &&
116+
paste.Api === api &&
117+
selection.activeCell.row === bounds.r1 &&
118+
selection.activeCell.col === bounds.c1 &&
119+
selection.ranges.length === 1 &&
120+
selection.ranges.every(
121+
(range) =>
122+
range.r1 === bounds.r1 && range.r2 === bounds.r2 && range.c1 === bounds.c1 && range.c2 === bounds.c2,
123+
)
124+
);
121125
},
122-
text,
123-
signal,
124-
);
126+
endPaste: () => {
127+
if (getEditorContext()?.AscCommon?.g_specialPasteHelper === paste && paste.Api === api) paste.Paste_Process_End();
128+
},
129+
};
130+
if (literal) await writeExcelLiteralText(scope, text, signal);
131+
else await writeExcelNativeText(scope, text, signal, false, verify);
125132
}

‎lib/agent-plugin/excel-text-write.ts‎

Lines changed: 71 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,24 @@ export interface ExcelTextWriteScope {
3535
}
3636

3737
/** Exact single-cell text insertion through the native paste pipeline. */
38-
export async function writeExcelLiteralText(
38+
export function writeExcelLiteralText(scope: ExcelTextWriteScope, text: string, signal?: AbortSignal): Promise<void> {
39+
return writeExcelNativeText(scope, text, signal, true);
40+
}
41+
42+
/** Native numeric/formula parsing with the same cancellation and history ownership as literal text. */
43+
export async function writeExcelNativeText(
3944
scope: ExcelTextWriteScope,
4045
text: string,
4146
signal?: AbortSignal,
47+
literal = false,
48+
verify?: () => boolean,
4249
): Promise<void> {
4350
signal?.throwIfAborted();
4451
if (typeof text !== 'string' || !text.length || text.length > 8000 || /[\t\r\n]/.test(text))
4552
throw new Error('Invalid document tool parameters');
4653
if (!scope.isCurrent()) throw new Error('Paste target has expired');
4754
assertExcelTextWritable(scope.api, scope.model, scope.cell, scope.nativeRange, scope.readonly);
48-
const format = captureExcelCellFormat(scope.cell);
55+
const format = literal ? captureExcelCellFormat(scope.cell) : undefined;
4956
let editOwned = true;
5057
let completedOwnership: (() => boolean) | undefined;
5158
// Navigation invalidates insertion, but does not transfer ownership of our
@@ -71,62 +78,72 @@ export async function writeExcelLiteralText(
7178
const targetCurrent = () => ownsGroup() && scope.isCurrent();
7279
let temporaryFormatAttempted = false;
7380
let pendingOwnership: (() => boolean) | undefined;
74-
await withExcelTextFormat(
75-
{
76-
originalFormat: format.originalFormat,
77-
isCurrent: targetCurrent,
78-
createPoint: () => scope.history.Create_NewPoint(),
79-
startTransaction: () => scope.history.StartTransaction(),
80-
endTransaction: () => {
81-
const owned = current();
82-
scope.history.EndTransaction();
83-
if (owned) captureCompletion();
84-
},
85-
setFormat: (value) => {
86-
if (temporaryFormatAttempted) {
87-
current();
88-
const currentFormat = scope.cell.getNumFormat()?.sFormat;
89-
if (!scope.isDocumentCurrent() || (currentFormat !== '@' && currentFormat !== format.originalFormat)) {
90-
editOwned = false;
91-
throw new Error('Paste target has expired');
81+
const paste = async () => {
82+
try {
83+
await pasteExcelText(scope.api, scope.view, scope.clipboardFormat, text, {
84+
signal,
85+
isCurrent: targetCurrent,
86+
endPaste: scope.endPaste,
87+
onNativeCompletion: captureCompletion,
88+
captureOwnership: () => {
89+
const proof = captureExcelHistoryOwnership(scope.history, ownsGroup);
90+
pendingOwnership = proof;
91+
return () => {
92+
const owned = proof();
93+
pendingOwnership = undefined;
94+
return owned;
95+
};
96+
},
97+
onOwnershipLost: () => {
98+
editOwned = false;
99+
},
100+
});
101+
} catch (error) {
102+
// Stop/timeout can settle before a native continuation checks its proof.
103+
if (pendingOwnership && !pendingOwnership()) editOwned = false;
104+
throw error;
105+
}
106+
};
107+
if (format)
108+
await withExcelTextFormat(
109+
{
110+
originalFormat: format.originalFormat,
111+
isCurrent: targetCurrent,
112+
createPoint: () => scope.history.Create_NewPoint(),
113+
startTransaction: () => scope.history.StartTransaction(),
114+
endTransaction: () => {
115+
const owned = current();
116+
scope.history.EndTransaction();
117+
if (owned) captureCompletion();
118+
},
119+
setFormat: (value) => {
120+
if (temporaryFormatAttempted) {
121+
current();
122+
const currentFormat = scope.cell.getNumFormat()?.sFormat;
123+
if (
124+
!scope.isDocumentCurrent() ||
125+
(currentFormat !== '@' && currentFormat !== format.originalFormat)
126+
) {
127+
editOwned = false;
128+
throw new Error('Paste target has expired');
129+
}
130+
if (currentFormat === format.originalFormat) return;
92131
}
93-
if (currentFormat === format.originalFormat) return;
94-
}
95-
temporaryFormatAttempted = true;
96-
format.setFormat(value);
97-
if (completedOwnership && editOwned) captureCompletion();
132+
temporaryFormatAttempted = true;
133+
format.setFormat(value);
134+
if (completedOwnership && editOwned) captureCompletion();
135+
},
98136
},
99-
},
100-
async () => {
101-
try {
102-
await pasteExcelText(scope.api, scope.view, scope.clipboardFormat, text, {
103-
signal,
104-
isCurrent: targetCurrent,
105-
endPaste: scope.endPaste,
106-
onNativeCompletion: captureCompletion,
107-
captureOwnership: () => {
108-
const proof = captureExcelHistoryOwnership(scope.history, ownsGroup);
109-
pendingOwnership = proof;
110-
return () => {
111-
const owned = proof();
112-
pendingOwnership = undefined;
113-
return owned;
114-
};
115-
},
116-
onOwnershipLost: () => {
117-
editOwned = false;
118-
},
119-
});
120-
} catch (error) {
121-
// Stop/timeout can settle before a native continuation checks its proof.
122-
if (pendingOwnership && !pendingOwnership()) editOwned = false;
123-
throw error;
124-
}
125-
},
126-
);
137+
paste,
138+
);
139+
else await paste();
127140
signal?.throwIfAborted();
128141
if (!targetCurrent()) throw new Error('Paste target has expired');
129-
if (scope.cell.getValue() !== text || scope.cell.getNumFormat()?.sFormat !== format.originalFormat)
142+
if (
143+
(format &&
144+
(scope.cell.getValue() !== text || scope.cell.getNumFormat()?.sFormat !== format.originalFormat)) ||
145+
(verify && !verify())
146+
)
130147
throw new Error('The change could not be verified. Check the document and use Undo if needed.');
131148
},
132149
() => scope.isDocumentCurrent(),

‎lib/agent-plugin/office-tools.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { writeExcelCellValue } from './excel-cell-write';
12
import type { AgentTool } from '@ranuts/agent-core/types';
23
import { requireEditorApi, getEditorContext, type EditorApi } from './editor-bridge';
34
import { pasteSlideText, type NativePasteApi } from './native-paste';
@@ -200,7 +201,8 @@ export const sumRangeTool: AgentTool<
200201
},
201202
// The optional target makes this a potentially mutating tool.
202203
readOnlyHint: false,
203-
execute: async ({ range, target }) => {
204+
execute: async ({ range, target }, signal) => {
205+
signal?.throwIfAborted();
204206
const bounds = parseOfficeRange(range);
205207
const destination = target === undefined ? undefined : parseOfficeRange(target);
206208
if (
@@ -235,14 +237,11 @@ export const sumRangeTool: AgentTool<
235237
ensureWritable(view, destination);
236238
const cell = model.getRange3(destination.r1, destination.c1, destination.r2, destination.c2);
237239
if (cell.getValue() || cell.getFormula() || cell.hasMerged?.()) fail('officeTargetNotEmpty');
238-
if (typeof api.asc_findCell !== 'function' || typeof api.pluginMethod_PasteText !== 'function')
239-
fail('officeSpreadsheetOnly');
240240
const formula = `SUM(${range.toUpperCase()})`;
241-
api.asc_findCell(target!);
242-
api.pluginMethod_PasteText(`=${formula}`);
243-
await verify(
244-
api,
245-
() => api.wb?.getWorksheet() === view,
241+
await writeExcelCellValue(
242+
target!,
243+
`=${formula}`,
244+
signal,
246245
() =>
247246
cell.getFormula().replace(/^=/, '').toUpperCase() === formula &&
248247
Math.abs((cell.getNumberValue() ?? NaN) - sum) <= 1e-9 * Math.max(1, Math.abs(sum)),

‎lib/agent-plugin/tools.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { setDocumentReviewMode } from './review-mode';
2-
import { writeExcelCellText } from './excel-cell-write';
2+
import { writeExcelCellText, writeExcelCellValue } from './excel-cell-write';
33
import { pasteWordHtml } from './word-paste';
44
import { addSlideTextTool, replaceSlideText } from './office-tools';
55
/**
@@ -309,12 +309,7 @@ export const setCellTool: AgentTool<SetCellParams, { cell: string; value: string
309309
await writeExcelCellText(cell, value, signal);
310310
return { cell, value };
311311
}
312-
const api = requireEditorApi();
313-
if (typeof api.asc_findCell !== 'function' || typeof api.pluginMethod_PasteText !== 'function') {
314-
throw new Error('set_cell is only available in the spreadsheet (Excel) editor');
315-
}
316-
api.asc_findCell(cell);
317-
api.pluginMethod_PasteText(value);
312+
await writeExcelCellValue(cell, value, signal);
318313
return { cell, value };
319314
},
320315
};

0 commit comments

Comments
 (0)