diff.ts 4.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. /**
  2. * Result-time contextual-diff computation for the `write`/`edit` tools. Turns a
  3. * before/after pair of file texts into one {@link FileDiff} per applied hunk —
  4. * each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with
  5. * ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp
  6. * renders an editor inline diff.
  7. *
  8. * This is display-only presentation vocabulary (a UI concern), so it lives in
  9. * the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns
  10. * only the raw before/after text (storage facts) and the tool computes the diff.
  11. *
  12. * @module @deepseek-ai/dsh-tool-fs/src/diff
  13. */
  14. import { structuredPatch } from 'diff'
  15. import type { FileDiff } from '@deepseek-ai/dsh-tools'
  16. /** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */
  17. export const DIFF_CONTEXT = 3
  18. /**
  19. * The `write`/`edit` tools' private `tool/result` `meta` payload: the applied
  20. * contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and
  21. * persisted with the session log — it must be JSON-serializable (the session
  22. * validates this at `append`), so `presentResult` reproduces the diff card on
  23. * replay. The producing tool owns this shape; the bridge only sees the opaque
  24. * `meta` and the tool narrows it back via {@link diffsFromMeta}.
  25. */
  26. export type FsDiffMeta = { diffs: FileDiff[] }
  27. /**
  28. * Compute one {@link FileDiff} per hunk between `before` and `after`, each
  29. * carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an
  30. * empty array when the texts are identical (no hunks). For a scattered
  31. * `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s
  32. * come back — matching the editor rendering one diff block per site.
  33. *
  34. * Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`;
  35. * `newText` is its `+` (added) and context lines. A hunk with no old lines
  36. * (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring
  37. * the call-time card's new-file convention. The unified-diff "\ No newline at end
  38. * of file" markers are dropped — they annotate the patch, not file content.
  39. */
  40. export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] {
  41. const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT })
  42. const diffs: FileDiff[] = []
  43. for (const hunk of patch.hunks) {
  44. const oldLines: string[] = []
  45. const newLines: string[] = []
  46. for (const line of hunk.lines) {
  47. // The unified-diff marker for a missing trailing newline annotates the
  48. // patch, not the content — skip it so it never leaks into a diff block.
  49. if (line.startsWith('\\')) continue
  50. const text = line.slice(1)
  51. if (line.startsWith('-')) {
  52. oldLines.push(text)
  53. } else if (line.startsWith('+')) {
  54. newLines.push(text)
  55. } else {
  56. // A context (unchanged) line appears on both sides.
  57. oldLines.push(text)
  58. newLines.push(text)
  59. }
  60. }
  61. diffs.push({ path, oldText: oldLines.length > 0 ? oldLines.join('\n') : null, newText: newLines.join('\n') })
  62. }
  63. return diffs
  64. }
  65. /** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */
  66. function isFileDiff(value: unknown): value is FileDiff {
  67. if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
  68. const { path, oldText, newText } = value as Record<string, unknown>
  69. return typeof path === 'string'
  70. && (oldText === null || typeof oldText === 'string')
  71. && typeof newText === 'string'
  72. }
  73. /**
  74. * Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff}
  75. * hunks, or `undefined` when it is absent/malformed. `presentResult` runs on
  76. * arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so
  77. * it validates defensively rather than trusting the payload — a bad `meta` yields
  78. * `undefined`, and the caller decides the fallback (edit → the generic result
  79. * rendering; write → an args-derived whole-file diff), never a thrown presenter.
  80. */
  81. export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
  82. if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
  83. const diffs = (meta as Record<string, unknown>).diffs
  84. if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined
  85. return diffs
  86. }