@gajae-code/coding-agent 0.12.19 → 0.12.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +302 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/todo-contract.d.ts +10 -1
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +541 -226
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7057 -399
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/descriptors.test.ts +51 -0
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +82 -13
- package/src/tools/todo-write.ts +2 -5
- package/src/tui/output-block.ts +11 -1
package/src/task/executor.ts
CHANGED
|
@@ -41,7 +41,10 @@ import type { ArtifactManager } from "../session/artifacts";
|
|
|
41
41
|
import type { AuthStorage } from "../session/auth-storage";
|
|
42
42
|
import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
|
|
43
43
|
import { SessionManager } from "../session/session-manager";
|
|
44
|
+
import { FileSessionStorage } from "../session/session-storage";
|
|
44
45
|
import { truncateTail } from "../session/streaming-output";
|
|
46
|
+
// Ensure mandatory subagent result extraction is available even when a session is mocked.
|
|
47
|
+
import "../tools/yield";
|
|
45
48
|
import type { ContextFileEntry } from "../tools";
|
|
46
49
|
import { jtdToJsonSchema, normalizeSchema } from "../tools/jtd-to-json-schema";
|
|
47
50
|
import type { ReportFindingDetails } from "../tools/review";
|
|
@@ -285,7 +288,7 @@ export class ManagedTaskPersistence {
|
|
|
285
288
|
throw new Error("Managed task persistence authority is unavailable");
|
|
286
289
|
}
|
|
287
290
|
|
|
288
|
-
async openSession(cwd: string): Promise<SessionManager> {
|
|
291
|
+
async openSession(cwd: string, sessionMemoryMode: "off" | "shadow" | "enabled" = "shadow"): Promise<SessionManager> {
|
|
289
292
|
const store = this.#artifacts.getManagedStore();
|
|
290
293
|
if (!store) throw new Error("Managed task persistence authority is unavailable");
|
|
291
294
|
this.#artifacts.assertManagedBinding();
|
|
@@ -296,6 +299,7 @@ export class ManagedTaskPersistence {
|
|
|
296
299
|
store,
|
|
297
300
|
undefined,
|
|
298
301
|
cwd,
|
|
302
|
+
sessionMemoryMode,
|
|
299
303
|
);
|
|
300
304
|
this.#artifacts.assertManagedBinding();
|
|
301
305
|
return session;
|
|
@@ -1509,7 +1513,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1509
1513
|
options.parentActiveModelPattern,
|
|
1510
1514
|
modelRegistry,
|
|
1511
1515
|
settings,
|
|
1512
|
-
|
|
1516
|
+
canonicalChildScope,
|
|
1513
1517
|
{ managedFallback: true },
|
|
1514
1518
|
canonicalChildScope,
|
|
1515
1519
|
),
|
|
@@ -1555,10 +1559,18 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1555
1559
|
effectiveThinkingLevelForWarning = effectiveThinkingLevel;
|
|
1556
1560
|
|
|
1557
1561
|
const sessionManager = options.managedPersistence
|
|
1558
|
-
? await awaitAbortable(
|
|
1562
|
+
? await awaitAbortable(
|
|
1563
|
+
options.managedPersistence.openSession(worktree ?? cwd, subagentSettings.get("sessionMemory.mode")),
|
|
1564
|
+
)
|
|
1559
1565
|
: sessionFile
|
|
1560
1566
|
? await awaitAbortable(
|
|
1561
|
-
SessionManager.open(
|
|
1567
|
+
SessionManager.open(
|
|
1568
|
+
sessionFile,
|
|
1569
|
+
SessionManager.explicitDestination(path.dirname(sessionFile)),
|
|
1570
|
+
new FileSessionStorage(),
|
|
1571
|
+
subagentSettings.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain",
|
|
1572
|
+
subagentSettings.get("sessionMemory.mode"),
|
|
1573
|
+
),
|
|
1562
1574
|
)
|
|
1563
1575
|
: SessionManager.inMemory(worktree ?? cwd);
|
|
1564
1576
|
if (options.parentArtifactManager) {
|
|
@@ -1649,6 +1661,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1649
1661
|
authStorage,
|
|
1650
1662
|
modelRegistry,
|
|
1651
1663
|
settings: subagentSettings,
|
|
1664
|
+
providerSessionId: canonicalChildScope,
|
|
1652
1665
|
model,
|
|
1653
1666
|
thinkingLevel: effectiveThinkingLevel,
|
|
1654
1667
|
modelSubstitution:
|
package/src/tools/bash.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
|
|
3
3
|
import type { Component } from "@gajae-code/tui";
|
|
4
|
-
import { ImageProtocol, TERMINAL, Text } from "@gajae-code/tui";
|
|
4
|
+
import { getKeybindings, ImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui";
|
|
5
5
|
import { getProjectDir, isEnoent, logger, prompt } from "@gajae-code/utils";
|
|
6
6
|
import * as z from "zod/v4";
|
|
7
7
|
import { AsyncJobManager } from "../async";
|
|
@@ -32,7 +32,8 @@ import {
|
|
|
32
32
|
} from "../session/streaming-output";
|
|
33
33
|
|
|
34
34
|
import { renderStatusLine } from "../tui";
|
|
35
|
-
import { CachedOutputBlock } from "../tui/output-block";
|
|
35
|
+
import { CachedOutputBlock, getOutputBlockContentWidth } from "../tui/output-block";
|
|
36
|
+
import { truncateToWidth } from "../tui/utils";
|
|
36
37
|
import { getSixelLineMask } from "../utils/sixel";
|
|
37
38
|
import type { ToolSession } from ".";
|
|
38
39
|
import { checkBashAllowedPrefixes, normalizeReadOnlyBashCommand } from "./bash-allowed-prefixes";
|
|
@@ -1635,6 +1636,7 @@ export interface ShellRendererConfig<TArgs> {
|
|
|
1635
1636
|
resolveCommand?: (args: TArgs | undefined) => string | undefined;
|
|
1636
1637
|
resolveCwd?: (args: TArgs | undefined) => string | undefined;
|
|
1637
1638
|
resolveEnv?: (args: TArgs | undefined) => Record<string, string> | undefined;
|
|
1639
|
+
compactCommand?: boolean;
|
|
1638
1640
|
}
|
|
1639
1641
|
|
|
1640
1642
|
function getPartialJson<TArgs>(args: TArgs | undefined): string | undefined {
|
|
@@ -1682,6 +1684,85 @@ export function formatBashCommandLines(args: BashRenderArgs, uiTheme: Theme): st
|
|
|
1682
1684
|
return highlightedLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
|
|
1683
1685
|
}
|
|
1684
1686
|
|
|
1687
|
+
const BASH_COLLAPSED_COMMAND_MAX_VISUAL_ROWS = 5;
|
|
1688
|
+
const BASH_COMMAND_SENTINEL_MAX_VISUAL_ROWS = 3;
|
|
1689
|
+
|
|
1690
|
+
interface BashCommandProjection {
|
|
1691
|
+
lines: string[];
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function resolveBashExpandKeyLabel(): string | undefined {
|
|
1695
|
+
return getKeybindings().getKeys("app.tools.expand")[0];
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function formatBashExpandActionHint(maxWidth?: number): string {
|
|
1699
|
+
const keyLabel = resolveBashExpandKeyLabel();
|
|
1700
|
+
const actionHint = keyLabel ? `${keyLabel} to expand` : "expand tools";
|
|
1701
|
+
return maxWidth !== undefined && visibleWidth(actionHint) > Math.max(1, maxWidth) ? "expand tools" : actionHint;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function renderBashCommandProjection(
|
|
1705
|
+
commandLines: string[],
|
|
1706
|
+
width: number,
|
|
1707
|
+
expanded: boolean,
|
|
1708
|
+
actionHint: string,
|
|
1709
|
+
uiTheme: Theme,
|
|
1710
|
+
): BashCommandProjection {
|
|
1711
|
+
const renderWidth = Math.max(1, width);
|
|
1712
|
+
const visualLines = new Text(commandLines.join("\n"), 0, 0).render(renderWidth);
|
|
1713
|
+
if (expanded || visualLines.length <= BASH_COLLAPSED_COMMAND_MAX_VISUAL_ROWS) {
|
|
1714
|
+
return { lines: visualLines };
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const retained = visualLines.slice(0, BASH_COLLAPSED_COMMAND_MAX_VISUAL_ROWS);
|
|
1718
|
+
const omittedVisualRows = visualLines.length - retained.length;
|
|
1719
|
+
let sentinel = new Text(
|
|
1720
|
+
uiTheme.fg("dim", `… ${omittedVisualRows} command rows omitted\n${actionHint}`),
|
|
1721
|
+
0,
|
|
1722
|
+
0,
|
|
1723
|
+
).render(renderWidth);
|
|
1724
|
+
|
|
1725
|
+
if (sentinel.length > BASH_COMMAND_SENTINEL_MAX_VISUAL_ROWS) {
|
|
1726
|
+
sentinel = new Text(uiTheme.fg("dim", `… ${omittedVisualRows} rows omitted\nexpand tools`), 0, 0).render(
|
|
1727
|
+
renderWidth,
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
if (sentinel.length > BASH_COMMAND_SENTINEL_MAX_VISUAL_ROWS) {
|
|
1731
|
+
sentinel = [
|
|
1732
|
+
uiTheme.fg("dim", truncateToWidth(`… +${omittedVisualRows}`, renderWidth)),
|
|
1733
|
+
uiTheme.fg("dim", truncateToWidth("expand tools", renderWidth)),
|
|
1734
|
+
];
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
return { lines: [...retained, ...sentinel] };
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function createBashCommandProjector(commandLines: string[], uiTheme: Theme) {
|
|
1741
|
+
let cached:
|
|
1742
|
+
| {
|
|
1743
|
+
width: number;
|
|
1744
|
+
expanded: boolean;
|
|
1745
|
+
actionHint: string;
|
|
1746
|
+
projection: BashCommandProjection;
|
|
1747
|
+
}
|
|
1748
|
+
| undefined;
|
|
1749
|
+
|
|
1750
|
+
return {
|
|
1751
|
+
render(width: number, expanded: boolean): BashCommandProjection {
|
|
1752
|
+
const actionHint = formatBashExpandActionHint(width);
|
|
1753
|
+
if (cached?.width === width && cached.expanded === expanded && cached.actionHint === actionHint) {
|
|
1754
|
+
return cached.projection;
|
|
1755
|
+
}
|
|
1756
|
+
const projection = renderBashCommandProjection(commandLines, width, expanded, actionHint, uiTheme);
|
|
1757
|
+
cached = { width, expanded, actionHint, projection };
|
|
1758
|
+
return projection;
|
|
1759
|
+
},
|
|
1760
|
+
invalidate(): void {
|
|
1761
|
+
cached = undefined;
|
|
1762
|
+
},
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1685
1766
|
function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
|
|
1686
1767
|
return {
|
|
1687
1768
|
command: config.resolveCommand?.(args),
|
|
@@ -1695,10 +1776,23 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1695
1776
|
return {
|
|
1696
1777
|
renderCall(args: TArgs, options: RenderResultOptions, uiTheme: Theme): Component {
|
|
1697
1778
|
const renderArgs = toBashRenderArgs(args, config);
|
|
1698
|
-
const cmdText = formatBashCommand(renderArgs);
|
|
1699
1779
|
const title = config.resolveTitle(args, options);
|
|
1700
|
-
|
|
1701
|
-
|
|
1780
|
+
if (!config.compactCommand) {
|
|
1781
|
+
const cmdText = formatBashCommand(renderArgs);
|
|
1782
|
+
const text = renderStatusLine({ icon: "pending", title, description: cmdText }, uiTheme);
|
|
1783
|
+
return new Text(text, 0, 0);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const header = renderStatusLine({ icon: "pending", title }, uiTheme);
|
|
1787
|
+
const projectCommand = createBashCommandProjector(formatBashCommandLines(renderArgs, uiTheme), uiTheme);
|
|
1788
|
+
const renderOptions = options as RenderResultOptions & { renderContext?: BashRenderContext };
|
|
1789
|
+
return {
|
|
1790
|
+
render: (width: number): string[] => {
|
|
1791
|
+
const expanded = renderOptions.renderContext?.expanded ?? renderOptions.expanded;
|
|
1792
|
+
return [header, ...projectCommand.render(width, expanded).lines];
|
|
1793
|
+
},
|
|
1794
|
+
invalidate: () => projectCommand.invalidate(),
|
|
1795
|
+
};
|
|
1702
1796
|
},
|
|
1703
1797
|
|
|
1704
1798
|
renderResult(
|
|
@@ -1713,6 +1807,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1713
1807
|
): Component {
|
|
1714
1808
|
const renderArgs = toBashRenderArgs(args, config);
|
|
1715
1809
|
const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
|
|
1810
|
+
const projectCommand =
|
|
1811
|
+
config.compactCommand && cmdLines ? createBashCommandProjector(cmdLines, uiTheme) : undefined;
|
|
1716
1812
|
const isError = result.isError === true;
|
|
1717
1813
|
const icon = options.isPartial ? "pending" : isError ? "error" : "success";
|
|
1718
1814
|
const title = config.resolveTitle(args, options);
|
|
@@ -1726,6 +1822,10 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1726
1822
|
const { renderContext } = options;
|
|
1727
1823
|
const expanded = renderContext?.expanded ?? options.expanded;
|
|
1728
1824
|
const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
|
|
1825
|
+
const commandLines =
|
|
1826
|
+
cmdLines && projectCommand
|
|
1827
|
+
? projectCommand.render(getOutputBlockContentWidth(width, uiTheme), expanded).lines
|
|
1828
|
+
: (cmdLines ?? []);
|
|
1729
1829
|
|
|
1730
1830
|
// Get output from context (preferred) or fall back to result content.
|
|
1731
1831
|
// Strip the LLM-facing notice appended by wrappedExecute so we don't
|
|
@@ -1778,7 +1878,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1778
1878
|
outputLines.push(
|
|
1779
1879
|
uiTheme.fg(
|
|
1780
1880
|
"dim",
|
|
1781
|
-
`… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (
|
|
1881
|
+
`… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (${formatBashExpandActionHint(getOutputBlockContentWidth(width, uiTheme))})`,
|
|
1782
1882
|
),
|
|
1783
1883
|
);
|
|
1784
1884
|
}
|
|
@@ -1793,7 +1893,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1793
1893
|
header,
|
|
1794
1894
|
state: options.isPartial ? "pending" : isError ? "error" : "success",
|
|
1795
1895
|
sections: [
|
|
1796
|
-
{ lines:
|
|
1896
|
+
{ lines: commandLines },
|
|
1797
1897
|
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
|
|
1798
1898
|
],
|
|
1799
1899
|
width,
|
|
@@ -1803,6 +1903,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1803
1903
|
},
|
|
1804
1904
|
invalidate: () => {
|
|
1805
1905
|
outputBlock.invalidate();
|
|
1906
|
+
projectCommand?.invalidate();
|
|
1806
1907
|
},
|
|
1807
1908
|
};
|
|
1808
1909
|
},
|
|
@@ -1816,4 +1917,5 @@ export const bashToolRenderer = createShellRenderer<BashRenderArgs>({
|
|
|
1816
1917
|
resolveCommand: args => args?.command,
|
|
1817
1918
|
resolveCwd: args => args?.cwd,
|
|
1818
1919
|
resolveEnv: args => args?.env,
|
|
1920
|
+
compactCommand: true,
|
|
1819
1921
|
});
|
|
@@ -485,6 +485,8 @@ describe("tool descriptor compatibility gate", () => {
|
|
|
485
485
|
{ ops: "not-an-array" },
|
|
486
486
|
{ ops: [{ op: "done", task: "x" }], stray: true },
|
|
487
487
|
{ _i: "Tracking progress", ops: [{ op: "done", task: "x" }] },
|
|
488
|
+
{ ops: [{ op: "retitle", task: "x", newTask: "y" }] },
|
|
489
|
+
{ ops: [{ op: "add", phase: "P", items: ["a"] }] },
|
|
488
490
|
];
|
|
489
491
|
for (const payload of payloads) {
|
|
490
492
|
expect(lazy.rawArgumentValidation(payload)).toEqual(eager.rawArgumentValidation(payload));
|
|
@@ -508,6 +510,55 @@ describe("tool descriptor compatibility gate", () => {
|
|
|
508
510
|
).toThrow("todo_write done and drop entries require a task or phase target");
|
|
509
511
|
});
|
|
510
512
|
|
|
513
|
+
test("deferred and loaded todo_write reject a bad payload with the same message", async () => {
|
|
514
|
+
const session = makeSession({ "tools.discoveryMode": "all" });
|
|
515
|
+
session.hasUI = true;
|
|
516
|
+
const lazy = (await createTools(session)).find(tool => tool.name === "todo_write");
|
|
517
|
+
const eager = await BUILTIN_TOOL_DESCRIPTORS.todo_write.load(session);
|
|
518
|
+
if (!lazy || !eager) throw new Error("expected deferred and loaded todo_write tools");
|
|
519
|
+
const capture = (run: () => unknown): string => {
|
|
520
|
+
try {
|
|
521
|
+
run();
|
|
522
|
+
} catch (error) {
|
|
523
|
+
return error instanceof Error ? error.message : String(error);
|
|
524
|
+
}
|
|
525
|
+
throw new Error("expected a raw-argument rejection");
|
|
526
|
+
};
|
|
527
|
+
const call = (arguments_: Record<string, unknown>) => ({
|
|
528
|
+
id: "call-parity",
|
|
529
|
+
type: "toolCall" as const,
|
|
530
|
+
name: "todo_write",
|
|
531
|
+
arguments: arguments_,
|
|
532
|
+
});
|
|
533
|
+
// The observed incident: four usable entries discarded alongside one
|
|
534
|
+
// invented op. Whichever path runs first must say the same thing, or the
|
|
535
|
+
// caller that hit the cold registry sees none of the added guidance.
|
|
536
|
+
const invalidOp = call({
|
|
537
|
+
ops: [
|
|
538
|
+
{ op: "append", phase: "Work", items: ["ship it"] },
|
|
539
|
+
{ op: "done", task: "first" },
|
|
540
|
+
{ op: "done", task: "second" },
|
|
541
|
+
{ op: "done", task: "third" },
|
|
542
|
+
{ op: "retitle", task: "third", newTask: "fourth" },
|
|
543
|
+
],
|
|
544
|
+
});
|
|
545
|
+
const lazyOpMessage = capture(() => validateToolArguments(lazy, invalidOp));
|
|
546
|
+
expect(lazyOpMessage).toBe(capture(() => validateToolArguments(eager, invalidOp)));
|
|
547
|
+
expect(lazyOpMessage).toContain("todo_write operation entries require a known op value");
|
|
548
|
+
expect(lazyOpMessage).toContain("op must be one of: init, start, done, rm, drop, append, note");
|
|
549
|
+
expect(lazyOpMessage).toContain("ops[4]");
|
|
550
|
+
|
|
551
|
+
const unknownKey = call({
|
|
552
|
+
ops: [
|
|
553
|
+
{ op: "done", task: "x" },
|
|
554
|
+
{ op: "done", task: "y", bogusOpKey: 1 },
|
|
555
|
+
],
|
|
556
|
+
});
|
|
557
|
+
const lazyKeyMessage = capture(() => validateToolArguments(lazy, unknownKey));
|
|
558
|
+
expect(lazyKeyMessage).toBe(capture(() => validateToolArguments(eager, unknownKey)));
|
|
559
|
+
expect(lazyKeyMessage).toContain('rejected key: "bogusOpKey" (ops[1])');
|
|
560
|
+
});
|
|
561
|
+
|
|
511
562
|
test("deferred ask validator recovers the canonical round-zero pair", async () => {
|
|
512
563
|
const session = makeSession({ "tools.discoveryMode": "all" });
|
|
513
564
|
session.hasUI = true;
|
package/src/tools/find.ts
CHANGED
|
@@ -59,6 +59,25 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000;
|
|
|
59
59
|
const MIN_GLOB_TIMEOUT_MS = 500;
|
|
60
60
|
const MAX_GLOB_TIMEOUT_MS = 60_000;
|
|
61
61
|
|
|
62
|
+
function comparePaths(left: string, right: string): number {
|
|
63
|
+
let leftIndex = 0;
|
|
64
|
+
let rightIndex = 0;
|
|
65
|
+
while (leftIndex < left.length && rightIndex < right.length) {
|
|
66
|
+
const leftCodePoint = left.codePointAt(leftIndex);
|
|
67
|
+
const rightCodePoint = right.codePointAt(rightIndex);
|
|
68
|
+
if (leftCodePoint === undefined || rightCodePoint === undefined) break;
|
|
69
|
+
if (leftCodePoint !== rightCodePoint) return leftCodePoint - rightCodePoint;
|
|
70
|
+
leftIndex += leftCodePoint > 0xffff ? 2 : 1;
|
|
71
|
+
rightIndex += rightCodePoint > 0xffff ? 2 : 1;
|
|
72
|
+
}
|
|
73
|
+
return left.length - right.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compareMtimePath(left: { path: string; mtime?: number }, right: { path: string; mtime?: number }): number {
|
|
77
|
+
const mtimeOrder = (right.mtime ?? 0) - (left.mtime ?? 0);
|
|
78
|
+
return mtimeOrder || comparePaths(left.path, right.path);
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
/**
|
|
63
82
|
* Reject comma-separated path lists packed into a single array element
|
|
64
83
|
* (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a
|
|
@@ -317,29 +336,43 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
317
336
|
}
|
|
318
337
|
|
|
319
338
|
let matches: natives.GlobMatch[];
|
|
320
|
-
const onUpdateMatches
|
|
339
|
+
const onUpdateMatches = new Map<string, number>();
|
|
340
|
+
const rankedUpdateMatches = (): Array<{ path: string; mtime: number }> =>
|
|
341
|
+
Array.from(onUpdateMatches, ([matchPath, mtime]) => ({ path: matchPath, mtime })).sort(compareMtimePath);
|
|
342
|
+
const retainUpdateMatch = (matchPath: string, mtime: number) => {
|
|
343
|
+
onUpdateMatches.set(matchPath, mtime);
|
|
344
|
+
if (onUpdateMatches.size <= effectiveLimit) return;
|
|
345
|
+
let worst: { path: string; mtime: number } | undefined;
|
|
346
|
+
for (const [candidatePath, candidateMtime] of onUpdateMatches) {
|
|
347
|
+
const candidate = { path: candidatePath, mtime: candidateMtime };
|
|
348
|
+
if (!worst || compareMtimePath(candidate, worst) > 0) worst = candidate;
|
|
349
|
+
}
|
|
350
|
+
if (worst) onUpdateMatches.delete(worst.path);
|
|
351
|
+
};
|
|
321
352
|
const updateIntervalMs = 200;
|
|
322
353
|
let lastUpdate = 0;
|
|
354
|
+
let terminal = false;
|
|
323
355
|
const emitUpdate = () => {
|
|
324
356
|
if (!onUpdate) return;
|
|
325
357
|
const now = Date.now();
|
|
326
358
|
if (now - lastUpdate < updateIntervalMs) return;
|
|
327
359
|
lastUpdate = now;
|
|
360
|
+
const ranked = rankedUpdateMatches().map(entry => entry.path);
|
|
328
361
|
const details: FindToolDetails = {
|
|
329
362
|
scopePath,
|
|
330
|
-
fileCount:
|
|
331
|
-
files:
|
|
363
|
+
fileCount: ranked.length,
|
|
364
|
+
files: ranked,
|
|
332
365
|
truncated: false,
|
|
333
366
|
};
|
|
334
367
|
onUpdate({
|
|
335
|
-
content: [{ type: "text", text:
|
|
368
|
+
content: [{ type: "text", text: ranked.join("\n") }],
|
|
336
369
|
details,
|
|
337
370
|
});
|
|
338
371
|
};
|
|
339
372
|
const onMatch = (err: Error | null, match: natives.GlobMatch | null) => {
|
|
340
|
-
if (err ||
|
|
373
|
+
if (err || terminal || combinedSignal.aborted || !match?.path) return;
|
|
341
374
|
const relativePath = formatMatchPath(match.path, match.fileType);
|
|
342
|
-
|
|
375
|
+
retainUpdateMatch(relativePath, match.mtime ?? 0);
|
|
343
376
|
emitUpdate();
|
|
344
377
|
};
|
|
345
378
|
|
|
@@ -355,6 +388,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
355
388
|
sortByMtime: true,
|
|
356
389
|
gitignore: useGitignore,
|
|
357
390
|
signal: combinedSignal,
|
|
391
|
+
timeoutMs,
|
|
358
392
|
},
|
|
359
393
|
onMatch,
|
|
360
394
|
),
|
|
@@ -363,34 +397,29 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
363
397
|
let timedOut = false;
|
|
364
398
|
try {
|
|
365
399
|
const result = await doGlob(useGitignore);
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
|
|
400
|
+
// Native uncached glob retains a bounded newest set; keep the same tie-break after callback delivery.
|
|
401
|
+
result.matches.sort(compareMtimePath);
|
|
369
402
|
matches = result.matches;
|
|
370
403
|
} catch (error) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
404
|
+
const nativeTimedOut = error instanceof Error && error.message === "Aborted: Timeout";
|
|
405
|
+
if (
|
|
406
|
+
(error instanceof Error && error.name === "AbortError" && timeoutSignal.aborted && !signal?.aborted) ||
|
|
407
|
+
(nativeTimedOut && !signal?.aborted)
|
|
408
|
+
) {
|
|
409
|
+
timedOut = true;
|
|
410
|
+
matches = [];
|
|
411
|
+
} else if (error instanceof Error && error.name === "AbortError") {
|
|
412
|
+
throw new ToolAbortError();
|
|
378
413
|
} else {
|
|
379
414
|
throw error;
|
|
380
415
|
}
|
|
416
|
+
} finally {
|
|
417
|
+
terminal = true;
|
|
381
418
|
}
|
|
382
419
|
|
|
383
420
|
if (timedOut) {
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
// caller to retry blind, which is the worst possible outcome.
|
|
387
|
-
const seen = new Set<string>();
|
|
388
|
-
const partial: string[] = [];
|
|
389
|
-
for (const entry of onUpdateMatches) {
|
|
390
|
-
if (seen.has(entry)) continue;
|
|
391
|
-
seen.add(entry);
|
|
392
|
-
partial.push(entry);
|
|
393
|
-
}
|
|
421
|
+
// Return the bounded, deterministically ranked candidates observed before timeout.
|
|
422
|
+
const partial = rankedUpdateMatches().map(entry => entry.path);
|
|
394
423
|
const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
|
|
395
424
|
const notice = `find timed out after ${seconds}s; returning ${partial.length} partial matches — increase timeout or narrow pattern`;
|
|
396
425
|
return buildResult(partial, { notice, forceTruncated: true });
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* tool ever loaded, with no correction hint to recover from.
|
|
9
9
|
*/
|
|
10
10
|
import { INTENT_FIELD } from "@gajae-code/agent-core";
|
|
11
|
-
import type { RawArgumentValidationResult } from "@gajae-code/ai/types";
|
|
11
|
+
import type { RawArgumentRejectionDetail, RawArgumentValidationResult } from "@gajae-code/ai/types";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Models reliably reach for `complete`/`completed` because the status an op
|
|
@@ -22,6 +22,23 @@ export function isDoneAlias(value: string): boolean {
|
|
|
22
22
|
return (TODO_DONE_ALIASES as readonly string[]).includes(value);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Legal `op` values. Exported so the todo_write schema and this contract read
|
|
27
|
+
* from one list: a rejection that names a vocabulary the schema no longer
|
|
28
|
+
* accepts is exactly the drift this file exists to prevent.
|
|
29
|
+
*/
|
|
30
|
+
export const TODO_OPS = ["init", "start", "done", "rm", "drop", "append", "note"] as const;
|
|
31
|
+
|
|
32
|
+
function isKnownOp(value: string): boolean {
|
|
33
|
+
return (TODO_OPS as readonly string[]).includes(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The whole vocabulary, not a guess at which entry the caller meant. Static
|
|
38
|
+
* text, so clamping a rejection detail can never truncate it away.
|
|
39
|
+
*/
|
|
40
|
+
const TODO_OP_VOCABULARY_HINT = `op must be one of: ${TODO_OPS.join(", ")}`;
|
|
41
|
+
|
|
25
42
|
/**
|
|
26
43
|
* `_i` is the intent field injected into tool schemas by the agent loop. The
|
|
27
44
|
* loop strips it before validation, but replayed, bridged, and directly
|
|
@@ -34,8 +51,30 @@ const TODO_INIT_ENTRY_KEYS = new Set(["phase", "items"]);
|
|
|
34
51
|
/** `content` is accepted only as a synonym the schema rewrites to `task`. */
|
|
35
52
|
const TODO_OP_KEYS_WITH_ALIASES = new Set([...TODO_OP_KEYS, "content"]);
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Exact key corrections. `note` is the one repeatable confusion: it is an `op`
|
|
56
|
+
* value, and the body of a note op goes in `text`, so a caller who wrote
|
|
57
|
+
* `note: "..."` on an entry can be told exactly which fields a note op takes.
|
|
58
|
+
* The correction names both required fields rather than a literal rewrite,
|
|
59
|
+
* because a rewrite that omits `task` is rejected again on the retry. Only add
|
|
60
|
+
* an entry here when the replacement is unambiguous — never from fuzzy or
|
|
61
|
+
* edit-distance matching, because a wrong suggestion costs more turns than no
|
|
62
|
+
* suggestion.
|
|
63
|
+
*/
|
|
64
|
+
const TODO_KEY_CORRECTIONS = new Map<string, string>([
|
|
65
|
+
["note", 'note is an op, not a key; note operations require both "task" and "text"'],
|
|
66
|
+
["newTask", "there is no rename op; re-run init with the corrected list to rename a task"],
|
|
67
|
+
["tasks", 'tasks is not a key; append operations take "items"'],
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function unknownKeys(value: object, allowed: Set<string>): string[] {
|
|
71
|
+
return Object.keys(value).filter(key => !allowed.has(key));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function keyRejectionDetail(keys: readonly string[], location?: string): RawArgumentRejectionDetail {
|
|
75
|
+
const hints = keys.map(key => TODO_KEY_CORRECTIONS.get(key)).filter((hint): hint is string => hint !== undefined);
|
|
76
|
+
const parts = location === undefined ? hints : [location, ...hints];
|
|
77
|
+
return parts.length > 0 ? { rejectedKeys: keys, hint: parts.join("; ") } : { rejectedKeys: keys };
|
|
39
78
|
}
|
|
40
79
|
|
|
41
80
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -45,27 +84,57 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
45
84
|
/**
|
|
46
85
|
* Single source of truth for todo_write pre-coercion validation. Every
|
|
47
86
|
* rejection carries a code so the caller can surface an actionable correction
|
|
48
|
-
* instead of a bare "raw arguments rejected" message
|
|
87
|
+
* instead of a bare "raw arguments rejected" message, and every entry-level
|
|
88
|
+
* rejection names the failing `ops` index. Rejection stays batch-atomic, but a
|
|
89
|
+
* caller told which entry failed can resubmit the rest instead of rebuilding
|
|
90
|
+
* the payload from memory — or, as observed, silently abandoning the list.
|
|
49
91
|
*/
|
|
50
92
|
export function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgumentValidationResult {
|
|
51
|
-
|
|
93
|
+
const unknownRootKeys = unknownKeys(arguments_, TODO_WRITE_KEYS);
|
|
94
|
+
if (unknownRootKeys.length > 0)
|
|
95
|
+
return { outcome: "reject", code: "todo-write-unknown-root-key", detail: keyRejectionDetail(unknownRootKeys) };
|
|
52
96
|
if (!Array.isArray(arguments_.ops)) return { outcome: "passthrough" };
|
|
53
|
-
|
|
97
|
+
const ops: readonly unknown[] = arguments_.ops;
|
|
98
|
+
for (const [index, entry] of ops.entries()) {
|
|
54
99
|
if (!isPlainRecord(entry)) continue;
|
|
100
|
+
const location = `ops[${index}]`;
|
|
101
|
+
// The op value is checked before the entry keys. An entry that invents
|
|
102
|
+
// both an op and a key is an unknown-op problem, and reporting the key
|
|
103
|
+
// first tells the caller which keys are legal while leaving it to guess
|
|
104
|
+
// that the operation it reached for does not exist at all.
|
|
105
|
+
const rawOp = entry.op;
|
|
106
|
+
const op = typeof rawOp === "string" && isDoneAlias(rawOp) ? "done" : rawOp;
|
|
107
|
+
if (typeof op === "string" && !isKnownOp(op))
|
|
108
|
+
return {
|
|
109
|
+
outcome: "reject",
|
|
110
|
+
code: "todo-write-unknown-op-value",
|
|
111
|
+
detail: { rejectedKeys: [op], hint: `${location}; ${TODO_OP_VOCABULARY_HINT}` },
|
|
112
|
+
};
|
|
55
113
|
// `content` is normalized to `task` by the schema preprocessor, so it must
|
|
56
114
|
// not be rejected here as an unknown key first.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
115
|
+
const unknownEntryKeys = unknownKeys(entry, TODO_OP_KEYS_WITH_ALIASES);
|
|
116
|
+
if (unknownEntryKeys.length > 0)
|
|
117
|
+
return {
|
|
118
|
+
outcome: "reject",
|
|
119
|
+
code: "todo-write-unknown-op-entry-key",
|
|
120
|
+
detail: keyRejectionDetail(unknownEntryKeys, location),
|
|
121
|
+
};
|
|
60
122
|
const target = entry.task ?? entry.content;
|
|
61
123
|
if ((op === "done" || op === "drop") && !target && !entry.phase) {
|
|
62
|
-
return { outcome: "reject", code: "todo-write-done-drop-requires-target" };
|
|
124
|
+
return { outcome: "reject", code: "todo-write-done-drop-requires-target", detail: { hint: location } };
|
|
63
125
|
}
|
|
64
126
|
const list = entry.list;
|
|
65
127
|
if (!Array.isArray(list)) continue;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
128
|
+
const items: readonly unknown[] = list;
|
|
129
|
+
for (const [itemIndex, item] of items.entries()) {
|
|
130
|
+
if (!isPlainRecord(item)) continue;
|
|
131
|
+
const unknownItemKeys = unknownKeys(item, TODO_INIT_ENTRY_KEYS);
|
|
132
|
+
if (unknownItemKeys.length > 0)
|
|
133
|
+
return {
|
|
134
|
+
outcome: "reject",
|
|
135
|
+
code: "todo-write-unknown-init-entry-key",
|
|
136
|
+
detail: keyRejectionDetail(unknownItemKeys, `${location}.list[${itemIndex}]`),
|
|
137
|
+
};
|
|
69
138
|
}
|
|
70
139
|
}
|
|
71
140
|
return { outcome: "passthrough" };
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { ToolSession } from "../sdk";
|
|
|
11
11
|
import type { SessionEntry } from "../session/session-manager";
|
|
12
12
|
import { renderStatusLine, renderTreeList } from "../tui";
|
|
13
13
|
import { PREVIEW_LIMITS } from "./render-utils";
|
|
14
|
-
import { isDoneAlias, validateRawTodoArguments } from "./todo-contract";
|
|
14
|
+
import { isDoneAlias, TODO_OPS, validateRawTodoArguments } from "./todo-contract";
|
|
15
15
|
|
|
16
16
|
// =============================================================================
|
|
17
17
|
// Types
|
|
@@ -47,10 +47,7 @@ export interface TodoWriteToolDetails {
|
|
|
47
47
|
// =============================================================================
|
|
48
48
|
|
|
49
49
|
const TodoOp = z
|
|
50
|
-
.preprocess(
|
|
51
|
-
value => (typeof value === "string" && isDoneAlias(value) ? "done" : value),
|
|
52
|
-
z.enum(["init", "start", "done", "rm", "drop", "append", "note"] as const),
|
|
53
|
-
)
|
|
50
|
+
.preprocess(value => (typeof value === "string" && isDoneAlias(value) ? "done" : value), z.enum(TODO_OPS))
|
|
54
51
|
.describe('operation to apply; use "done" to complete a task');
|
|
55
52
|
|
|
56
53
|
const InitListEntry = z.object({
|
package/src/tui/output-block.ts
CHANGED
|
@@ -23,6 +23,16 @@ export interface OutputBlockOptions {
|
|
|
23
23
|
width: number;
|
|
24
24
|
applyBg?: boolean;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Return the width available to section content after the output block's
|
|
28
|
+
* visible left and right borders. Keep callers on the same layout contract as
|
|
29
|
+
* renderOutputBlock so width-aware projections are not wrapped a second time.
|
|
30
|
+
*/
|
|
31
|
+
export function getOutputBlockContentWidth(width: number, theme: Theme): number {
|
|
32
|
+
const lineWidth = Math.max(0, width);
|
|
33
|
+
const borderWidth = visibleWidth(`${theme.boxSharp.vertical} `) + visibleWidth(theme.boxSharp.vertical);
|
|
34
|
+
return Math.max(0, lineWidth - borderWidth);
|
|
35
|
+
}
|
|
26
36
|
|
|
27
37
|
export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
|
|
28
38
|
const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
|
|
@@ -70,7 +80,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
70
80
|
|
|
71
81
|
const contentPrefix = border(`${v} `);
|
|
72
82
|
const contentSuffix = border(v);
|
|
73
|
-
const contentWidth =
|
|
83
|
+
const contentWidth = getOutputBlockContentWidth(lineWidth, theme);
|
|
74
84
|
const lines: string[] = [];
|
|
75
85
|
|
|
76
86
|
lines.push(
|