| ... | @@ -38,6 +38,7 @@ export interface WorkLogEntry { | ... | @@ -38,6 +38,7 @@ export interface WorkLogEntry { |
| 38 | label: string; | 38 | label: string; |
| 39 | detail?: string; | 39 | detail?: string; |
| 40 | command?: string; | 40 | command?: string; |
| | 41 | rawCommand?: string; |
| 41 | changedFiles?: ReadonlyArray<string>; | 42 | changedFiles?: ReadonlyArray<string>; |
| 42 | tone: "thinking" | "tool" | "info" | "error"; | 43 | tone: "thinking" | "tool" | "info" | "error"; |
| 43 | toolTitle?: string; | 44 | toolTitle?: string; |
| ... | @@ -489,7 +490,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -489,7 +490,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 489 | activity.payload && typeof activity.payload === "object" | 490 | activity.payload && typeof activity.payload === "object" |
| 490 | ? (activity.payload as Record<string, unknown>) | 491 | ? (activity.payload as Record<string, unknown>) |
| 491 | : null; | 492 | : null; |
| 492 | const command = extractToolCommand(payload); | 493 | const commandPreview = extractToolCommand(payload); |
| 493 | const changedFiles = extractChangedFiles(payload); | 494 | const changedFiles = extractChangedFiles(payload); |
| 494 | const title = extractToolTitle(payload); | 495 | const title = extractToolTitle(payload); |
| 495 | const entry: DerivedWorkLogEntry = { | 496 | const entry: DerivedWorkLogEntry = { |
| ... | @@ -507,8 +508,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -507,8 +508,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 507 | entry.detail = detail; | 508 | entry.detail = detail; |
| 508 | } | 509 | } |
| 509 | } | 510 | } |
| 510 | if (command) { | 511 | if (commandPreview.command) { |
| 511 | entry.command = command; | 512 | entry.command = commandPreview.command; |
| | 513 | } |
| | 514 | if (commandPreview.rawCommand) { |
| | 515 | entry.rawCommand = commandPreview.rawCommand; |
| 512 | } | 516 | } |
| 513 | if (changedFiles.length > 0) { | 517 | if (changedFiles.length > 0) { |
| 514 | entry.changedFiles = changedFiles; | 518 | entry.changedFiles = changedFiles; |
| ... | @@ -567,6 +571,7 @@ function mergeDerivedWorkLogEntries( | ... | @@ -567,6 +571,7 @@ function mergeDerivedWorkLogEntries( |
| 567 | const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); | 571 | const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); |
| 568 | const detail = next.detail ?? previous.detail; | 572 | const detail = next.detail ?? previous.detail; |
| 569 | const command = next.command ?? previous.command; | 573 | const command = next.command ?? previous.command; |
| | 574 | const rawCommand = next.rawCommand ?? previous.rawCommand; |
| 570 | const toolTitle = next.toolTitle ?? previous.toolTitle; | 575 | const toolTitle = next.toolTitle ?? previous.toolTitle; |
| 571 | const itemType = next.itemType ?? previous.itemType; | 576 | const itemType = next.itemType ?? previous.itemType; |
| 572 | const requestKind = next.requestKind ?? previous.requestKind; | 577 | const requestKind = next.requestKind ?? previous.requestKind; |
| ... | @@ -576,6 +581,7 @@ function mergeDerivedWorkLogEntries( | ... | @@ -576,6 +581,7 @@ function mergeDerivedWorkLogEntries( |
| 576 | ...next, | 581 | ...next, |
| 577 | ...(detail ? { detail } : {}), | 582 | ...(detail ? { detail } : {}), |
| 578 | ...(command ? { command } : {}), | 583 | ...(command ? { command } : {}), |
| | 584 | ...(rawCommand ? { rawCommand } : {}), |
| 579 | ...(changedFiles.length > 0 ? { changedFiles } : {}), | 585 | ...(changedFiles.length > 0 ? { changedFiles } : {}), |
| 580 | ...(toolTitle ? { toolTitle } : {}), | 586 | ...(toolTitle ? { toolTitle } : {}), |
| 581 | ...(itemType ? { itemType } : {}), | 587 | ...(itemType ? { itemType } : {}), |
| ... | @@ -636,7 +642,121 @@ function asTrimmedString(value: unknown): string | null { | ... | @@ -636,7 +642,121 @@ function asTrimmedString(value: unknown): string | null { |
| 636 | return trimmed.length > 0 ? trimmed : null; | 642 | return trimmed.length > 0 ? trimmed : null; |
| 637 | } | 643 | } |
| 638 | | 644 | |
| 639 | function normalizeCommandValue(value: unknown): string | null { | 645 | function trimMatchingOuterQuotes(value: string): string { |
| | 646 | const trimmed = value.trim(); |
| | 647 | if ( |
| | 648 | (trimmed.startsWith("'") && trimmed.endsWith("'")) || |
| | 649 | (trimmed.startsWith('"') && trimmed.endsWith('"')) |
| | 650 | ) { |
| | 651 | const unquoted = trimmed.slice(1, -1).trim(); |
| | 652 | return unquoted.length > 0 ? unquoted : trimmed; |
| | 653 | } |
| | 654 | return trimmed; |
| | 655 | } |
| | 656 | |
| | 657 | function executableBasename(value: string): string | null { |
| | 658 | const trimmed = trimMatchingOuterQuotes(value); |
| | 659 | if (trimmed.length === 0) { |
| | 660 | return null; |
| | 661 | } |
| | 662 | const normalized = trimmed.replace(/\\/g, "/"); |
| | 663 | const segments = normalized.split("/"); |
| | 664 | const last = segments.at(-1)?.trim() ?? ""; |
| | 665 | return last.length > 0 ? last.toLowerCase() : null; |
| | 666 | } |
| | 667 | |
| | 668 | function splitExecutableAndRest(value: string): { executable: string; rest: string } | null { |
| | 669 | const trimmed = value.trim(); |
| | 670 | if (trimmed.length === 0) { |
| | 671 | return null; |
| | 672 | } |
| | 673 | |
| | 674 | if (trimmed.startsWith('"') || trimmed.startsWith("'")) { |
| | 675 | const quote = trimmed.charAt(0); |
| | 676 | const closeIndex = trimmed.indexOf(quote, 1); |
| | 677 | if (closeIndex <= 0) { |
| | 678 | return null; |
| | 679 | } |
| | 680 | return { |
| | 681 | executable: trimmed.slice(0, closeIndex + 1), |
| | 682 | rest: trimmed.slice(closeIndex + 1).trim(), |
| | 683 | }; |
| | 684 | } |
| | 685 | |
| | 686 | const firstWhitespace = trimmed.search(/\s/); |
| | 687 | if (firstWhitespace < 0) { |
| | 688 | return { |
| | 689 | executable: trimmed, |
| | 690 | rest: "", |
| | 691 | }; |
| | 692 | } |
| | 693 | |
| | 694 | return { |
| | 695 | executable: trimmed.slice(0, firstWhitespace), |
| | 696 | rest: trimmed.slice(firstWhitespace).trim(), |
| | 697 | }; |
| | 698 | } |
| | 699 | |
| | 700 | const SHELL_WRAPPER_SPECS = [ |
| | 701 | { |
| | 702 | executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"], |
| | 703 | wrapperFlagPattern: /(?:^|\s)-command\s+/i, |
| | 704 | }, |
| | 705 | { |
| | 706 | executables: ["cmd", "cmd.exe"], |
| | 707 | wrapperFlagPattern: /(?:^|\s)\/c\s+/i, |
| | 708 | }, |
| | 709 | { |
| | 710 | executables: ["bash", "sh", "zsh"], |
| | 711 | wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i, |
| | 712 | }, |
| | 713 | ] as const; |
| | 714 | |
| | 715 | function findShellWrapperSpec(shell: string) { |
| | 716 | return SHELL_WRAPPER_SPECS.find((spec) => |
| | 717 | (spec.executables as ReadonlyArray<string>).includes(shell), |
| | 718 | ); |
| | 719 | } |
| | 720 | |
| | 721 | function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { |
| | 722 | const match = wrapperFlagPattern.exec(value); |
| | 723 | if (!match) { |
| | 724 | return null; |
| | 725 | } |
| | 726 | |
| | 727 | const command = value.slice(match.index + match[0].length).trim(); |
| | 728 | if (command.length === 0) { |
| | 729 | return null; |
| | 730 | } |
| | 731 | |
| | 732 | const unwrapped = trimMatchingOuterQuotes(command); |
| | 733 | return unwrapped.length > 0 ? unwrapped : null; |
| | 734 | } |
| | 735 | |
| | 736 | function unwrapKnownShellCommandWrapper(value: string): string { |
| | 737 | const split = splitExecutableAndRest(value); |
| | 738 | if (!split || split.rest.length === 0) { |
| | 739 | return value; |
| | 740 | } |
| | 741 | |
| | 742 | const shell = executableBasename(split.executable); |
| | 743 | if (!shell) { |
| | 744 | return value; |
| | 745 | } |
| | 746 | |
| | 747 | const spec = findShellWrapperSpec(shell); |
| | 748 | if (!spec) { |
| | 749 | return value; |
| | 750 | } |
| | 751 | |
| | 752 | return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; |
| | 753 | } |
| | 754 | |
| | 755 | function formatCommandArrayPart(value: string): string { |
| | 756 | return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; |
| | 757 | } |
| | 758 | |
| | 759 | function formatCommandValue(value: unknown): string | null { |
| 640 | const direct = asTrimmedString(value); | 760 | const direct = asTrimmedString(value); |
| 641 | if (direct) { | 761 | if (direct) { |
| 642 | return direct; | 762 | return direct; |
| ... | @@ -647,21 +767,58 @@ function normalizeCommandValue(value: unknown): string | null { | ... | @@ -647,21 +767,58 @@ function normalizeCommandValue(value: unknown): string | null { |
| 647 | const parts = value | 767 | const parts = value |
| 648 | .map((entry) => asTrimmedString(entry)) | 768 | .map((entry) => asTrimmedString(entry)) |
| 649 | .filter((entry): entry is string => entry !== null); | 769 | .filter((entry): entry is string => entry !== null); |
| 650 | return parts.length > 0 ? parts.join(" ") : null; | 770 | if (parts.length === 0) { |
| | 771 | return null; |
| | 772 | } |
| | 773 | return parts.map((part) => formatCommandArrayPart(part)).join(" "); |
| 651 | } | 774 | } |
| 652 | | 775 | |
| 653 | function extractToolCommand(payload: Record<string, unknown> | null): string | null { | 776 | function normalizeCommandValue(value: unknown): string | null { |
| | 777 | const formatted = formatCommandValue(value); |
| | 778 | return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; |
| | 779 | } |
| | 780 | |
| | 781 | function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null { |
| | 782 | const formatted = formatCommandValue(value); |
| | 783 | if (!formatted || normalizedCommand === null) { |
| | 784 | return null; |
| | 785 | } |
| | 786 | return formatted === normalizedCommand ? null : formatted; |
| | 787 | } |
| | 788 | |
| | 789 | function extractToolCommand(payload: Record<string, unknown> | null): { |
| | 790 | command: string | null; |
| | 791 | rawCommand: string | null; |
| | 792 | } { |
| 654 | const data = asRecord(payload?.data); | 793 | const data = asRecord(payload?.data); |
| 655 | const item = asRecord(data?.item); | 794 | const item = asRecord(data?.item); |
| 656 | const itemResult = asRecord(item?.result); | 795 | const itemResult = asRecord(item?.result); |
| 657 | const itemInput = asRecord(item?.input); | 796 | const itemInput = asRecord(item?.input); |
| 658 | const candidates = [ | 797 | const itemType = asTrimmedString(payload?.itemType); |
| 659 | normalizeCommandValue(item?.command), | 798 | const detail = asTrimmedString(payload?.detail); |
| 660 | normalizeCommandValue(itemInput?.command), | 799 | const candidates: unknown[] = [ |
| 661 | normalizeCommandValue(itemResult?.command), | 800 | item?.command, |
| 662 | normalizeCommandValue(data?.command), | 801 | itemInput?.command, |
| | 802 | itemResult?.command, |
| | 803 | data?.command, |
| | 804 | itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, |
| 663 | ]; | 805 | ]; |
| 664 | return candidates.find((candidate) => candidate !== null) ?? null; | 806 | |
| | 807 | for (const candidate of candidates) { |
| | 808 | const command = normalizeCommandValue(candidate); |
| | 809 | if (!command) { |
| | 810 | continue; |
| | 811 | } |
| | 812 | return { |
| | 813 | command, |
| | 814 | rawCommand: toRawToolCommand(candidate, command), |
| | 815 | }; |
| | 816 | } |
| | 817 | |
| | 818 | return { |
| | 819 | command: null, |
| | 820 | rawCommand: null, |
| | 821 | }; |
| 665 | } | 822 | } |
| 666 | | 823 | |
| 667 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { | 824 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { |